From 22afa04b6ca09236e571eeb65676daac7b0067ec Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Oct 2025 14:54:11 +0000 Subject: [PATCH 1/8] Initial plan From e8fbfe2168fcface45e2c9b04c18bbece5b429c3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Oct 2025 15:17:23 +0000 Subject: [PATCH 2/8] Implement engine.concurrency field to replace max-concurrency - Remove MaxConcurrency and ConcurrencyGroup fields from EngineConfig - Add Concurrency field to EngineConfig to store YAML-formatted concurrency config - Update ExtractEngineConfig to parse concurrency field (string or object format) - Update GenerateJobConcurrencyConfig to use new field with default gh-aw-{engine-id} - Update JSON schema to replace max-concurrency with concurrency field - Update tests to reflect new concurrency behavior - Add integration tests for concurrency field extraction and compilation Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/artifacts-summary.lock.yml | 2 + .github/workflows/brave.lock.yml | 2 + .../workflows/changeset-generator.lock.yml | 2 + .github/workflows/ci-doctor.lock.yml | 2 + .github/workflows/dev.lock.yml | 2 + .../duplicate-code-detector.lock.yml | 2 + .../workflows/go-pattern-detector.lock.yml | 2 + .github/workflows/issue-classifier.lock.yml | 2 + .github/workflows/pdf-summary.lock.yml | 2 + .github/workflows/poem-bot.lock.yml | 2 + .github/workflows/scout.lock.yml | 2 + .../workflows/technical-doc-writer.lock.yml | 2 + .github/workflows/tidy.lock.yml | 2 + pkg/parser/schemas/main_workflow_schema.json | 28 ++++- pkg/workflow/compiler.go | 8 +- pkg/workflow/concurrency.go | 29 ++--- pkg/workflow/concurrency_test.go | 51 +++++---- pkg/workflow/engine.go | 59 +++++----- .../engine_concurrency_integration_test.go | 105 ++++++++++++++++++ pkg/workflow/engine_concurrency_test.go | 94 ++++++++++++++++ 20 files changed, 325 insertions(+), 75 deletions(-) create mode 100644 pkg/workflow/engine_concurrency_integration_test.go create mode 100644 pkg/workflow/engine_concurrency_test.go diff --git a/.github/workflows/artifacts-summary.lock.yml b/.github/workflows/artifacts-summary.lock.yml index 5c0bd9ef6cf..b09c80abd72 100644 --- a/.github/workflows/artifacts-summary.lock.yml +++ b/.github/workflows/artifacts-summary.lock.yml @@ -119,6 +119,8 @@ jobs: permissions: actions: read contents: read + concurrency: + group: "gh-aw-copilot" env: GITHUB_AW_SAFE_OUTPUTS: /tmp/safe-outputs/outputs.jsonl GITHUB_AW_SAFE_OUTPUTS_CONFIG: "{\"create-issue\":{\"max\":1},\"missing-tool\":{}}" diff --git a/.github/workflows/brave.lock.yml b/.github/workflows/brave.lock.yml index 367df886b47..2cf1ae92507 100644 --- a/.github/workflows/brave.lock.yml +++ b/.github/workflows/brave.lock.yml @@ -428,6 +428,8 @@ jobs: permissions: actions: read contents: read + concurrency: + group: "gh-aw-copilot" env: GITHUB_AW_SAFE_OUTPUTS: /tmp/safe-outputs/outputs.jsonl GITHUB_AW_SAFE_OUTPUTS_CONFIG: "{\"add-comment\":{\"max\":1},\"missing-tool\":{}}" diff --git a/.github/workflows/changeset-generator.lock.yml b/.github/workflows/changeset-generator.lock.yml index 6fe00d82bc1..5b2f00c5e9a 100644 --- a/.github/workflows/changeset-generator.lock.yml +++ b/.github/workflows/changeset-generator.lock.yml @@ -271,6 +271,8 @@ jobs: permissions: contents: read pull-requests: read + concurrency: + group: "gh-aw-claude" env: GITHUB_AW_SAFE_OUTPUTS: /tmp/safe-outputs/outputs.jsonl GITHUB_AW_SAFE_OUTPUTS_CONFIG: "{\"missing-tool\":{},\"push-to-pull-request-branch\":{}}" diff --git a/.github/workflows/ci-doctor.lock.yml b/.github/workflows/ci-doctor.lock.yml index b7ae259cf05..bf20d72336e 100644 --- a/.github/workflows/ci-doctor.lock.yml +++ b/.github/workflows/ci-doctor.lock.yml @@ -70,6 +70,8 @@ jobs: needs: activation runs-on: ubuntu-latest permissions: read-all + concurrency: + group: "gh-aw-copilot" env: GITHUB_AW_SAFE_OUTPUTS: /tmp/safe-outputs/outputs.jsonl GITHUB_AW_SAFE_OUTPUTS_CONFIG: "{\"add-comment\":{\"max\":1},\"create-issue\":{\"max\":1},\"missing-tool\":{}}" diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml index 6103c1a9b29..ddaf8e0bd50 100644 --- a/.github/workflows/dev.lock.yml +++ b/.github/workflows/dev.lock.yml @@ -130,6 +130,8 @@ jobs: needs: activation runs-on: ubuntu-latest permissions: read-all + concurrency: + group: "gh-aw-claude" env: GITHUB_AW_SAFE_OUTPUTS: /tmp/safe-outputs/outputs.jsonl GITHUB_AW_SAFE_OUTPUTS_CONFIG: "{\"create-issue\":{\"max\":1},\"missing-tool\":{}}" diff --git a/.github/workflows/duplicate-code-detector.lock.yml b/.github/workflows/duplicate-code-detector.lock.yml index f215e0c9849..f30119b4f14 100644 --- a/.github/workflows/duplicate-code-detector.lock.yml +++ b/.github/workflows/duplicate-code-detector.lock.yml @@ -122,6 +122,8 @@ jobs: permissions: actions: read contents: read + concurrency: + group: "gh-aw-copilot" env: GITHUB_AW_SAFE_OUTPUTS: /tmp/safe-outputs/outputs.jsonl GITHUB_AW_SAFE_OUTPUTS_CONFIG: "{\"create-issue\":{\"max\":1},\"missing-tool\":{}}" diff --git a/.github/workflows/go-pattern-detector.lock.yml b/.github/workflows/go-pattern-detector.lock.yml index b5431ba2571..1905759d58f 100644 --- a/.github/workflows/go-pattern-detector.lock.yml +++ b/.github/workflows/go-pattern-detector.lock.yml @@ -126,6 +126,8 @@ jobs: permissions: actions: read contents: read + concurrency: + group: "gh-aw-claude" env: GITHUB_AW_SAFE_OUTPUTS: /tmp/safe-outputs/outputs.jsonl GITHUB_AW_SAFE_OUTPUTS_CONFIG: "{\"create-issue\":{\"max\":1},\"missing-tool\":{}}" diff --git a/.github/workflows/issue-classifier.lock.yml b/.github/workflows/issue-classifier.lock.yml index 6e90a140eca..0132d3c9775 100644 --- a/.github/workflows/issue-classifier.lock.yml +++ b/.github/workflows/issue-classifier.lock.yml @@ -425,6 +425,8 @@ jobs: actions: read contents: read models: read + concurrency: + group: "gh-aw-custom" env: GITHUB_AW_SAFE_OUTPUTS: /tmp/safe-outputs/outputs.jsonl GITHUB_AW_SAFE_OUTPUTS_CONFIG: "{\"add-labels\":{\"allowed\":[\"bug\",\"feature\",\"enhancement\",\"documentation\"],\"max\":1},\"missing-tool\":{}}" diff --git a/.github/workflows/pdf-summary.lock.yml b/.github/workflows/pdf-summary.lock.yml index dfb0c7cb29b..2f79934bf67 100644 --- a/.github/workflows/pdf-summary.lock.yml +++ b/.github/workflows/pdf-summary.lock.yml @@ -453,6 +453,8 @@ jobs: permissions: actions: read contents: read + concurrency: + group: "gh-aw-copilot" env: GITHUB_AW_SAFE_OUTPUTS: /tmp/safe-outputs/outputs.jsonl GITHUB_AW_SAFE_OUTPUTS_CONFIG: "{\"add-comment\":{\"max\":1},\"missing-tool\":{}}" diff --git a/.github/workflows/poem-bot.lock.yml b/.github/workflows/poem-bot.lock.yml index aa588ebf779..59264ffc224 100644 --- a/.github/workflows/poem-bot.lock.yml +++ b/.github/workflows/poem-bot.lock.yml @@ -436,6 +436,8 @@ jobs: permissions: actions: read contents: read + concurrency: + group: "gh-aw-copilot" env: GITHUB_AW_SAFE_OUTPUTS: /tmp/safe-outputs/outputs.jsonl GITHUB_AW_SAFE_OUTPUTS_CONFIG: "{\"add-comment\":{\"max\":3,\"target\":\"*\"},\"add-labels\":{\"allowed\":[\"poetry\",\"creative\",\"automation\",\"ai-generated\",\"epic\",\"haiku\",\"sonnet\",\"limerick\"],\"max\":5},\"create-issue\":{\"max\":2},\"create-pull-request\":{},\"create-pull-request-review-comment\":{\"max\":2},\"missing-tool\":{},\"push-to-pull-request-branch\":{},\"update-issue\":{\"max\":2},\"upload-asset\":{}}" diff --git a/.github/workflows/scout.lock.yml b/.github/workflows/scout.lock.yml index 6c8474c4826..8877d43962a 100644 --- a/.github/workflows/scout.lock.yml +++ b/.github/workflows/scout.lock.yml @@ -467,6 +467,8 @@ jobs: permissions: actions: read contents: read + concurrency: + group: "gh-aw-copilot" env: GITHUB_AW_SAFE_OUTPUTS: /tmp/safe-outputs/outputs.jsonl GITHUB_AW_SAFE_OUTPUTS_CONFIG: "{\"add-comment\":{\"max\":1},\"missing-tool\":{}}" diff --git a/.github/workflows/technical-doc-writer.lock.yml b/.github/workflows/technical-doc-writer.lock.yml index 4fa33d0fd74..1e0d86d34e0 100644 --- a/.github/workflows/technical-doc-writer.lock.yml +++ b/.github/workflows/technical-doc-writer.lock.yml @@ -120,6 +120,8 @@ jobs: needs: activation runs-on: ubuntu-latest permissions: read-all + concurrency: + group: "gh-aw-claude" env: GITHUB_AW_SAFE_OUTPUTS: /tmp/safe-outputs/outputs.jsonl GITHUB_AW_SAFE_OUTPUTS_CONFIG: "{\"add-comment\":{\"max\":1},\"create-pull-request\":{},\"missing-tool\":{},\"upload-asset\":{}}" diff --git a/.github/workflows/tidy.lock.yml b/.github/workflows/tidy.lock.yml index a9ae16be2a1..20616bb5319 100644 --- a/.github/workflows/tidy.lock.yml +++ b/.github/workflows/tidy.lock.yml @@ -289,6 +289,8 @@ jobs: permissions: actions: read contents: read + concurrency: + group: "gh-aw-copilot" env: GITHUB_AW_SAFE_OUTPUTS: /tmp/safe-outputs/outputs.jsonl GITHUB_AW_SAFE_OUTPUTS_CONFIG: "{\"create-pull-request\":{},\"missing-tool\":{},\"push-to-pull-request-branch\":{}}" diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index e541c7cc260..7631ba345ec 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -2264,10 +2264,30 @@ "type": "integer", "description": "Maximum number of chat iterations per run. Helps prevent runaway loops and control costs. Has sensible defaults and can typically be omitted." }, - "max-concurrency": { - "type": "integer", - "minimum": -1, - "description": "Maximum number of agentic jobs that can run concurrently across all workflows using this engine. Defaults to 3. Set to -1 to disable agent concurrency limiting. Uses GitHub Actions concurrency controls with slot-based distribution." + "concurrency": { + "oneOf": [ + { + "type": "string", + "description": "Simple concurrency group name. Gets converted to GitHub Actions concurrency format with the specified group." + }, + { + "type": "object", + "description": "GitHub Actions concurrency configuration for the agent job. Controls how many agent jobs can run concurrently.", + "properties": { + "group": { + "type": "string", + "description": "Concurrency group identifier. Use GitHub Actions expressions like ${{ github.workflow }} or ${{ github.ref }}. Defaults to 'gh-aw-{engine-id}' if not specified." + }, + "cancel-in-progress": { + "type": "boolean", + "description": "Whether to cancel in-progress runs of the same concurrency group. Defaults to false for agent jobs." + } + }, + "required": ["group"], + "additionalProperties": false + } + ], + "description": "Agent job concurrency configuration. Defaults to single job per engine across all workflows (group: 'gh-aw-{engine-id}'). Supports full GitHub Actions concurrency syntax." }, "user-agent": { "type": "string", diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index 4d97b013089..33867e9a234 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -118,7 +118,7 @@ type WorkflowData struct { On string Permissions string Network string // top-level network permissions configuration - Concurrency string + Concurrency string // workflow-level concurrency configuration RunName string Env string If string @@ -1958,8 +1958,8 @@ func (c *Compiler) buildMainJob(data *WorkflowData, activationJobCreated bool) ( } } - // Generate agent concurrency for max-concurrency feature - //agentConcurrency := GenerateJobConcurrencyConfig(data) + // Generate agent concurrency configuration + agentConcurrency := GenerateJobConcurrencyConfig(data) job := &Job{ Name: constants.AgentJobName, @@ -1969,7 +1969,7 @@ func (c *Compiler) buildMainJob(data *WorkflowData, activationJobCreated bool) ( Container: c.indentYAMLLines(data.Container, " "), Services: c.indentYAMLLines(data.Services, " "), Permissions: c.indentYAMLLines(data.Permissions, " "), - Concurrency: "", // c.indentYAMLLines(agentConcurrency, " "), + Concurrency: c.indentYAMLLines(agentConcurrency, " "), Env: env, Steps: steps, Needs: depends, diff --git a/pkg/workflow/concurrency.go b/pkg/workflow/concurrency.go index 4a858383c5d..5aa9e6eac13 100644 --- a/pkg/workflow/concurrency.go +++ b/pkg/workflow/concurrency.go @@ -29,38 +29,25 @@ func GenerateConcurrencyConfig(workflowData *WorkflowData, isCommandTrigger bool } // GenerateJobConcurrencyConfig generates the agent concurrency configuration -// for max-concurrency limiting across all workflows using the same engine +// for the agent job based on engine.concurrency field func GenerateJobConcurrencyConfig(workflowData *WorkflowData) string { - // Check if max-concurrency is -1 (disabled) - if workflowData.EngineConfig != nil && workflowData.EngineConfig.MaxConcurrency == -1 { - return "" // Don't emit agent concurrency when disabled + // If concurrency is explicitly configured in engine, use it + if workflowData.EngineConfig != nil && workflowData.EngineConfig.Concurrency != "" { + return workflowData.EngineConfig.Concurrency } - // Build agent concurrency for max-concurrency feature - // This uses ONLY engine ID (or custom concurrency-group) and run_id slot for global limiting + // Default behavior: single job per engine across all workflows + // Pattern: gh-aw-{engine-id} var keys []string // Prepend with gh-aw- prefix keys = append(keys, "gh-aw") - // Use custom concurrency-group if provided, otherwise use engine ID - if workflowData.EngineConfig != nil && workflowData.EngineConfig.ConcurrencyGroup != "" { - keys = append(keys, workflowData.EngineConfig.ConcurrencyGroup) - } else if workflowData.EngineConfig != nil && workflowData.EngineConfig.ID != "" { + // Use engine ID for isolation between different engines + if workflowData.EngineConfig != nil && workflowData.EngineConfig.ID != "" { keys = append(keys, workflowData.EngineConfig.ID) } - // Add max-concurrency slot to the group - maxConcurrency := 3 // default value - if workflowData.EngineConfig != nil && workflowData.EngineConfig.MaxConcurrency > 0 { - maxConcurrency = workflowData.EngineConfig.MaxConcurrency - } - - // Add a slot number based on run_id to distribute workflows across concurrency slots - // This implements a simple round-robin distribution using modulo - slotKey := fmt.Sprintf("${{ github.run_id %% %d }}", maxConcurrency) - keys = append(keys, slotKey) - groupValue := strings.Join(keys, "-") // Build the concurrency configuration (no cancel-in-progress at agent level) diff --git a/pkg/workflow/concurrency_test.go b/pkg/workflow/concurrency_test.go index 71ca797fd51..69490fe9cc5 100644 --- a/pkg/workflow/concurrency_test.go +++ b/pkg/workflow/concurrency_test.go @@ -307,7 +307,7 @@ func TestGenerateConcurrencyConfig(t *testing.T) { } } -// TestGenerateJobConcurrencyConfig tests the job-level concurrency configuration for max-concurrency +// TestGenerateJobConcurrencyConfig tests the job-level concurrency configuration for agent jobs func TestGenerateJobConcurrencyConfig(t *testing.T) { tests := []struct { name string @@ -316,48 +316,59 @@ func TestGenerateJobConcurrencyConfig(t *testing.T) { description string }{ { - name: "Default max-concurrency (3) with copilot engine", + name: "Default concurrency with copilot engine", workflowData: &WorkflowData{ EngineConfig: &EngineConfig{ID: "copilot"}, }, expected: `concurrency: - group: "gh-aw-copilot-${{ github.run_id % 3 }}"`, - description: "Should use default max-concurrency of 3 with copilot engine ID", + group: "gh-aw-copilot"`, + description: "Should use default pattern gh-aw-{engine-id} without slot distribution", }, { - name: "Custom max-concurrency value should be used", + name: "Default concurrency with claude engine", workflowData: &WorkflowData{ - EngineConfig: &EngineConfig{ID: "claude", MaxConcurrency: 5}, + EngineConfig: &EngineConfig{ID: "claude"}, }, expected: `concurrency: - group: "gh-aw-claude-${{ github.run_id % 5 }}"`, - description: "Custom max-concurrency should use specified value instead of default", + group: "gh-aw-claude"`, + description: "Should use default pattern gh-aw-{engine-id} for claude", }, { - name: "Zero max-concurrency should use default (3)", + name: "Custom concurrency string (simple group)", workflowData: &WorkflowData{ - EngineConfig: &EngineConfig{ID: "copilot", MaxConcurrency: 0}, // 0 means use default + EngineConfig: &EngineConfig{ + ID: "claude", + Concurrency: `concurrency: + group: "custom-group-${{ github.ref }}"`, + }, }, expected: `concurrency: - group: "gh-aw-copilot-${{ github.run_id % 3 }}"`, - description: "Zero max-concurrency should default to 3", + group: "custom-group-${{ github.ref }}"`, + description: "Should use custom concurrency when specified", }, { - name: "Different engine ID should be included in concurrency group", + name: "Custom concurrency with cancel-in-progress", workflowData: &WorkflowData{ - EngineConfig: &EngineConfig{ID: "codex"}, + EngineConfig: &EngineConfig{ + ID: "copilot", + Concurrency: `concurrency: + group: "custom-group" + cancel-in-progress: true`, + }, }, expected: `concurrency: - group: "gh-aw-codex-${{ github.run_id % 3 }}"`, - description: "Different engine IDs should be included in concurrency group for isolation", + group: "custom-group" + cancel-in-progress: true`, + description: "Should preserve cancel-in-progress when specified", }, { - name: "Max-concurrency -1 should disable agent concurrency", + name: "Different engine ID should be included in default concurrency group", workflowData: &WorkflowData{ - EngineConfig: &EngineConfig{ID: "claude", MaxConcurrency: -1}, + EngineConfig: &EngineConfig{ID: "codex"}, }, - expected: "", - description: "Max-concurrency -1 should return empty string (no agent concurrency)", + expected: `concurrency: + group: "gh-aw-codex"`, + description: "Different engine IDs should be included in concurrency group for isolation", }, } diff --git a/pkg/workflow/engine.go b/pkg/workflow/engine.go index 90f15e7ea42..b311fa811da 100644 --- a/pkg/workflow/engine.go +++ b/pkg/workflow/engine.go @@ -7,17 +7,16 @@ import ( // EngineConfig represents the parsed engine configuration type EngineConfig struct { - ID string - Version string - Model string - MaxTurns string - MaxConcurrency int - ConcurrencyGroup string - UserAgent string - Env map[string]string - Steps []map[string]any - ErrorPatterns []ErrorPattern - Config string + ID string + Version string + Model string + MaxTurns string + Concurrency string // Agent job-level concurrency configuration (YAML format) + UserAgent string + Env map[string]string + Steps []map[string]any + ErrorPatterns []ErrorPattern + Config string } // NetworkPermissions represents network access permissions @@ -76,21 +75,29 @@ func (c *Compiler) ExtractEngineConfig(frontmatter map[string]any) (string, *Eng } } - // Extract optional 'max-concurrency' field - if maxConcurrency, hasMaxConcurrency := engineObj["max-concurrency"]; hasMaxConcurrency { - if maxConcurrencyInt, ok := maxConcurrency.(int); ok { - config.MaxConcurrency = maxConcurrencyInt - } else if maxConcurrencyFloat, ok := maxConcurrency.(float64); ok { - config.MaxConcurrency = int(maxConcurrencyFloat) - } else if maxConcurrencyUint64, ok := maxConcurrency.(uint64); ok { - config.MaxConcurrency = int(maxConcurrencyUint64) - } - } - - // Extract optional 'concurrency-group' field - if concurrencyGroup, hasConcurrencyGroup := engineObj["concurrency-group"]; hasConcurrencyGroup { - if concurrencyGroupStr, ok := concurrencyGroup.(string); ok { - config.ConcurrencyGroup = concurrencyGroupStr + // Extract optional 'concurrency' field (string or object format) + if concurrency, hasConcurrency := engineObj["concurrency"]; hasConcurrency { + if concurrencyStr, ok := concurrency.(string); ok { + // Simple string format (group name) + config.Concurrency = fmt.Sprintf("concurrency:\n group: \"%s\"", concurrencyStr) + } else if concurrencyObj, ok := concurrency.(map[string]any); ok { + // Object format with group and optional cancel-in-progress + var parts []string + if group, hasGroup := concurrencyObj["group"]; hasGroup { + if groupStr, ok := group.(string); ok { + parts = append(parts, fmt.Sprintf("concurrency:\n group: \"%s\"", groupStr)) + } + } + if cancel, hasCancel := concurrencyObj["cancel-in-progress"]; hasCancel { + if cancelBool, ok := cancel.(bool); ok && cancelBool { + if len(parts) > 0 { + parts[0] += "\n cancel-in-progress: true" + } + } + } + if len(parts) > 0 { + config.Concurrency = parts[0] + } } } diff --git a/pkg/workflow/engine_concurrency_integration_test.go b/pkg/workflow/engine_concurrency_integration_test.go new file mode 100644 index 00000000000..a3427177353 --- /dev/null +++ b/pkg/workflow/engine_concurrency_integration_test.go @@ -0,0 +1,105 @@ +package workflow + +import ( +"os" +"path/filepath" +"strings" +"testing" +) + +func TestEngineConcurrencyIntegration(t *testing.T) { +tests := []struct { +name string +markdown string +expectedInJob string +description string +}{ +{ +name: "Default concurrency (no engine.concurrency specified)", +markdown: `--- +on: push +engine: + id: claude +tools: + github: + allowed: [list_issues] +--- + +# Test workflow +Test content`, +expectedInJob: `concurrency: + group: "gh-aw-claude"`, +description: "Should use default pattern gh-aw-{engine-id}", +}, +{ +name: "Custom concurrency with string format", +markdown: `--- +on: push +engine: + id: claude + concurrency: "custom-${{ github.ref }}" +tools: + github: + allowed: [list_issues] +--- + +# Test workflow +Test content`, +expectedInJob: `concurrency: + group: "custom-${{ github.ref }}"`, +description: "Should use custom concurrency group from string format", +}, +{ +name: "Custom concurrency with object format", +markdown: `--- +on: push +engine: + id: claude + concurrency: + group: "my-group-${{ github.workflow }}" + cancel-in-progress: true +tools: + github: + allowed: [list_issues] +--- + +# Test workflow +Test content`, +expectedInJob: `concurrency: + group: "my-group-${{ github.workflow }}" + cancel-in-progress: true`, +description: "Should use custom concurrency with cancel-in-progress", +}, +} + +for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create temporary directory and file + tmpDir := t.TempDir() + workflowPath := filepath.Join(tmpDir, "test-workflow.md") + if err := os.WriteFile(workflowPath, []byte(tt.markdown), 0644); err != nil { + t.Fatalf("Failed to write test workflow: %v", err) + } + + // Compile workflow + compiler := NewCompiler(false, "", "test") + err := compiler.CompileWorkflow(workflowPath) + if err != nil { + t.Fatalf("Failed to compile workflow: %v", err) + } + + // Read the generated lock file + lockFile := filepath.Join(tmpDir, "test-workflow.lock.yml") + lockContent, err := os.ReadFile(lockFile) + if err != nil { + t.Fatalf("Failed to read generated lock file: %v", err) + } + + // Check if expected concurrency is in the job section + if !strings.Contains(string(lockContent), tt.expectedInJob) { + t.Errorf("Compiled workflow doesn't contain expected concurrency\nExpected to find:\n%s\n\nFull output:\n%s", + tt.expectedInJob, string(lockContent)) + } + }) +} +} diff --git a/pkg/workflow/engine_concurrency_test.go b/pkg/workflow/engine_concurrency_test.go new file mode 100644 index 00000000000..876db98e023 --- /dev/null +++ b/pkg/workflow/engine_concurrency_test.go @@ -0,0 +1,94 @@ +package workflow + +import ( +"testing" +) + +func TestExtractEngineConcurrencyField(t *testing.T) { +compiler := NewCompiler(false, "", "test") + +tests := []struct { +name string +frontmatter map[string]any +expectedConcurrency string +description string +}{ +{ +name: "Simple string concurrency (just group name)", +frontmatter: map[string]any{ +"engine": map[string]any{ +"id": "claude", +"concurrency": "custom-group-${{ github.ref }}", +}, +}, +expectedConcurrency: "concurrency:\n group: \"custom-group-${{ github.ref }}\"", +description: "String concurrency should be converted to proper YAML format", +}, +{ +name: "Object format concurrency with group only", +frontmatter: map[string]any{ +"engine": map[string]any{ +"id": "claude", +"concurrency": map[string]any{ +"group": "custom-group", +}, +}, +}, +expectedConcurrency: "concurrency:\n group: \"custom-group\"", +description: "Object with group only should generate proper YAML", +}, +{ +name: "Object format concurrency with group and cancel-in-progress", +frontmatter: map[string]any{ +"engine": map[string]any{ +"id": "claude", +"concurrency": map[string]any{ +"group": "custom-group", +"cancel-in-progress": true, +}, +}, +}, +expectedConcurrency: "concurrency:\n group: \"custom-group\"\n cancel-in-progress: true", +description: "Object with cancel-in-progress should include it in YAML", +}, +{ +name: "Object format concurrency with cancel-in-progress false", +frontmatter: map[string]any{ +"engine": map[string]any{ +"id": "claude", +"concurrency": map[string]any{ +"group": "custom-group", +"cancel-in-progress": false, +}, +}, +}, +expectedConcurrency: "concurrency:\n group: \"custom-group\"", +description: "Object with cancel-in-progress false should not include it", +}, +{ +name: "No concurrency field", +frontmatter: map[string]any{ +"engine": map[string]any{ +"id": "claude", +}, +}, +expectedConcurrency: "", +description: "Missing concurrency field should return empty string", +}, +} + +for _, tt := range tests { +t.Run(tt.name, func(t *testing.T) { +_, config := compiler.ExtractEngineConfig(tt.frontmatter) + +if config == nil { +t.Fatalf("Expected config to be non-nil") +} + +if config.Concurrency != tt.expectedConcurrency { +t.Errorf("ExtractEngineConfig() failed for %s\nExpected:\n%s\nGot:\n%s", +tt.description, tt.expectedConcurrency, config.Concurrency) +} +}) +} +} From 1a4281f66560e709cbe93dab3e24ce651c94448c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Oct 2025 15:25:11 +0000 Subject: [PATCH 3/8] Update documentation for engine.concurrency field - Replace max-concurrency documentation with concurrency field - Update frontmatter.md with new Agent Job Concurrency section - Update concurrency.md with comprehensive concurrency documentation - Remove references to slot-based distribution - Add examples for string and object concurrency formats - Document default behavior (single job per engine) - Update troubleshooting sections Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../src/content/docs/reference/concurrency.md | 208 ++++++++++-------- .../src/content/docs/reference/frontmatter.md | 103 +++++---- 2 files changed, 174 insertions(+), 137 deletions(-) diff --git a/docs/src/content/docs/reference/concurrency.md b/docs/src/content/docs/reference/concurrency.md index f95d255396f..7912a2c2dc6 100644 --- a/docs/src/content/docs/reference/concurrency.md +++ b/docs/src/content/docs/reference/concurrency.md @@ -1,65 +1,75 @@ --- title: Concurrency Control -description: Complete guide to concurrency control in GitHub Agentic Workflows, including max-concurrency configuration, global locks, and engine isolation. +description: Complete guide to concurrency control in GitHub Agentic Workflows, including agent job concurrency configuration and engine isolation. sidebar: order: 6 --- -GitHub Agentic Workflows provides sophisticated concurrency control to manage how many AI-powered workflows can run simultaneously. This helps prevent resource exhaustion, control costs, and ensure predictable workflow execution. +GitHub Agentic Workflows provides sophisticated concurrency control to manage how many AI-powered agent jobs can run simultaneously. This helps prevent resource exhaustion, control costs, and ensure predictable workflow execution. ## Overview -Concurrency control in GitHub Agentic Workflows uses a dual-level approach with different strategies at each level: +Concurrency control in GitHub Agentic Workflows uses a dual-level approach: - **Workflow-level concurrency**: Context-specific limiting based on workflow type (issue, PR, branch, etc.) -- **Agent concurrency (max-concurrency)**: Global limiting across all workflows using the same engine +- **Agent job concurrency**: Controls concurrent execution of agent jobs using the `engine.concurrency` field -This dual-level approach provides both fine-grained control per workflow and global resource management across all workflows. +This dual-level approach provides both fine-grained control per workflow and flexible resource management for AI execution. -## Max Concurrency Configuration +## Agent Job Concurrency Configuration -The `max-concurrency` option is configured under the `engine` section and controls how many agentic jobs can run concurrently across **all workflows** in your repository: +The `concurrency` field under the `engine` section controls concurrency for the agent job. It uses GitHub Actions concurrency syntax: ```yaml engine: id: claude - max-concurrency: 5 + concurrency: + group: "my-group-${{ github.workflow }}" + cancel-in-progress: true ``` -### Default Value +### Default Behavior -- **Default**: 3 concurrent slots (when not specified or set to 0) -- **Minimum**: 1 (sequential execution) -- **Disabled**: -1 (no agent concurrency limiting) -- **No maximum**: Set to any positive integer based on your needs +**Default:** Single job per engine across all workflows + +When no `engine.concurrency` is specified, the default pattern is: +```yaml +concurrency: + group: "gh-aw-{engine-id}" +``` + +This ensures only one agent job runs at a time for each engine across all workflows and refs, preventing resource exhaustion. ### Configuration Examples -**Sequential execution (one at a time):** +**Default (single job per engine):** ```yaml engine: - id: copilot - max-concurrency: 1 + id: claude + # No concurrency specified - uses gh-aw-claude ``` -**Moderate parallelism (default):** +**Per-workflow concurrency:** ```yaml engine: id: claude - # max-concurrency not specified, defaults to 3 + concurrency: + group: "gh-aw-claude-${{ github.workflow }}" ``` -**High parallelism for busy repositories:** +**Per-branch concurrency with cancellation:** ```yaml engine: - id: claude - max-concurrency: 10 + id: copilot + concurrency: + group: "gh-aw-copilot-${{ github.ref }}" + cancel-in-progress: true ``` -**Disable agent concurrency limiting:** +**Simple string format:** ```yaml engine: id: claude - max-concurrency: -1 # No global limiting, only workflow-level concurrency applies + concurrency: "custom-group-${{ github.workflow }}" ``` ## How It Works @@ -95,26 +105,28 @@ concurrency: This ensures workflows operating on different issues, PRs, or branches can run concurrently without interfering with each other. -### Agent Concurrency (Max-Concurrency) +### Agent Job Concurrency -The agent concurrency uses **only** the engine ID and slot number for global limiting: +The agent job concurrency is configured via `engine.concurrency` and uses the specified pattern: +**Default pattern (single job per engine):** ```yaml jobs: agent: concurrency: - group: "gh-aw-{engine-id}-${{ github.run_id % max-concurrency }}" + group: "gh-aw-{engine-id}" ``` -**Example for Claude with max-concurrency of 5:** +**Custom pattern example:** ```yaml jobs: agent: concurrency: - group: "gh-aw-claude-${{ github.run_id % 5 }}" + group: "custom-${{ github.workflow }}" + cancel-in-progress: true ``` -This creates a global lock across **all workflows and refs** for each engine, preventing resource exhaustion from too many concurrent AI executions. +This controls concurrent execution of agent jobs across all workflows, preventing resource exhaustion from too many concurrent AI executions. ### Complete Example @@ -136,64 +148,55 @@ jobs: agent: runs-on: ubuntu-latest permissions: read-all - # Agent concurrency: Global max-concurrency limiting + # Agent concurrency: Default single job per engine concurrency: - group: "gh-aw-claude-${{ github.run_id % 5 }}" + group: "gh-aw-claude" steps: - name: Execute workflow ... ``` -### Slot Distribution - -Workflows are distributed across available slots using modulo arithmetic: -- `github.run_id % max-concurrency` calculates the slot number (0 to max-concurrency-1) -- Each slot can only run one workflow at a time -- Workflows are automatically assigned to the next available slot - -**Example with max-concurrency: 3** -- Run ID 1001 → Slot 2 (`1001 % 3 = 2`) -- Run ID 1002 → Slot 0 (`1002 % 3 = 0`) -- Run ID 1003 → Slot 1 (`1003 % 3 = 1`) -- Run ID 1004 → Slot 2 (`1004 % 3 = 2`) - ### Dual-Level Application The dual-level concurrency provides complementary control: 1. **Workflow-level**: Prevents conflicts between runs of the same workflow on different contexts (e.g., different issues or PRs) -2. **Agent concurrency**: Prevents resource exhaustion by limiting total concurrent AI executions across all workflows +2. **Agent job concurrency**: Controls concurrent execution of agent jobs based on configured pattern **Example scenario:** - 5 different issues trigger the same workflow - Workflow-level concurrency allows all 5 to start (different issue numbers) -- Agent concurrency with max-concurrency (e.g., 3) ensures only 3 AI jobs run simultaneously -- The other 2 workflows queue until slots become available +- Agent job concurrency with default `gh-aw-claude` means only 1 agent job runs at a time +- The other 4 workflows queue until the agent job completes This approach balances: - **Workflow isolation**: Different contexts don't block each other at the workflow level -- **Global resource management**: Total AI resource usage is controlled at the agent level +- **Resource management**: Agent job execution is controlled via concurrency configuration ## Global Lock Behavior -The **agent concurrency** (max-concurrency) uses **only** engine ID and slot number, creating a true global lock: +The **agent job concurrency** creates a lock based on the configured pattern: -### What's Included in Agent Concurrency +### What's Included in Default Agent Concurrency - ✅ Engine ID (`copilot`, `claude`, `codex`) -- ✅ Slot number (from `run_id % max-concurrency`) - ✅ `gh-aw-` prefix -### What's NOT Included in Agent Concurrency +### What's NOT Included in Default Agent Concurrency - ❌ Workflow name - ❌ Issue number - ❌ Pull request number - ❌ Branch/ref name - ❌ Event type -This ensures the max-concurrency limit applies **repository-wide** across all workflows and refs for each engine. +The default pattern `gh-aw-{engine-id}` ensures only one agent job runs per engine across **all workflows and refs**. -**Disabling Agent Concurrency**: -Set `max-concurrency: -1` to disable agent concurrency limiting entirely. When disabled, only workflow-level concurrency applies, and there is no global limit on concurrent AI executions across workflows. +You can customize this behavior by specifying a different `engine.concurrency` pattern: +```yaml +engine: + id: claude + concurrency: + group: "gh-aw-claude-${{ github.workflow }}" # Per-workflow concurrency +``` ### Workflow-Level Concurrency Includes Context @@ -213,16 +216,16 @@ Different engines can run concurrently without interfering with each other: # Workflow A uses Copilot engine: id: copilot - max-concurrency: 3 + # Default: gh-aw-copilot # Workflow B uses Claude engine: id: claude - max-concurrency: 5 + # Default: gh-aw-claude ``` -- Copilot workflows have their own 3-slot concurrency pool -- Claude workflows have their own 5-slot concurrency pool +- Copilot agent jobs use the `gh-aw-copilot` concurrency group +- Claude agent jobs use the `gh-aw-claude` concurrency group - Both can run simultaneously without conflict ## Cancellation Behavior @@ -244,28 +247,28 @@ concurrency: ## Benefits ### Cost Control -- **Prevents runaway costs**: Limits the number of concurrent AI executions -- **Predictable spending**: Maximum concurrent workflows are known in advance -- **Flexible budgeting**: Adjust limits based on repository needs +- **Prevents runaway costs**: Controls concurrent AI job execution +- **Predictable resource usage**: Known concurrency patterns +- **Flexible configuration**: Customize per workflow or engine ### Resource Management -- **Prevents resource exhaustion**: Ensures system stability -- **Fair resource distribution**: Workflows queue when slots are full -- **Maintains throughput**: Multiple workflows can still run concurrently +- **Prevents resource exhaustion**: Ensures system stability with default single-job-per-engine pattern +- **Fair resource distribution**: Agent jobs queue when concurrency limit is reached +- **Maintains throughput**: Activation and other jobs continue running ### Engine Isolation -- **Independent limits**: Each engine has its own concurrency pool -- **No cross-engine interference**: Copilot workflows don't block Claude workflows -- **Flexible configuration**: Different limits for different engines +- **Independent limits**: Each engine has its own default concurrency group +- **No cross-engine interference**: Copilot agent jobs don't block Claude agent jobs +- **Flexible configuration**: Customize concurrency per engine ### Simplicity -- **Global lock**: Same limit across all workflows and refs -- **Automatic distribution**: No manual slot assignment needed +- **Default global lock**: Single job per engine by default +- **Standard GitHub Actions syntax**: Familiar concurrency configuration - **Consistent behavior**: Predictable execution patterns ## Custom Concurrency -You can override the automatic concurrency generation by specifying your own `concurrency` section in the frontmatter: +You can override the automatic concurrency generation by specifying your own `concurrency` section in the frontmatter (for workflow-level concurrency): ```yaml --- @@ -273,78 +276,93 @@ on: push concurrency: group: custom-group-${{ github.ref }} cancel-in-progress: true +engine: + id: claude + concurrency: # Agent job concurrency (separate from workflow concurrency) + group: "custom-agent-${{ github.workflow }}" tools: github: allowed: [list_issues] --- ``` -**Note**: Custom concurrency bypasses the max-concurrency limit and engine isolation features. +**Note**: Workflow-level concurrency and agent job concurrency are independent and can be configured separately. ## Best Practices -### Setting Max Concurrency +### Configuring Agent Concurrency -**Start conservative:** +**Default (recommended for most cases):** ```yaml engine: id: claude - max-concurrency: 1 # Start with sequential execution + # No concurrency specified - single job per engine ``` -**Increase as needed:** +**Allow per-workflow concurrency:** ```yaml engine: id: claude - max-concurrency: 5 # Increase after monitoring costs and performance + concurrency: + group: "gh-aw-claude-${{ github.workflow }}" +``` + +**Per-branch concurrency for PR workflows:** +```yaml +engine: + id: copilot + concurrency: + group: "gh-aw-copilot-${{ github.ref }}" + cancel-in-progress: true ``` -### Different Limits for Different Engines +### Different Patterns for Different Engines -**Cost-sensitive engine:** +**Conservative engine (default):** ```yaml engine: - id: claude # Expensive model - max-concurrency: 2 + id: claude + # No concurrency - uses gh-aw-claude (single job) ``` -**Budget-friendly engine:** +**More permissive engine:** ```yaml engine: - id: copilot # More affordable - max-concurrency: 5 + id: copilot + concurrency: + group: "gh-aw-copilot-${{ github.workflow }}" # Per-workflow concurrency ``` ### Monitoring and Adjustment 1. **Monitor workflow execution**: Use GitHub Actions insights 2. **Track costs**: Review AI model usage and expenses -3. **Adjust limits**: Increase or decrease based on needs -4. **Test changes**: Validate new limits with test workflows +3. **Adjust patterns**: Change concurrency groups based on needs +4. **Test changes**: Validate new patterns with test workflows ## Troubleshooting -### Workflows Queuing +### Agent Jobs Queuing -**Symptom**: Workflows wait in queue instead of running +**Symptom**: Agent jobs wait in queue instead of running -**Cause**: All concurrency slots are full +**Cause**: Concurrency group is blocking (e.g., default single-job-per-engine pattern) **Solution**: -- Increase `max-concurrency` value -- Check for long-running workflows +- Customize `engine.concurrency` to allow more parallel execution +- Use per-workflow or per-branch patterns if appropriate - Consider using different engines for different workflows ### Too Many Concurrent Runs -**Symptom**: High costs or resource usage +**Symptom**: High costs or resource usage from concurrent agent jobs -**Cause**: `max-concurrency` set too high +**Cause**: Concurrency pattern allows too many parallel executions **Solution**: -- Decrease `max-concurrency` value +- Use more restrictive concurrency pattern (e.g., default `gh-aw-{engine-id}`) - Monitor usage patterns -- Set appropriate limits per engine +- Set appropriate patterns per engine ### Workflows Not Canceling diff --git a/docs/src/content/docs/reference/frontmatter.md b/docs/src/content/docs/reference/frontmatter.md index 5d57903f20d..ebd831397f6 100644 --- a/docs/src/content/docs/reference/frontmatter.md +++ b/docs/src/content/docs/reference/frontmatter.md @@ -465,70 +465,83 @@ engine: 3. Helps prevent runaway chat loops and control costs 4. Only applies to engines that support turn limiting (currently Claude) -### Concurrency Limiting +### Agent Job Concurrency -The `max-concurrency` option limits how many agentic jobs can run concurrently across **all workflows** in your repository: +The `concurrency` field in the engine configuration controls how many agent jobs can run concurrently. It uses GitHub Actions concurrency syntax: ```yaml engine: id: claude - max-concurrency: 5 + concurrency: + group: "custom-group-${{ github.workflow }}" + cancel-in-progress: true ``` -**Default Value:** 3 (if not specified) +**Default Behavior:** Single job per engine across all workflows (group: `gh-aw-{engine-id}`) **How it works:** -- Uses GitHub Actions concurrency groups with slot distribution for global limiting -- Workflows are distributed across available slots using `github.run_id % max-concurrency` -- Each slot can only run one workflow at a time across ALL workflows and refs -- Includes engine ID in concurrency group for isolation between different engines -- Prevents resource exhaustion from too many concurrent AI executions +- Creates a concurrency group for the agent job +- Default pattern: `gh-aw-{engine-id}` ensures one job per engine across all workflows +- Supports full GitHub Actions concurrency syntax (group + optional cancel-in-progress) +- Different engines (claude, copilot, codex) can run concurrently without interfering -**Example configurations:** +**Simple string format:** +```yaml +engine: + id: claude + concurrency: "my-custom-group-${{ github.ref }}" +``` + +This is converted to: +```yaml +concurrency: + group: "my-custom-group-${{ github.ref }}" +``` +**Object format (full control):** ```yaml -# Allow up to 10 concurrent Claude workflows engine: id: claude - max-concurrency: 10 + concurrency: + group: "my-group-${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: true ``` +**Example configurations:** + ```yaml -# Restrict to 1 workflow at a time (sequential execution) +# Default: single job per engine across all workflows engine: - id: copilot - max-concurrency: 1 + id: claude + # No concurrency specified, uses gh-aw-claude ``` ```yaml -# Use default of 3 concurrent workflows +# Allow multiple concurrent jobs with different workflow names engine: id: claude - # max-concurrency not specified, defaults to 3 + concurrency: + group: "gh-aw-claude-${{ github.workflow }}" ``` -**Generated concurrency group pattern:** ```yaml -# At workflow level -concurrency: - group: "gh-aw-{engine-id}-${{ github.run_id % max-concurrency }}" +# Per-branch concurrency +engine: + id: copilot + concurrency: + group: "gh-aw-copilot-${{ github.ref }}" + cancel-in-progress: true +``` -# At job level (agentic job) +**Generated concurrency in agent job:** +```yaml jobs: agent: concurrency: - group: "gh-aw-{engine-id}-${{ github.run_id % max-concurrency }}" + group: "gh-aw-claude" # Default pattern ``` -Example for claude with max-concurrency of 5: -```yaml -concurrency: - group: "gh-aw-claude-${{ github.run_id % 5 }}" -``` - -The concurrency group uses **only** the engine ID and slot number (prefixed with "gh-aw-"), creating a global lock across all workflows and refs for that engine. This ensures the max-concurrency limit applies repository-wide. - -The concurrency is applied at **both workflow and job level** for maximum control. +The concurrency group applies **only** to the agent job (not the workflow level). This ensures concurrency control for AI execution while allowing activation jobs to run freely. ## Tools Configuration (`tools:`) @@ -577,30 +590,36 @@ timeout_minutes: 30 # Defaults to 15 minutes ## Concurrency Control (`concurrency:`) -GitHub Agentic Workflows automatically generates concurrency policies to limit concurrent execution across all workflows using the same engine. +GitHub Agentic Workflows automatically generates concurrency policies for the agent job to control concurrent execution. -See [Concurrency Control](/gh-aw/reference/concurrency/) for complete documentation on max-concurrency configuration, global locks, and engine isolation. +See [Concurrency Control](/gh-aw/reference/concurrency/) for complete documentation on agent concurrency configuration. **Quick reference:** -- Configure via `engine.max-concurrency` (default: 3) -- Creates global lock across all workflows and refs for each engine -- Applied at both workflow and job levels +- Configure via `engine.concurrency` field +- Default: Single job per engine across all workflows (group: `gh-aw-{engine-id}`) +- Applied at the agent job level only - Different engines can run concurrently without interfering +- Supports full GitHub Actions concurrency syntax **Example:** ```yaml engine: id: claude - max-concurrency: 5 + concurrency: + group: "custom-${{ github.workflow }}" + cancel-in-progress: true ``` -Generates: +Generates agent job concurrency: ```yaml -concurrency: - group: "gh-aw-claude-${{ github.run_id % 5 }}" +jobs: + agent: + concurrency: + group: "custom-${{ github.workflow }}" + cancel-in-progress: true ``` -You can override automatic concurrency by specifying a custom `concurrency` section in the frontmatter. +You can also override workflow-level concurrency by specifying a custom `concurrency` section in the frontmatter (separate from engine concurrency). ## Environment Variables (`env:`) From 62dfb15816889325ca372f774f7fdca56b6e7032 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Oct 2025 15:29:56 +0000 Subject: [PATCH 4/8] Final validation - all tests pass and manual testing complete - Verified default concurrency pattern: gh-aw-{engine-id} - Verified string format concurrency conversion - Verified object format concurrency with cancel-in-progress - All unit tests pass - All integration tests pass - Manual compilation testing successful Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/artifacts-summary.lock.yml | 1714 +++++++------- .github/workflows/brave.lock.yml | 1418 +++++------ .../workflows/changeset-generator.lock.yml | 1418 +++++------ .github/workflows/ci-doctor.lock.yml | 1712 +++++++------- .github/workflows/dev.lock.yml | 1714 +++++++------- .../duplicate-code-detector.lock.yml | 1714 +++++++------- .../workflows/go-pattern-detector.lock.yml | 1714 +++++++------- .github/workflows/issue-classifier.lock.yml | 1767 +++++++------- .github/workflows/pdf-summary.lock.yml | 1418 +++++------ .github/workflows/poem-bot.lock.yml | 2073 +++++++++-------- .github/workflows/scout.lock.yml | 1418 +++++------ .../workflows/technical-doc-writer.lock.yml | 1416 +++++------ .github/workflows/tidy.lock.yml | 1416 +++++------ pkg/workflow/js/add_labels.js | 371 +-- pkg/workflow/js/collect_ndjson_output.js | 1414 +++++------ pkg/workflow/js/create_discussion.js | 272 +-- pkg/workflow/js/create_issue.js | 290 ++- 17 files changed, 11627 insertions(+), 11632 deletions(-) diff --git a/.github/workflows/artifacts-summary.lock.yml b/.github/workflows/artifacts-summary.lock.yml index b09c80abd72..76a34943a92 100644 --- a/.github/workflows/artifacts-summary.lock.yml +++ b/.github/workflows/artifacts-summary.lock.yml @@ -1116,742 +1116,742 @@ jobs: with: script: | async function main() { - const fs = require("fs"); - function sanitizeContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; - const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; - const allowedDomains = allowedDomainsEnv - ? allowedDomainsEnv - .split(",") - .map(d => d.trim()) - .filter(d => d) - : defaultAllowedDomains; - let sanitized = content; - sanitized = neutralizeMentions(sanitized); - sanitized = removeXmlComments(sanitized); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitizeUrlProtocols(sanitized); - sanitized = sanitizeUrlDomains(sanitized); - const maxLength = 524288; - if (sanitized.length > maxLength) { - sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; - } - const lines = sanitized.split("\n"); - const maxLines = 65000; - if (lines.length > maxLines) { - sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; - } - sanitized = neutralizeBotTriggers(sanitized); - return sanitized.trim(); - function sanitizeUrlDomains(s) { - return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { - const urlAfterProtocol = match.slice(8); - const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); - const isAllowed = allowedDomains.some(allowedDomain => { - const normalizedAllowed = allowedDomain.toLowerCase(); - return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); - }); - return isAllowed ? match : "(redacted)"; - }); - } - function sanitizeUrlProtocols(s) { - return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { - return protocol.toLowerCase() === "https" ? match : "(redacted)"; - }); - } - function neutralizeMentions(s) { - return s.replace( - /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, - (_m, p1, p2) => `${p1}\`@${p2}\`` - ); - } - function removeXmlComments(s) { - return s.replace(//g, "").replace(//g, ""); - } - function neutralizeBotTriggers(s) { - return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); - } - } - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create-issue": - return 1; - case "add-comment": - return 1; - case "create-pull-request": - return 1; - case "create-pull-request-review-comment": - return 1; - case "add-labels": - return 5; - case "update-issue": - return 1; - case "push-to-pull-request-branch": - return 1; - case "create-discussion": - return 1; - case "missing-tool": - return 1000; - case "create-code-scanning-alert": - return 1000; - case "upload-asset": - return 10; - default: - return 1; + const fs = require("fs"); + function sanitizeContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; + const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; + const allowedDomains = allowedDomainsEnv + ? allowedDomainsEnv + .split(",") + .map(d => d.trim()) + .filter(d => d) + : defaultAllowedDomains; + let sanitized = content; + sanitized = neutralizeMentions(sanitized); + sanitized = removeXmlComments(sanitized); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitizeUrlProtocols(sanitized); + sanitized = sanitizeUrlDomains(sanitized); + const maxLength = 524288; + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; + } + const lines = sanitized.split("\n"); + const maxLines = 65000; + if (lines.length > maxLines) { + sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; + } + sanitized = neutralizeBotTriggers(sanitized); + return sanitized.trim(); + function sanitizeUrlDomains(s) { + return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { + const urlAfterProtocol = match.slice(8); + const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); + const isAllowed = allowedDomains.some(allowedDomain => { + const normalizedAllowed = allowedDomain.toLowerCase(); + return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); + }); + return isAllowed ? match : "(redacted)"; + }); + } + function sanitizeUrlProtocols(s) { + return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { + return protocol.toLowerCase() === "https" ? match : "(redacted)"; + }); + } + function neutralizeMentions(s) { + return s.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); + } + function removeXmlComments(s) { + return s.replace(//g, "").replace(//g, ""); + } + function neutralizeBotTriggers(s) { + return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); + } } - } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + switch (itemType) { + case "create-issue": + return 1; + case "add-comment": + return 1; + case "create-pull-request": + return 1; + case "create-pull-request-review-comment": + return 1; + case "add-labels": + return 5; + case "update-issue": + return 1; + case "push-to-pull-request-branch": + return 1; + case "create-discussion": + return 1; + case "missing-tool": + return 1000; + case "create-code-scanning-alert": + return 1000; + case "upload-asset": + return 10; + default: + return 1; + } } - return 0; - } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; - } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; + } + return 0; + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } + else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } + else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { + if (inputSchema.required && (value === undefined || value === null)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (value === undefined || value === null) { + return { + isValid: true, + normalizedValue: inputSchema.default || undefined, + }; + } + const inputType = inputSchema.type || "string"; + let normalizedValue = value; + switch (inputType) { + case "string": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string`, + }; + } + normalizedValue = sanitizeContent(value); + break; + case "boolean": + if (typeof value !== "boolean") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a boolean`, + }; + } + break; + case "number": + if (typeof value !== "number") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number`, + }; + } + break; + case "choice": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, + }; + } + if (inputSchema.options && !inputSchema.options.includes(value)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, + }; + } + normalizedValue = sanitizeContent(value); + break; + default: + if (typeof value === "string") { + normalizedValue = sanitizeContent(value); + } + break; + } return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, + isValid: true, + normalizedValue, }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; - } - return { isValid: true, normalizedValue: parsed }; - } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { + function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { + const errors = []; + const normalizedItem = { ...item }; + if (!jobConfig.inputs) { + return { + isValid: true, + errors: [], + normalizedItem: item, + }; + } + for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { + const fieldValue = item[fieldName]; + const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); + if (!validation.isValid && validation.error) { + errors.push(validation.error); + } + else if (validation.normalizedValue !== undefined) { + normalizedItem[fieldName] = validation.normalizedValue; + } + } return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, + isValid: errors.length === 0, + errors, + normalizedItem, }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; + function parseJsonWithRepair(jsonStr) { + try { + return JSON.parse(jsonStr); + } + catch (originalError) { + try { + const repairedJson = repairJson(jsonStr); + return JSON.parse(repairedJson); + } + catch (repairError) { + core.info(`invalid input json: ${jsonStr}`); + const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); + const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); + throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); + } + } } - return { isValid: true, normalizedValue: parsed }; - } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; + const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; + if (!outputFile) { + core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); + core.setOutput("output", ""); + return; } - return { isValid: true }; - } - function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { - if (inputSchema.required && (value === undefined || value === null)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; + if (!fs.existsSync(outputFile)) { + core.info(`Output file does not exist: ${outputFile}`); + core.setOutput("output", ""); + return; } - if (value === undefined || value === null) { - return { - isValid: true, - normalizedValue: inputSchema.default || undefined, - }; + const outputContent = fs.readFileSync(outputFile, "utf8"); + if (outputContent.trim() === "") { + core.info("Output file is empty"); } - const inputType = inputSchema.type || "string"; - let normalizedValue = value; - switch (inputType) { - case "string": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string`, - }; - } - normalizedValue = sanitizeContent(value); - break; - case "boolean": - if (typeof value !== "boolean") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a boolean`, - }; - } - break; - case "number": - if (typeof value !== "number") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number`, - }; - } - break; - case "choice": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, - }; - } - if (inputSchema.options && !inputSchema.options.includes(value)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, - }; + core.info(`Raw output content length: ${outputContent.length}`); + let expectedOutputTypes = {}; + if (safeOutputsConfig) { + try { + expectedOutputTypes = JSON.parse(safeOutputsConfig); + core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); } - normalizedValue = sanitizeContent(value); - break; - default: - if (typeof value === "string") { - normalizedValue = sanitizeContent(value); + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); } - break; } - return { - isValid: true, - normalizedValue, - }; - } - function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { + const lines = outputContent.trim().split("\n"); + const parsedItems = []; const errors = []; - const normalizedItem = { ...item }; - if (!jobConfig.inputs) { - return { - isValid: true, - errors: [], - normalizedItem: item, - }; - } - for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { - const fieldValue = item[fieldName]; - const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); - if (!validation.isValid && validation.error) { - errors.push(validation.error); - } else if (validation.normalizedValue !== undefined) { - normalizedItem[fieldName] = validation.normalizedValue; - } - } - return { - isValid: errors.length === 0, - errors, - normalizedItem, - }; - } - function parseJsonWithRepair(jsonStr) { - try { - return JSON.parse(jsonStr); - } catch (originalError) { - try { - const repairedJson = repairJson(jsonStr); - return JSON.parse(repairedJson); - } catch (repairError) { - core.info(`invalid input json: ${jsonStr}`); - const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); - const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); - throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); - } - } - } - const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; - const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; - if (!outputFile) { - core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); - core.setOutput("output", ""); - return; - } - if (!fs.existsSync(outputFile)) { - core.info(`Output file does not exist: ${outputFile}`); - core.setOutput("output", ""); - return; - } - const outputContent = fs.readFileSync(outputFile, "utf8"); - if (outputContent.trim() === "") { - core.info("Output file is empty"); - } - core.info(`Raw output content length: ${outputContent.length}`); - let expectedOutputTypes = {}; - if (safeOutputsConfig) { - try { - expectedOutputTypes = JSON.parse(safeOutputsConfig); - core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); - } - } - const lines = outputContent.trim().split("\n"); - const parsedItems = []; - const errors = []; - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); - if (line === "") continue; - try { - const item = parseJsonWithRepair(line); - if (item === undefined) { - errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); - continue; - } - if (!item.type) { - errors.push(`Line ${i + 1}: Missing required 'type' field`); - continue; - } - const itemType = item.type; - if (!expectedOutputTypes[itemType]) { - errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); - continue; - } - const typeCount = parsedItems.filter(existing => existing.type === itemType).length; - const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); - if (typeCount >= maxAllowed) { - errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); - continue; - } - core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create-issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); - if (!issueNumValidation.isValid) { - if (issueNumValidation.error) errors.push(issueNumValidation.error); - continue; - } - item.body = sanitizeContent(item.body); - break; - case "create-pull-request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - item.branch = sanitizeContent(item.branch); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); - if (!labelsIssueNumValidation.isValid) { - if (labelsIssueNumValidation.error) errors.push(labelsIssueNumValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label)); - break; - case "update-issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "push-to-pull-request-branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch); - item.message = sanitizeContent(item.message); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push-to-pull-request-branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create-pull-request-review-comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line === "") continue; - } - item.body = sanitizeContent(item.body); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create-pull-request-review-comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create-discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category); - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - break; - case "missing-tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool); - item.reason = sanitizeContent(item.reason); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); - continue; + try { + const item = parseJsonWithRepair(line); + if (item === undefined) { + errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); + continue; } - item.alternatives = sanitizeContent(item.alternatives); - } - break; - case "upload-asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "create-code-scanning-alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); + if (!item.type) { + errors.push(`Line ${i + 1}: Missing required 'type' field`); + continue; } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); - continue; + const itemType = item.type; + if (!expectedOutputTypes[itemType]) { + errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); + continue; } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; + const typeCount = parsedItems.filter(existing => existing.type === itemType).length; + const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); + if (typeCount >= maxAllowed) { + errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); + continue; } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file); - item.severity = sanitizeContent(item.severity); - item.message = sanitizeContent(item.message); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); - continue; - } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; + core.info(`Line ${i + 1}: type '${itemType}'`); + switch (itemType) { + case "create-issue": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-comment": + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); + continue; + } + const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); + if (!issueNumValidation.isValid) { + if (issueNumValidation.error) + errors.push(issueNumValidation.error); + continue; + } + item.body = sanitizeContent(item.body); + break; + case "create-pull-request": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); + continue; + } + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + item.branch = sanitizeContent(item.branch); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-labels": + if (!item.labels || !Array.isArray(item.labels)) { + errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); + continue; + } + if (item.labels.some((label) => typeof label !== "string")) { + errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); + continue; + } + const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); + if (!labelsIssueNumValidation.isValid) { + if (labelsIssueNumValidation.error) + errors.push(labelsIssueNumValidation.error); + continue; + } + item.labels = item.labels.map((label) => sanitizeContent(label)); + break; + case "update-issue": + const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; + if (!hasValidField) { + errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); + continue; + } + if (item.status !== undefined) { + if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { + errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); + continue; + } + } + if (item.title !== undefined) { + if (typeof item.title !== "string") { + errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); + continue; + } + item.title = sanitizeContent(item.title); + } + if (item.body !== undefined) { + if (typeof item.body !== "string") { + errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); + continue; + } + item.body = sanitizeContent(item.body); + } + const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); + if (!updateIssueNumValidation.isValid) { + if (updateIssueNumValidation.error) + errors.push(updateIssueNumValidation.error); + continue; + } + break; + case "push-to-pull-request-branch": + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); + continue; + } + item.branch = sanitizeContent(item.branch); + item.message = sanitizeContent(item.message); + const pushPRNumValidation = validateIssueOrPRNumber(item.pull_request_number, "push-to-pull-request-branch 'pull_request_number'", i + 1); + if (!pushPRNumValidation.isValid) { + if (pushPRNumValidation.error) + errors.push(pushPRNumValidation.error); + continue; + } + break; + case "create-pull-request-review-comment": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); + continue; + } + const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); + if (!lineValidation.isValid) { + if (lineValidation.error) + errors.push(lineValidation.error); + continue; + } + const lineNumber = lineValidation.normalizedValue; + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); + continue; + } + item.body = sanitizeContent(item.body); + const startLineValidation = validateOptionalPositiveInteger(item.start_line, "create-pull-request-review-comment 'start_line'", i + 1); + if (!startLineValidation.isValid) { + if (startLineValidation.error) + errors.push(startLineValidation.error); + continue; + } + if (startLineValidation.normalizedValue !== undefined && + lineNumber !== undefined && + startLineValidation.normalizedValue > lineNumber) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); + continue; + } + if (item.side !== undefined) { + if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); + continue; + } + } + break; + case "create-discussion": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); + continue; + } + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); + continue; + } + item.category = sanitizeContent(item.category); + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + break; + case "missing-tool": + if (!item.tool || typeof item.tool !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); + continue; + } + if (!item.reason || typeof item.reason !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); + continue; + } + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); + continue; + } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "upload-asset": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); + continue; + } + break; + case "create-code-scanning-alert": + if (!item.file || typeof item.file !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); + continue; + } + const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); + if (!alertLineValidation.isValid) { + if (alertLineValidation.error) { + errors.push(alertLineValidation.error); + } + continue; + } + if (!item.severity || typeof item.severity !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); + continue; + } + const allowedSeverities = ["error", "warning", "info", "note"]; + if (!allowedSeverities.includes(item.severity.toLowerCase())) { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}`); + continue; + } + const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); + if (!columnValidation.isValid) { + if (columnValidation.error) + errors.push(columnValidation.error); + continue; + } + if (item.ruleIdSuffix !== undefined) { + if (typeof item.ruleIdSuffix !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); + continue; + } + if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores`); + continue; + } + } + item.severity = item.severity.toLowerCase(); + item.file = sanitizeContent(item.file); + item.severity = sanitizeContent(item.severity); + item.message = sanitizeContent(item.message); + if (item.ruleIdSuffix) { + item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); + } + break; + default: + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); + continue; + } + Object.assign(item, validation.normalizedItem); + } + break; } - Object.assign(item, validation.normalizedItem); - } - break; - } - core.info(`Line ${i + 1}: Valid ${itemType} item`); - parsedItems.push(item); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); + core.info(`Line ${i + 1}: Valid ${itemType} item`); + parsedItems.push(item); + } + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); + } } - } - if (errors.length > 0) { - core.warning("Validation errors found:"); - errors.forEach(error => core.warning(` - ${error}`)); - if (parsedItems.length === 0) { - core.setFailed(errors.map(e => ` - ${e}`).join("\n")); - return; + if (errors.length > 0) { + core.warning("Validation errors found:"); + errors.forEach(error => core.warning(` - ${error}`)); + if (parsedItems.length === 0) { + core.setFailed(errors.map(e => ` - ${e}`).join("\n")); + return; + } } - } - for (const itemType of Object.keys(expectedOutputTypes)) { - const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); - if (minRequired > 0) { - const actualCount = parsedItems.filter(item => item.type === itemType).length; - if (actualCount < minRequired) { - errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); - } + for (const itemType of Object.keys(expectedOutputTypes)) { + const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); + if (minRequired > 0) { + const actualCount = parsedItems.filter(item => item.type === itemType).length; + if (actualCount < minRequired) { + errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); + } + } + } + core.info(`Successfully parsed ${parsedItems.length} valid output items`); + const validatedOutput = { + items: parsedItems, + errors: errors, + }; + const agentOutputFile = "/tmp/agent_output.json"; + const validatedOutputJson = JSON.stringify(validatedOutput); + try { + fs.mkdirSync("/tmp", { recursive: true }); + fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); + core.info(`Stored validated output to: ${agentOutputFile}`); + core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); + } + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.error(`Failed to write agent output file: ${errorMsg}`); + } + core.setOutput("output", JSON.stringify(validatedOutput)); + core.setOutput("raw_output", outputContent); + const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); + core.info(`output_types: ${outputTypes.join(", ")}`); + core.setOutput("output_types", outputTypes.join(",")); + try { + await core.summary + .addRaw("## Processed Output\n\n") + .addRaw("```json\n") + .addRaw(JSON.stringify(validatedOutput)) + .addRaw("\n```\n") + .write(); + core.info("Successfully wrote processed output to step summary"); + } + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.warning(`Failed to write to step summary: ${errorMsg}`); } - } - core.info(`Successfully parsed ${parsedItems.length} valid output items`); - const validatedOutput = { - items: parsedItems, - errors: errors, - }; - const agentOutputFile = "/tmp/agent_output.json"; - const validatedOutputJson = JSON.stringify(validatedOutput); - try { - fs.mkdirSync("/tmp", { recursive: true }); - fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); - core.info(`Stored validated output to: ${agentOutputFile}`); - core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.error(`Failed to write agent output file: ${errorMsg}`); - } - core.setOutput("output", JSON.stringify(validatedOutput)); - core.setOutput("raw_output", outputContent); - const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); - core.info(`output_types: ${outputTypes.join(", ")}`); - core.setOutput("output_types", outputTypes.join(",")); - try { - await core.summary - .addRaw("## Processed Output\n\n") - .addRaw("```json\n") - .addRaw(JSON.stringify(validatedOutput)) - .addRaw("\n```\n") - .write(); - core.info("Successfully wrote processed output to step summary"); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.warning(`Failed to write to step summary: ${errorMsg}`); - } } await main(); - name: Upload sanitized agent output @@ -2748,165 +2748,163 @@ jobs: with: script: | function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace( - /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, - (_m, p1, p2) => `${p1}\`@${p2}\`` - ); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); + if (!content || typeof content !== "string") { + return ""; + } + let sanitized = content.trim(); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); + sanitized = sanitized.replace(/[<>&'"]/g, ""); + return sanitized.trim(); } async function main() { - const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; - const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; - if (!outputContent) { - core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); - return; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - return; - } - const createIssueItems = validatedOutput.items.filter(item => item.type === "create-issue"); - if (createIssueItems.length === 0) { - core.info("No create-issue items found in agent output"); - return; - } - core.info(`Found ${createIssueItems.length} create-issue item(s)`); - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Issues Preview\n\n"; - summaryContent += "The following issues would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createIssueItems.length; i++) { - const item = createIssueItems[i]; - summaryContent += `### Issue ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; - } - if (item.labels && item.labels.length > 0) { - summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; - } - summaryContent += "---\n\n"; + const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; + const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; + if (!outputContent) { + core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); + return; } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Issue creation preview written to step summary"); - return; - } - const parentIssueNumber = context.payload?.issue?.number; - const labelsEnv = process.env.GITHUB_AW_ISSUE_LABELS; - let envLabels = labelsEnv - ? labelsEnv - .split(",") - .map(label => label.trim()) - .filter(label => label) - : []; - const createdIssues = []; - for (let i = 0; i < createIssueItems.length; i++) { - const createIssueItem = createIssueItems[i]; - core.info( - `Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}` - ); - let labels = [...envLabels]; - if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { - labels = [...labels, ...createIssueItem.labels]; - } - labels = labels - .filter(label => label != null && label !== false && label !== 0) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - let title = createIssueItem.title ? createIssueItem.title.trim() : ""; - let bodyLines = createIssueItem.body.split("\n"); - if (!title) { - title = createIssueItem.body || "Agent Output"; - } - const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - if (parentIssueNumber) { - core.info("Detected issue context, parent issue #" + parentIssueNumber); - bodyLines.push(`Related to #${parentIssueNumber}`); - } - const workflowName = process.env.GITHUB_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const runUrl = context.payload.repository - ? `${context.payload.repository.html_url}/actions/runs/${runId}` - : `https://github.com/actions/runs/${runId}`; - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating issue with title: ${title}`); - core.info(`Labels: ${labels}`); - core.info(`Body length: ${body.length}`); + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: body, - labels: labels, - }); - core.info("Created issue #" + issue.number + ": " + issue.html_url); - createdIssues.push(issue); - if (parentIssueNumber) { + validatedOutput = JSON.parse(outputContent); + } + catch (error) { + core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); + return; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + return; + } + const createIssueItems = validatedOutput.items.filter(item => item.type === "create-issue"); + if (createIssueItems.length === 0) { + core.info("No create-issue items found in agent output"); + return; + } + core.info(`Found ${createIssueItems.length} create-issue item(s)`); + if (isStaged) { + let summaryContent = "## 🎭 Staged Mode: Create Issues Preview\n\n"; + summaryContent += "The following issues would be created if staged mode was disabled:\n\n"; + for (let i = 0; i < createIssueItems.length; i++) { + const item = createIssueItems[i]; + summaryContent += `### Issue ${i + 1}\n`; + summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; + if (item.body) { + summaryContent += `**Body:**\n${item.body}\n\n`; + } + if (item.labels && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; + } + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Issue creation preview written to step summary"); + return; + } + const parentIssueNumber = context.payload?.issue?.number; + const labelsEnv = process.env.GITHUB_AW_ISSUE_LABELS; + let envLabels = labelsEnv + ? labelsEnv + .split(",") + .map((label) => label.trim()) + .filter((label) => label) + : []; + const createdIssues = []; + for (let i = 0; i < createIssueItems.length; i++) { + const createIssueItem = createIssueItems[i]; + core.info(`Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}`); + let labels = [...envLabels]; + if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { + labels = [...labels, ...createIssueItem.labels]; + } + labels = labels + .filter(label => label != null && label !== false && label !== 0) + .map(label => String(label).trim()) + .filter(label => label) + .map(label => sanitizeLabelContent(label)) + .filter(label => label) + .map(label => (label.length > 64 ? label.substring(0, 64) : label)) + .filter((label, index, arr) => arr.indexOf(label) === index); + let title = createIssueItem.title ? createIssueItem.title.trim() : ""; + let bodyLines = createIssueItem.body.split("\n"); + if (!title) { + title = createIssueItem.body || "Agent Output"; + } + const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; + if (titlePrefix && !title.startsWith(titlePrefix)) { + title = titlePrefix + title; + } + if (parentIssueNumber) { + core.info("Detected issue context, parent issue #" + parentIssueNumber); + bodyLines.push(`Related to #${parentIssueNumber}`); + } + const workflowName = process.env.GITHUB_AW_WORKFLOW_NAME || "Workflow"; + const runId = context.runId; + const runUrl = context.payload.repository + ? `${context.payload.repository.html_url}/actions/runs/${runId}` + : `https://github.com/actions/runs/${runId}`; + bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); + const body = bodyLines.join("\n").trim(); + core.info(`Creating issue with title: ${title}`); + core.info(`Labels: ${labels}`); + core.info(`Body length: ${body.length}`); try { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: parentIssueNumber, - body: `Created related issue: #${issue.number}`, - }); - core.info("Added comment to parent issue #" + parentIssueNumber); - } catch (error) { - core.info(`Warning: Could not add comment to parent issue: ${error instanceof Error ? error.message : String(error)}`); + const { data: issue } = await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: title, + body: body, + labels: labels, + }); + core.info("Created issue #" + issue.number + ": " + issue.html_url); + createdIssues.push(issue); + if (parentIssueNumber) { + try { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: parentIssueNumber, + body: `Created related issue: #${issue.number}`, + }); + core.info("Added comment to parent issue #" + parentIssueNumber); + } + catch (error) { + core.info(`Warning: Could not add comment to parent issue: ${error instanceof Error ? error.message : String(error)}`); + } + } + if (i === createIssueItems.length - 1) { + core.setOutput("issue_number", issue.number); + core.setOutput("issue_url", issue.html_url); + } + } + catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage.includes("Issues has been disabled in this repository")) { + core.info(`⚠ Cannot create issue "${title}": Issues are disabled for this repository`); + core.info("Consider enabling issues in repository settings if you want to create issues automatically"); + continue; + } + core.error(`✗ Failed to create issue "${title}": ${errorMessage}`); + throw error; } - } - if (i === createIssueItems.length - 1) { - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Issues has been disabled in this repository")) { - core.info(`⚠ Cannot create issue "${title}": Issues are disabled for this repository`); - core.info("Consider enabling issues in repository settings if you want to create issues automatically"); - continue; - } - core.error(`✗ Failed to create issue "${title}": ${errorMessage}`); - throw error; } - } - if (createdIssues.length > 0) { - let summaryContent = "\n\n## GitHub Issues\n"; - for (const issue of createdIssues) { - summaryContent += `- Issue #${issue.number}: [${issue.title}](${issue.html_url})\n`; + if (createdIssues.length > 0) { + let summaryContent = "\n\n## GitHub Issues\n"; + for (const issue of createdIssues) { + summaryContent += `- Issue #${issue.number}: [${issue.title}](${issue.html_url})\n`; + } + await core.summary.addRaw(summaryContent).write(); } - await core.summary.addRaw(summaryContent).write(); - } - core.info(`Successfully created ${createdIssues.length} issue(s)`); + core.info(`Successfully created ${createdIssues.length} issue(s)`); } (async () => { - await main(); + await main(); })(); missing_tool: diff --git a/.github/workflows/brave.lock.yml b/.github/workflows/brave.lock.yml index 2cf1ae92507..d820846498a 100644 --- a/.github/workflows/brave.lock.yml +++ b/.github/workflows/brave.lock.yml @@ -1515,742 +1515,742 @@ jobs: with: script: | async function main() { - const fs = require("fs"); - function sanitizeContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; - const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; - const allowedDomains = allowedDomainsEnv - ? allowedDomainsEnv - .split(",") - .map(d => d.trim()) - .filter(d => d) - : defaultAllowedDomains; - let sanitized = content; - sanitized = neutralizeMentions(sanitized); - sanitized = removeXmlComments(sanitized); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitizeUrlProtocols(sanitized); - sanitized = sanitizeUrlDomains(sanitized); - const maxLength = 524288; - if (sanitized.length > maxLength) { - sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; - } - const lines = sanitized.split("\n"); - const maxLines = 65000; - if (lines.length > maxLines) { - sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; - } - sanitized = neutralizeBotTriggers(sanitized); - return sanitized.trim(); - function sanitizeUrlDomains(s) { - return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { - const urlAfterProtocol = match.slice(8); - const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); - const isAllowed = allowedDomains.some(allowedDomain => { - const normalizedAllowed = allowedDomain.toLowerCase(); - return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); - }); - return isAllowed ? match : "(redacted)"; - }); - } - function sanitizeUrlProtocols(s) { - return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { - return protocol.toLowerCase() === "https" ? match : "(redacted)"; - }); - } - function neutralizeMentions(s) { - return s.replace( - /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, - (_m, p1, p2) => `${p1}\`@${p2}\`` - ); - } - function removeXmlComments(s) { - return s.replace(//g, "").replace(//g, ""); - } - function neutralizeBotTriggers(s) { - return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); - } - } - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create-issue": - return 1; - case "add-comment": - return 1; - case "create-pull-request": - return 1; - case "create-pull-request-review-comment": - return 1; - case "add-labels": - return 5; - case "update-issue": - return 1; - case "push-to-pull-request-branch": - return 1; - case "create-discussion": - return 1; - case "missing-tool": - return 1000; - case "create-code-scanning-alert": - return 1000; - case "upload-asset": - return 10; - default: - return 1; - } - } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; - } - return 0; - } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; - } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; + const fs = require("fs"); + function sanitizeContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; + const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; + const allowedDomains = allowedDomainsEnv + ? allowedDomainsEnv + .split(",") + .map(d => d.trim()) + .filter(d => d) + : defaultAllowedDomains; + let sanitized = content; + sanitized = neutralizeMentions(sanitized); + sanitized = removeXmlComments(sanitized); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitizeUrlProtocols(sanitized); + sanitized = sanitizeUrlDomains(sanitized); + const maxLength = 524288; + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; + } + const lines = sanitized.split("\n"); + const maxLines = 65000; + if (lines.length > maxLines) { + sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; + } + sanitized = neutralizeBotTriggers(sanitized); + return sanitized.trim(); + function sanitizeUrlDomains(s) { + return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { + const urlAfterProtocol = match.slice(8); + const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); + const isAllowed = allowedDomains.some(allowedDomain => { + const normalizedAllowed = allowedDomain.toLowerCase(); + return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); + }); + return isAllowed ? match : "(redacted)"; + }); + } + function sanitizeUrlProtocols(s) { + return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { + return protocol.toLowerCase() === "https" ? match : "(redacted)"; + }); + } + function neutralizeMentions(s) { + return s.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); + } + function removeXmlComments(s) { + return s.replace(//g, "").replace(//g, ""); + } + function neutralizeBotTriggers(s) { + return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); + } } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + switch (itemType) { + case "create-issue": + return 1; + case "add-comment": + return 1; + case "create-pull-request": + return 1; + case "create-pull-request-review-comment": + return 1; + case "add-labels": + return 5; + case "update-issue": + return 1; + case "push-to-pull-request-branch": + return 1; + case "create-discussion": + return 1; + case "missing-tool": + return 1000; + case "create-code-scanning-alert": + return 1000; + case "upload-asset": + return 10; + default: + return 1; + } } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; + } + return 0; + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } + else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } + else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; } - return { isValid: true, normalizedValue: parsed }; - } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { + if (inputSchema.required && (value === undefined || value === null)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (value === undefined || value === null) { + return { + isValid: true, + normalizedValue: inputSchema.default || undefined, + }; + } + const inputType = inputSchema.type || "string"; + let normalizedValue = value; + switch (inputType) { + case "string": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string`, + }; + } + normalizedValue = sanitizeContent(value); + break; + case "boolean": + if (typeof value !== "boolean") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a boolean`, + }; + } + break; + case "number": + if (typeof value !== "number") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number`, + }; + } + break; + case "choice": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, + }; + } + if (inputSchema.options && !inputSchema.options.includes(value)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, + }; + } + normalizedValue = sanitizeContent(value); + break; + default: + if (typeof value === "string") { + normalizedValue = sanitizeContent(value); + } + break; + } return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, + isValid: true, + normalizedValue, }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { + function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { + const errors = []; + const normalizedItem = { ...item }; + if (!jobConfig.inputs) { + return { + isValid: true, + errors: [], + normalizedItem: item, + }; + } + for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { + const fieldValue = item[fieldName]; + const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); + if (!validation.isValid && validation.error) { + errors.push(validation.error); + } + else if (validation.normalizedValue !== undefined) { + normalizedItem[fieldName] = validation.normalizedValue; + } + } return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, + isValid: errors.length === 0, + errors, + normalizedItem, }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; - } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + function parseJsonWithRepair(jsonStr) { + try { + return JSON.parse(jsonStr); + } + catch (originalError) { + try { + const repairedJson = repairJson(jsonStr); + return JSON.parse(repairedJson); + } + catch (repairError) { + core.info(`invalid input json: ${jsonStr}`); + const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); + const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); + throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); + } + } } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; + const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; + if (!outputFile) { + core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); + core.setOutput("output", ""); + return; } - return { isValid: true }; - } - function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { - if (inputSchema.required && (value === undefined || value === null)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; + if (!fs.existsSync(outputFile)) { + core.info(`Output file does not exist: ${outputFile}`); + core.setOutput("output", ""); + return; } - if (value === undefined || value === null) { - return { - isValid: true, - normalizedValue: inputSchema.default || undefined, - }; + const outputContent = fs.readFileSync(outputFile, "utf8"); + if (outputContent.trim() === "") { + core.info("Output file is empty"); } - const inputType = inputSchema.type || "string"; - let normalizedValue = value; - switch (inputType) { - case "string": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string`, - }; - } - normalizedValue = sanitizeContent(value); - break; - case "boolean": - if (typeof value !== "boolean") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a boolean`, - }; + core.info(`Raw output content length: ${outputContent.length}`); + let expectedOutputTypes = {}; + if (safeOutputsConfig) { + try { + expectedOutputTypes = JSON.parse(safeOutputsConfig); + core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); } - break; - case "number": - if (typeof value !== "number") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number`, - }; + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); } - break; - case "choice": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, - }; - } - if (inputSchema.options && !inputSchema.options.includes(value)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, - }; - } - normalizedValue = sanitizeContent(value); - break; - default: - if (typeof value === "string") { - normalizedValue = sanitizeContent(value); - } - break; } - return { - isValid: true, - normalizedValue, - }; - } - function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { + const lines = outputContent.trim().split("\n"); + const parsedItems = []; const errors = []; - const normalizedItem = { ...item }; - if (!jobConfig.inputs) { - return { - isValid: true, - errors: [], - normalizedItem: item, - }; - } - for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { - const fieldValue = item[fieldName]; - const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); - if (!validation.isValid && validation.error) { - errors.push(validation.error); - } else if (validation.normalizedValue !== undefined) { - normalizedItem[fieldName] = validation.normalizedValue; - } - } - return { - isValid: errors.length === 0, - errors, - normalizedItem, - }; - } - function parseJsonWithRepair(jsonStr) { - try { - return JSON.parse(jsonStr); - } catch (originalError) { - try { - const repairedJson = repairJson(jsonStr); - return JSON.parse(repairedJson); - } catch (repairError) { - core.info(`invalid input json: ${jsonStr}`); - const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); - const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); - throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); - } - } - } - const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; - const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; - if (!outputFile) { - core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); - core.setOutput("output", ""); - return; - } - if (!fs.existsSync(outputFile)) { - core.info(`Output file does not exist: ${outputFile}`); - core.setOutput("output", ""); - return; - } - const outputContent = fs.readFileSync(outputFile, "utf8"); - if (outputContent.trim() === "") { - core.info("Output file is empty"); - } - core.info(`Raw output content length: ${outputContent.length}`); - let expectedOutputTypes = {}; - if (safeOutputsConfig) { - try { - expectedOutputTypes = JSON.parse(safeOutputsConfig); - core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); - } - } - const lines = outputContent.trim().split("\n"); - const parsedItems = []; - const errors = []; - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); - if (line === "") continue; - try { - const item = parseJsonWithRepair(line); - if (item === undefined) { - errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); - continue; - } - if (!item.type) { - errors.push(`Line ${i + 1}: Missing required 'type' field`); - continue; - } - const itemType = item.type; - if (!expectedOutputTypes[itemType]) { - errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); - continue; - } - const typeCount = parsedItems.filter(existing => existing.type === itemType).length; - const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); - if (typeCount >= maxAllowed) { - errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); - continue; - } - core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create-issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line === "") continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); - if (!issueNumValidation.isValid) { - if (issueNumValidation.error) errors.push(issueNumValidation.error); - continue; - } - item.body = sanitizeContent(item.body); - break; - case "create-pull-request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - item.branch = sanitizeContent(item.branch); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); - if (!labelsIssueNumValidation.isValid) { - if (labelsIssueNumValidation.error) errors.push(labelsIssueNumValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label)); - break; - case "update-issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "push-to-pull-request-branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch); - item.message = sanitizeContent(item.message); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push-to-pull-request-branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create-pull-request-review-comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create-pull-request-review-comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create-discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category); - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - break; - case "missing-tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool); - item.reason = sanitizeContent(item.reason); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); - continue; + try { + const item = parseJsonWithRepair(line); + if (item === undefined) { + errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); + continue; } - item.alternatives = sanitizeContent(item.alternatives); - } - break; - case "upload-asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "create-code-scanning-alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); + if (!item.type) { + errors.push(`Line ${i + 1}: Missing required 'type' field`); + continue; } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); - continue; + const itemType = item.type; + if (!expectedOutputTypes[itemType]) { + errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); + continue; } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; + const typeCount = parsedItems.filter(existing => existing.type === itemType).length; + const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); + if (typeCount >= maxAllowed) { + errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); + continue; } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file); - item.severity = sanitizeContent(item.severity); - item.message = sanitizeContent(item.message); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); - continue; - } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; + core.info(`Line ${i + 1}: type '${itemType}'`); + switch (itemType) { + case "create-issue": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-comment": + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); + continue; + } + const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); + if (!issueNumValidation.isValid) { + if (issueNumValidation.error) + errors.push(issueNumValidation.error); + continue; + } + item.body = sanitizeContent(item.body); + break; + case "create-pull-request": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); + continue; + } + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + item.branch = sanitizeContent(item.branch); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-labels": + if (!item.labels || !Array.isArray(item.labels)) { + errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); + continue; + } + if (item.labels.some((label) => typeof label !== "string")) { + errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); + continue; + } + const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); + if (!labelsIssueNumValidation.isValid) { + if (labelsIssueNumValidation.error) + errors.push(labelsIssueNumValidation.error); + continue; + } + item.labels = item.labels.map((label) => sanitizeContent(label)); + break; + case "update-issue": + const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; + if (!hasValidField) { + errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); + continue; + } + if (item.status !== undefined) { + if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { + errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); + continue; + } + } + if (item.title !== undefined) { + if (typeof item.title !== "string") { + errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); + continue; + } + item.title = sanitizeContent(item.title); + } + if (item.body !== undefined) { + if (typeof item.body !== "string") { + errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); + continue; + } + item.body = sanitizeContent(item.body); + } + const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); + if (!updateIssueNumValidation.isValid) { + if (updateIssueNumValidation.error) + errors.push(updateIssueNumValidation.error); + continue; + } + break; + case "push-to-pull-request-branch": + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); + continue; + } + item.branch = sanitizeContent(item.branch); + item.message = sanitizeContent(item.message); + const pushPRNumValidation = validateIssueOrPRNumber(item.pull_request_number, "push-to-pull-request-branch 'pull_request_number'", i + 1); + if (!pushPRNumValidation.isValid) { + if (pushPRNumValidation.error) + errors.push(pushPRNumValidation.error); + continue; + } + break; + case "create-pull-request-review-comment": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); + continue; + } + const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); + if (!lineValidation.isValid) { + if (lineValidation.error) + errors.push(lineValidation.error); + continue; + } + const lineNumber = lineValidation.normalizedValue; + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); + continue; + } + item.body = sanitizeContent(item.body); + const startLineValidation = validateOptionalPositiveInteger(item.start_line, "create-pull-request-review-comment 'start_line'", i + 1); + if (!startLineValidation.isValid) { + if (startLineValidation.error) + errors.push(startLineValidation.error); + continue; + } + if (startLineValidation.normalizedValue !== undefined && + lineNumber !== undefined && + startLineValidation.normalizedValue > lineNumber) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); + continue; + } + if (item.side !== undefined) { + if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); + continue; + } + } + break; + case "create-discussion": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); + continue; + } + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); + continue; + } + item.category = sanitizeContent(item.category); + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + break; + case "missing-tool": + if (!item.tool || typeof item.tool !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); + continue; + } + if (!item.reason || typeof item.reason !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); + continue; + } + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); + continue; + } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "upload-asset": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); + continue; + } + break; + case "create-code-scanning-alert": + if (!item.file || typeof item.file !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); + continue; + } + const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); + if (!alertLineValidation.isValid) { + if (alertLineValidation.error) { + errors.push(alertLineValidation.error); + } + continue; + } + if (!item.severity || typeof item.severity !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); + continue; + } + const allowedSeverities = ["error", "warning", "info", "note"]; + if (!allowedSeverities.includes(item.severity.toLowerCase())) { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}`); + continue; + } + const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); + if (!columnValidation.isValid) { + if (columnValidation.error) + errors.push(columnValidation.error); + continue; + } + if (item.ruleIdSuffix !== undefined) { + if (typeof item.ruleIdSuffix !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); + continue; + } + if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores`); + continue; + } + } + item.severity = item.severity.toLowerCase(); + item.file = sanitizeContent(item.file); + item.severity = sanitizeContent(item.severity); + item.message = sanitizeContent(item.message); + if (item.ruleIdSuffix) { + item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); + } + break; + default: + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); + continue; + } + Object.assign(item, validation.normalizedItem); + } + break; } - Object.assign(item, validation.normalizedItem); - } - break; - } - core.info(`Line ${i + 1}: Valid ${itemType} item`); - parsedItems.push(item); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); + core.info(`Line ${i + 1}: Valid ${itemType} item`); + parsedItems.push(item); + } + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); + } } - } - if (errors.length > 0) { - core.warning("Validation errors found:"); - errors.forEach(error => core.warning(` - ${error}`)); - if (parsedItems.length === 0) { - core.setFailed(errors.map(e => ` - ${e}`).join("\n")); - return; + if (errors.length > 0) { + core.warning("Validation errors found:"); + errors.forEach(error => core.warning(` - ${error}`)); + if (parsedItems.length === 0) { + core.setFailed(errors.map(e => ` - ${e}`).join("\n")); + return; + } } - } - for (const itemType of Object.keys(expectedOutputTypes)) { - const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); - if (minRequired > 0) { - const actualCount = parsedItems.filter(item => item.type === itemType).length; - if (actualCount < minRequired) { - errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); - } + for (const itemType of Object.keys(expectedOutputTypes)) { + const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); + if (minRequired > 0) { + const actualCount = parsedItems.filter(item => item.type === itemType).length; + if (actualCount < minRequired) { + errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); + } + } + } + core.info(`Successfully parsed ${parsedItems.length} valid output items`); + const validatedOutput = { + items: parsedItems, + errors: errors, + }; + const agentOutputFile = "/tmp/agent_output.json"; + const validatedOutputJson = JSON.stringify(validatedOutput); + try { + fs.mkdirSync("/tmp", { recursive: true }); + fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); + core.info(`Stored validated output to: ${agentOutputFile}`); + core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); + } + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.error(`Failed to write agent output file: ${errorMsg}`); + } + core.setOutput("output", JSON.stringify(validatedOutput)); + core.setOutput("raw_output", outputContent); + const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); + core.info(`output_types: ${outputTypes.join(", ")}`); + core.setOutput("output_types", outputTypes.join(",")); + try { + await core.summary + .addRaw("## Processed Output\n\n") + .addRaw("```json\n") + .addRaw(JSON.stringify(validatedOutput)) + .addRaw("\n```\n") + .write(); + core.info("Successfully wrote processed output to step summary"); + } + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.warning(`Failed to write to step summary: ${errorMsg}`); } - } - core.info(`Successfully parsed ${parsedItems.length} valid output items`); - const validatedOutput = { - items: parsedItems, - errors: errors, - }; - const agentOutputFile = "/tmp/agent_output.json"; - const validatedOutputJson = JSON.stringify(validatedOutput); - try { - fs.mkdirSync("/tmp", { recursive: true }); - fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); - core.info(`Stored validated output to: ${agentOutputFile}`); - core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.error(`Failed to write agent output file: ${errorMsg}`); - } - core.setOutput("output", JSON.stringify(validatedOutput)); - core.setOutput("raw_output", outputContent); - const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); - core.info(`output_types: ${outputTypes.join(", ")}`); - core.setOutput("output_types", outputTypes.join(",")); - try { - await core.summary - .addRaw("## Processed Output\n\n") - .addRaw("```json\n") - .addRaw(JSON.stringify(validatedOutput)) - .addRaw("\n```\n") - .write(); - core.info("Successfully wrote processed output to step summary"); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.warning(`Failed to write to step summary: ${errorMsg}`); - } } await main(); - name: Upload sanitized agent output diff --git a/.github/workflows/changeset-generator.lock.yml b/.github/workflows/changeset-generator.lock.yml index 5b2f00c5e9a..872d0f602f3 100644 --- a/.github/workflows/changeset-generator.lock.yml +++ b/.github/workflows/changeset-generator.lock.yml @@ -1460,742 +1460,742 @@ jobs: with: script: | async function main() { - const fs = require("fs"); - function sanitizeContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; - const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; - const allowedDomains = allowedDomainsEnv - ? allowedDomainsEnv - .split(",") - .map(d => d.trim()) - .filter(d => d) - : defaultAllowedDomains; - let sanitized = content; - sanitized = neutralizeMentions(sanitized); - sanitized = removeXmlComments(sanitized); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitizeUrlProtocols(sanitized); - sanitized = sanitizeUrlDomains(sanitized); - const maxLength = 524288; - if (sanitized.length > maxLength) { - sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; - } - const lines = sanitized.split("\n"); - const maxLines = 65000; - if (lines.length > maxLines) { - sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; - } - sanitized = neutralizeBotTriggers(sanitized); - return sanitized.trim(); - function sanitizeUrlDomains(s) { - return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { - const urlAfterProtocol = match.slice(8); - const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); - const isAllowed = allowedDomains.some(allowedDomain => { - const normalizedAllowed = allowedDomain.toLowerCase(); - return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); - }); - return isAllowed ? match : "(redacted)"; - }); - } - function sanitizeUrlProtocols(s) { - return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { - return protocol.toLowerCase() === "https" ? match : "(redacted)"; - }); - } - function neutralizeMentions(s) { - return s.replace( - /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, - (_m, p1, p2) => `${p1}\`@${p2}\`` - ); - } - function removeXmlComments(s) { - return s.replace(//g, "").replace(//g, ""); - } - function neutralizeBotTriggers(s) { - return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); - } - } - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create-issue": - return 1; - case "add-comment": - return 1; - case "create-pull-request": - return 1; - case "create-pull-request-review-comment": - return 1; - case "add-labels": - return 5; - case "update-issue": - return 1; - case "push-to-pull-request-branch": - return 1; - case "create-discussion": - return 1; - case "missing-tool": - return 1000; - case "create-code-scanning-alert": - return 1000; - case "upload-asset": - return 10; - default: - return 1; - } - } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; - } - return 0; - } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; - } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; + const fs = require("fs"); + function sanitizeContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; + const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; + const allowedDomains = allowedDomainsEnv + ? allowedDomainsEnv + .split(",") + .map(d => d.trim()) + .filter(d => d) + : defaultAllowedDomains; + let sanitized = content; + sanitized = neutralizeMentions(sanitized); + sanitized = removeXmlComments(sanitized); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitizeUrlProtocols(sanitized); + sanitized = sanitizeUrlDomains(sanitized); + const maxLength = 524288; + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; + } + const lines = sanitized.split("\n"); + const maxLines = 65000; + if (lines.length > maxLines) { + sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; + } + sanitized = neutralizeBotTriggers(sanitized); + return sanitized.trim(); + function sanitizeUrlDomains(s) { + return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { + const urlAfterProtocol = match.slice(8); + const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); + const isAllowed = allowedDomains.some(allowedDomain => { + const normalizedAllowed = allowedDomain.toLowerCase(); + return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); + }); + return isAllowed ? match : "(redacted)"; + }); + } + function sanitizeUrlProtocols(s) { + return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { + return protocol.toLowerCase() === "https" ? match : "(redacted)"; + }); + } + function neutralizeMentions(s) { + return s.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); + } + function removeXmlComments(s) { + return s.replace(//g, "").replace(//g, ""); + } + function neutralizeBotTriggers(s) { + return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); + } } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + switch (itemType) { + case "create-issue": + return 1; + case "add-comment": + return 1; + case "create-pull-request": + return 1; + case "create-pull-request-review-comment": + return 1; + case "add-labels": + return 5; + case "update-issue": + return 1; + case "push-to-pull-request-branch": + return 1; + case "create-discussion": + return 1; + case "missing-tool": + return 1000; + case "create-code-scanning-alert": + return 1000; + case "upload-asset": + return 10; + default: + return 1; + } } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; + } + return 0; + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } + else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } + else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; } - return { isValid: true, normalizedValue: parsed }; - } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { + if (inputSchema.required && (value === undefined || value === null)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (value === undefined || value === null) { + return { + isValid: true, + normalizedValue: inputSchema.default || undefined, + }; + } + const inputType = inputSchema.type || "string"; + let normalizedValue = value; + switch (inputType) { + case "string": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string`, + }; + } + normalizedValue = sanitizeContent(value); + break; + case "boolean": + if (typeof value !== "boolean") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a boolean`, + }; + } + break; + case "number": + if (typeof value !== "number") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number`, + }; + } + break; + case "choice": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, + }; + } + if (inputSchema.options && !inputSchema.options.includes(value)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, + }; + } + normalizedValue = sanitizeContent(value); + break; + default: + if (typeof value === "string") { + normalizedValue = sanitizeContent(value); + } + break; + } return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, + isValid: true, + normalizedValue, }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { + function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { + const errors = []; + const normalizedItem = { ...item }; + if (!jobConfig.inputs) { + return { + isValid: true, + errors: [], + normalizedItem: item, + }; + } + for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { + const fieldValue = item[fieldName]; + const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); + if (!validation.isValid && validation.error) { + errors.push(validation.error); + } + else if (validation.normalizedValue !== undefined) { + normalizedItem[fieldName] = validation.normalizedValue; + } + } return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, + isValid: errors.length === 0, + errors, + normalizedItem, }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; - } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + function parseJsonWithRepair(jsonStr) { + try { + return JSON.parse(jsonStr); + } + catch (originalError) { + try { + const repairedJson = repairJson(jsonStr); + return JSON.parse(repairedJson); + } + catch (repairError) { + core.info(`invalid input json: ${jsonStr}`); + const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); + const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); + throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); + } + } } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; + const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; + if (!outputFile) { + core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); + core.setOutput("output", ""); + return; } - return { isValid: true }; - } - function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { - if (inputSchema.required && (value === undefined || value === null)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; + if (!fs.existsSync(outputFile)) { + core.info(`Output file does not exist: ${outputFile}`); + core.setOutput("output", ""); + return; } - if (value === undefined || value === null) { - return { - isValid: true, - normalizedValue: inputSchema.default || undefined, - }; + const outputContent = fs.readFileSync(outputFile, "utf8"); + if (outputContent.trim() === "") { + core.info("Output file is empty"); } - const inputType = inputSchema.type || "string"; - let normalizedValue = value; - switch (inputType) { - case "string": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string`, - }; - } - normalizedValue = sanitizeContent(value); - break; - case "boolean": - if (typeof value !== "boolean") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a boolean`, - }; - } - break; - case "number": - if (typeof value !== "number") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number`, - }; - } - break; - case "choice": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, - }; - } - if (inputSchema.options && !inputSchema.options.includes(value)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, - }; + core.info(`Raw output content length: ${outputContent.length}`); + let expectedOutputTypes = {}; + if (safeOutputsConfig) { + try { + expectedOutputTypes = JSON.parse(safeOutputsConfig); + core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); } - normalizedValue = sanitizeContent(value); - break; - default: - if (typeof value === "string") { - normalizedValue = sanitizeContent(value); + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); } - break; } - return { - isValid: true, - normalizedValue, - }; - } - function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { + const lines = outputContent.trim().split("\n"); + const parsedItems = []; const errors = []; - const normalizedItem = { ...item }; - if (!jobConfig.inputs) { - return { - isValid: true, - errors: [], - normalizedItem: item, - }; - } - for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { - const fieldValue = item[fieldName]; - const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); - if (!validation.isValid && validation.error) { - errors.push(validation.error); - } else if (validation.normalizedValue !== undefined) { - normalizedItem[fieldName] = validation.normalizedValue; - } - } - return { - isValid: errors.length === 0, - errors, - normalizedItem, - }; - } - function parseJsonWithRepair(jsonStr) { - try { - return JSON.parse(jsonStr); - } catch (originalError) { - try { - const repairedJson = repairJson(jsonStr); - return JSON.parse(repairedJson); - } catch (repairError) { - core.info(`invalid input json: ${jsonStr}`); - const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); - const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); - throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); - } - } - } - const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; - const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; - if (!outputFile) { - core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); - core.setOutput("output", ""); - return; - } - if (!fs.existsSync(outputFile)) { - core.info(`Output file does not exist: ${outputFile}`); - core.setOutput("output", ""); - return; - } - const outputContent = fs.readFileSync(outputFile, "utf8"); - if (outputContent.trim() === "") { - core.info("Output file is empty"); - } - core.info(`Raw output content length: ${outputContent.length}`); - let expectedOutputTypes = {}; - if (safeOutputsConfig) { - try { - expectedOutputTypes = JSON.parse(safeOutputsConfig); - core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); - } - } - const lines = outputContent.trim().split("\n"); - const parsedItems = []; - const errors = []; - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); - if (line === "") continue; - try { - const item = parseJsonWithRepair(line); - if (item === undefined) { - errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); - continue; - } - if (!item.type) { - errors.push(`Line ${i + 1}: Missing required 'type' field`); - continue; - } - const itemType = item.type; - if (!expectedOutputTypes[itemType]) { - errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); - continue; - } - const typeCount = parsedItems.filter(existing => existing.type === itemType).length; - const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); - if (typeCount >= maxAllowed) { - errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); - continue; - } - core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create-issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); - if (!issueNumValidation.isValid) { - if (issueNumValidation.error) errors.push(issueNumValidation.error); - continue; - } - item.body = sanitizeContent(item.body); - break; - case "create-pull-request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - item.branch = sanitizeContent(item.branch); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); - if (!labelsIssueNumValidation.isValid) { - if (labelsIssueNumValidation.error) errors.push(labelsIssueNumValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label)); - break; - case "update-issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "push-to-pull-request-branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch); - item.message = sanitizeContent(item.message); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push-to-pull-request-branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create-pull-request-review-comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line === "") continue; - } - item.body = sanitizeContent(item.body); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create-pull-request-review-comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create-discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category); - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - break; - case "missing-tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool); - item.reason = sanitizeContent(item.reason); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); - continue; + try { + const item = parseJsonWithRepair(line); + if (item === undefined) { + errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); + continue; } - item.alternatives = sanitizeContent(item.alternatives); - } - break; - case "upload-asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "create-code-scanning-alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); + if (!item.type) { + errors.push(`Line ${i + 1}: Missing required 'type' field`); + continue; } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); - continue; + const itemType = item.type; + if (!expectedOutputTypes[itemType]) { + errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); + continue; } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; + const typeCount = parsedItems.filter(existing => existing.type === itemType).length; + const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); + if (typeCount >= maxAllowed) { + errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); + continue; } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file); - item.severity = sanitizeContent(item.severity); - item.message = sanitizeContent(item.message); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); - continue; - } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; + core.info(`Line ${i + 1}: type '${itemType}'`); + switch (itemType) { + case "create-issue": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-comment": + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); + continue; + } + const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); + if (!issueNumValidation.isValid) { + if (issueNumValidation.error) + errors.push(issueNumValidation.error); + continue; + } + item.body = sanitizeContent(item.body); + break; + case "create-pull-request": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); + continue; + } + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + item.branch = sanitizeContent(item.branch); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-labels": + if (!item.labels || !Array.isArray(item.labels)) { + errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); + continue; + } + if (item.labels.some((label) => typeof label !== "string")) { + errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); + continue; + } + const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); + if (!labelsIssueNumValidation.isValid) { + if (labelsIssueNumValidation.error) + errors.push(labelsIssueNumValidation.error); + continue; + } + item.labels = item.labels.map((label) => sanitizeContent(label)); + break; + case "update-issue": + const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; + if (!hasValidField) { + errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); + continue; + } + if (item.status !== undefined) { + if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { + errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); + continue; + } + } + if (item.title !== undefined) { + if (typeof item.title !== "string") { + errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); + continue; + } + item.title = sanitizeContent(item.title); + } + if (item.body !== undefined) { + if (typeof item.body !== "string") { + errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); + continue; + } + item.body = sanitizeContent(item.body); + } + const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); + if (!updateIssueNumValidation.isValid) { + if (updateIssueNumValidation.error) + errors.push(updateIssueNumValidation.error); + continue; + } + break; + case "push-to-pull-request-branch": + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); + continue; + } + item.branch = sanitizeContent(item.branch); + item.message = sanitizeContent(item.message); + const pushPRNumValidation = validateIssueOrPRNumber(item.pull_request_number, "push-to-pull-request-branch 'pull_request_number'", i + 1); + if (!pushPRNumValidation.isValid) { + if (pushPRNumValidation.error) + errors.push(pushPRNumValidation.error); + continue; + } + break; + case "create-pull-request-review-comment": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); + continue; + } + const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); + if (!lineValidation.isValid) { + if (lineValidation.error) + errors.push(lineValidation.error); + continue; + } + const lineNumber = lineValidation.normalizedValue; + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); + continue; + } + item.body = sanitizeContent(item.body); + const startLineValidation = validateOptionalPositiveInteger(item.start_line, "create-pull-request-review-comment 'start_line'", i + 1); + if (!startLineValidation.isValid) { + if (startLineValidation.error) + errors.push(startLineValidation.error); + continue; + } + if (startLineValidation.normalizedValue !== undefined && + lineNumber !== undefined && + startLineValidation.normalizedValue > lineNumber) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); + continue; + } + if (item.side !== undefined) { + if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); + continue; + } + } + break; + case "create-discussion": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); + continue; + } + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); + continue; + } + item.category = sanitizeContent(item.category); + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + break; + case "missing-tool": + if (!item.tool || typeof item.tool !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); + continue; + } + if (!item.reason || typeof item.reason !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); + continue; + } + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); + continue; + } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "upload-asset": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); + continue; + } + break; + case "create-code-scanning-alert": + if (!item.file || typeof item.file !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); + continue; + } + const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); + if (!alertLineValidation.isValid) { + if (alertLineValidation.error) { + errors.push(alertLineValidation.error); + } + continue; + } + if (!item.severity || typeof item.severity !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); + continue; + } + const allowedSeverities = ["error", "warning", "info", "note"]; + if (!allowedSeverities.includes(item.severity.toLowerCase())) { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}`); + continue; + } + const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); + if (!columnValidation.isValid) { + if (columnValidation.error) + errors.push(columnValidation.error); + continue; + } + if (item.ruleIdSuffix !== undefined) { + if (typeof item.ruleIdSuffix !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); + continue; + } + if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores`); + continue; + } + } + item.severity = item.severity.toLowerCase(); + item.file = sanitizeContent(item.file); + item.severity = sanitizeContent(item.severity); + item.message = sanitizeContent(item.message); + if (item.ruleIdSuffix) { + item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); + } + break; + default: + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); + continue; + } + Object.assign(item, validation.normalizedItem); + } + break; } - Object.assign(item, validation.normalizedItem); - } - break; - } - core.info(`Line ${i + 1}: Valid ${itemType} item`); - parsedItems.push(item); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); + core.info(`Line ${i + 1}: Valid ${itemType} item`); + parsedItems.push(item); + } + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); + } } - } - if (errors.length > 0) { - core.warning("Validation errors found:"); - errors.forEach(error => core.warning(` - ${error}`)); - if (parsedItems.length === 0) { - core.setFailed(errors.map(e => ` - ${e}`).join("\n")); - return; + if (errors.length > 0) { + core.warning("Validation errors found:"); + errors.forEach(error => core.warning(` - ${error}`)); + if (parsedItems.length === 0) { + core.setFailed(errors.map(e => ` - ${e}`).join("\n")); + return; + } } - } - for (const itemType of Object.keys(expectedOutputTypes)) { - const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); - if (minRequired > 0) { - const actualCount = parsedItems.filter(item => item.type === itemType).length; - if (actualCount < minRequired) { - errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); - } + for (const itemType of Object.keys(expectedOutputTypes)) { + const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); + if (minRequired > 0) { + const actualCount = parsedItems.filter(item => item.type === itemType).length; + if (actualCount < minRequired) { + errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); + } + } + } + core.info(`Successfully parsed ${parsedItems.length} valid output items`); + const validatedOutput = { + items: parsedItems, + errors: errors, + }; + const agentOutputFile = "/tmp/agent_output.json"; + const validatedOutputJson = JSON.stringify(validatedOutput); + try { + fs.mkdirSync("/tmp", { recursive: true }); + fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); + core.info(`Stored validated output to: ${agentOutputFile}`); + core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); + } + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.error(`Failed to write agent output file: ${errorMsg}`); + } + core.setOutput("output", JSON.stringify(validatedOutput)); + core.setOutput("raw_output", outputContent); + const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); + core.info(`output_types: ${outputTypes.join(", ")}`); + core.setOutput("output_types", outputTypes.join(",")); + try { + await core.summary + .addRaw("## Processed Output\n\n") + .addRaw("```json\n") + .addRaw(JSON.stringify(validatedOutput)) + .addRaw("\n```\n") + .write(); + core.info("Successfully wrote processed output to step summary"); + } + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.warning(`Failed to write to step summary: ${errorMsg}`); } - } - core.info(`Successfully parsed ${parsedItems.length} valid output items`); - const validatedOutput = { - items: parsedItems, - errors: errors, - }; - const agentOutputFile = "/tmp/agent_output.json"; - const validatedOutputJson = JSON.stringify(validatedOutput); - try { - fs.mkdirSync("/tmp", { recursive: true }); - fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); - core.info(`Stored validated output to: ${agentOutputFile}`); - core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.error(`Failed to write agent output file: ${errorMsg}`); - } - core.setOutput("output", JSON.stringify(validatedOutput)); - core.setOutput("raw_output", outputContent); - const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); - core.info(`output_types: ${outputTypes.join(", ")}`); - core.setOutput("output_types", outputTypes.join(",")); - try { - await core.summary - .addRaw("## Processed Output\n\n") - .addRaw("```json\n") - .addRaw(JSON.stringify(validatedOutput)) - .addRaw("\n```\n") - .write(); - core.info("Successfully wrote processed output to step summary"); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.warning(`Failed to write to step summary: ${errorMsg}`); - } } await main(); - name: Upload sanitized agent output diff --git a/.github/workflows/ci-doctor.lock.yml b/.github/workflows/ci-doctor.lock.yml index bf20d72336e..eb9dafa9811 100644 --- a/.github/workflows/ci-doctor.lock.yml +++ b/.github/workflows/ci-doctor.lock.yml @@ -1210,742 +1210,742 @@ jobs: with: script: | async function main() { - const fs = require("fs"); - function sanitizeContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; - const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; - const allowedDomains = allowedDomainsEnv - ? allowedDomainsEnv - .split(",") - .map(d => d.trim()) - .filter(d => d) - : defaultAllowedDomains; - let sanitized = content; - sanitized = neutralizeMentions(sanitized); - sanitized = removeXmlComments(sanitized); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitizeUrlProtocols(sanitized); - sanitized = sanitizeUrlDomains(sanitized); - const maxLength = 524288; - if (sanitized.length > maxLength) { - sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; - } - const lines = sanitized.split("\n"); - const maxLines = 65000; - if (lines.length > maxLines) { - sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; - } - sanitized = neutralizeBotTriggers(sanitized); - return sanitized.trim(); - function sanitizeUrlDomains(s) { - return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { - const urlAfterProtocol = match.slice(8); - const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); - const isAllowed = allowedDomains.some(allowedDomain => { - const normalizedAllowed = allowedDomain.toLowerCase(); - return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); - }); - return isAllowed ? match : "(redacted)"; - }); - } - function sanitizeUrlProtocols(s) { - return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { - return protocol.toLowerCase() === "https" ? match : "(redacted)"; - }); - } - function neutralizeMentions(s) { - return s.replace( - /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, - (_m, p1, p2) => `${p1}\`@${p2}\`` - ); - } - function removeXmlComments(s) { - return s.replace(//g, "").replace(//g, ""); - } - function neutralizeBotTriggers(s) { - return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); - } - } - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create-issue": - return 1; - case "add-comment": - return 1; - case "create-pull-request": - return 1; - case "create-pull-request-review-comment": - return 1; - case "add-labels": - return 5; - case "update-issue": - return 1; - case "push-to-pull-request-branch": - return 1; - case "create-discussion": - return 1; - case "missing-tool": - return 1000; - case "create-code-scanning-alert": - return 1000; - case "upload-asset": - return 10; - default: - return 1; - } - } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; - } - return 0; - } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; - } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; + const fs = require("fs"); + function sanitizeContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; + const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; + const allowedDomains = allowedDomainsEnv + ? allowedDomainsEnv + .split(",") + .map(d => d.trim()) + .filter(d => d) + : defaultAllowedDomains; + let sanitized = content; + sanitized = neutralizeMentions(sanitized); + sanitized = removeXmlComments(sanitized); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitizeUrlProtocols(sanitized); + sanitized = sanitizeUrlDomains(sanitized); + const maxLength = 524288; + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; + } + const lines = sanitized.split("\n"); + const maxLines = 65000; + if (lines.length > maxLines) { + sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; + } + sanitized = neutralizeBotTriggers(sanitized); + return sanitized.trim(); + function sanitizeUrlDomains(s) { + return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { + const urlAfterProtocol = match.slice(8); + const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); + const isAllowed = allowedDomains.some(allowedDomain => { + const normalizedAllowed = allowedDomain.toLowerCase(); + return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); + }); + return isAllowed ? match : "(redacted)"; + }); + } + function sanitizeUrlProtocols(s) { + return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { + return protocol.toLowerCase() === "https" ? match : "(redacted)"; + }); + } + function neutralizeMentions(s) { + return s.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); + } + function removeXmlComments(s) { + return s.replace(//g, "").replace(//g, ""); + } + function neutralizeBotTriggers(s) { + return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); + } } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + switch (itemType) { + case "create-issue": + return 1; + case "add-comment": + return 1; + case "create-pull-request": + return 1; + case "create-pull-request-review-comment": + return 1; + case "add-labels": + return 5; + case "update-issue": + return 1; + case "push-to-pull-request-branch": + return 1; + case "create-discussion": + return 1; + case "missing-tool": + return 1000; + case "create-code-scanning-alert": + return 1000; + case "upload-asset": + return 10; + default: + return 1; + } } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; + } + return 0; + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } + else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } + else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; } - return { isValid: true, normalizedValue: parsed }; - } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { + if (inputSchema.required && (value === undefined || value === null)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (value === undefined || value === null) { + return { + isValid: true, + normalizedValue: inputSchema.default || undefined, + }; + } + const inputType = inputSchema.type || "string"; + let normalizedValue = value; + switch (inputType) { + case "string": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string`, + }; + } + normalizedValue = sanitizeContent(value); + break; + case "boolean": + if (typeof value !== "boolean") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a boolean`, + }; + } + break; + case "number": + if (typeof value !== "number") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number`, + }; + } + break; + case "choice": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, + }; + } + if (inputSchema.options && !inputSchema.options.includes(value)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, + }; + } + normalizedValue = sanitizeContent(value); + break; + default: + if (typeof value === "string") { + normalizedValue = sanitizeContent(value); + } + break; + } return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, + isValid: true, + normalizedValue, }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { + function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { + const errors = []; + const normalizedItem = { ...item }; + if (!jobConfig.inputs) { + return { + isValid: true, + errors: [], + normalizedItem: item, + }; + } + for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { + const fieldValue = item[fieldName]; + const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); + if (!validation.isValid && validation.error) { + errors.push(validation.error); + } + else if (validation.normalizedValue !== undefined) { + normalizedItem[fieldName] = validation.normalizedValue; + } + } return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, + isValid: errors.length === 0, + errors, + normalizedItem, }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; - } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + function parseJsonWithRepair(jsonStr) { + try { + return JSON.parse(jsonStr); + } + catch (originalError) { + try { + const repairedJson = repairJson(jsonStr); + return JSON.parse(repairedJson); + } + catch (repairError) { + core.info(`invalid input json: ${jsonStr}`); + const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); + const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); + throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); + } + } } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; + const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; + if (!outputFile) { + core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); + core.setOutput("output", ""); + return; } - return { isValid: true }; - } - function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { - if (inputSchema.required && (value === undefined || value === null)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; + if (!fs.existsSync(outputFile)) { + core.info(`Output file does not exist: ${outputFile}`); + core.setOutput("output", ""); + return; } - if (value === undefined || value === null) { - return { - isValid: true, - normalizedValue: inputSchema.default || undefined, - }; + const outputContent = fs.readFileSync(outputFile, "utf8"); + if (outputContent.trim() === "") { + core.info("Output file is empty"); } - const inputType = inputSchema.type || "string"; - let normalizedValue = value; - switch (inputType) { - case "string": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string`, - }; - } - normalizedValue = sanitizeContent(value); - break; - case "boolean": - if (typeof value !== "boolean") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a boolean`, - }; - } - break; - case "number": - if (typeof value !== "number") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number`, - }; - } - break; - case "choice": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, - }; - } - if (inputSchema.options && !inputSchema.options.includes(value)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, - }; + core.info(`Raw output content length: ${outputContent.length}`); + let expectedOutputTypes = {}; + if (safeOutputsConfig) { + try { + expectedOutputTypes = JSON.parse(safeOutputsConfig); + core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); } - normalizedValue = sanitizeContent(value); - break; - default: - if (typeof value === "string") { - normalizedValue = sanitizeContent(value); + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); } - break; } - return { - isValid: true, - normalizedValue, - }; - } - function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { + const lines = outputContent.trim().split("\n"); + const parsedItems = []; const errors = []; - const normalizedItem = { ...item }; - if (!jobConfig.inputs) { - return { - isValid: true, - errors: [], - normalizedItem: item, - }; - } - for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { - const fieldValue = item[fieldName]; - const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); - if (!validation.isValid && validation.error) { - errors.push(validation.error); - } else if (validation.normalizedValue !== undefined) { - normalizedItem[fieldName] = validation.normalizedValue; - } - } - return { - isValid: errors.length === 0, - errors, - normalizedItem, - }; - } - function parseJsonWithRepair(jsonStr) { - try { - return JSON.parse(jsonStr); - } catch (originalError) { - try { - const repairedJson = repairJson(jsonStr); - return JSON.parse(repairedJson); - } catch (repairError) { - core.info(`invalid input json: ${jsonStr}`); - const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); - const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); - throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); - } - } - } - const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; - const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; - if (!outputFile) { - core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); - core.setOutput("output", ""); - return; - } - if (!fs.existsSync(outputFile)) { - core.info(`Output file does not exist: ${outputFile}`); - core.setOutput("output", ""); - return; - } - const outputContent = fs.readFileSync(outputFile, "utf8"); - if (outputContent.trim() === "") { - core.info("Output file is empty"); - } - core.info(`Raw output content length: ${outputContent.length}`); - let expectedOutputTypes = {}; - if (safeOutputsConfig) { - try { - expectedOutputTypes = JSON.parse(safeOutputsConfig); - core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); - } - } - const lines = outputContent.trim().split("\n"); - const parsedItems = []; - const errors = []; - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); - if (line === "") continue; - try { - const item = parseJsonWithRepair(line); - if (item === undefined) { - errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); - continue; - } - if (!item.type) { - errors.push(`Line ${i + 1}: Missing required 'type' field`); - continue; - } - const itemType = item.type; - if (!expectedOutputTypes[itemType]) { - errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); - continue; - } - const typeCount = parsedItems.filter(existing => existing.type === itemType).length; - const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); - if (typeCount >= maxAllowed) { - errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); - continue; - } - core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create-issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); - if (!issueNumValidation.isValid) { - if (issueNumValidation.error) errors.push(issueNumValidation.error); - continue; - } - item.body = sanitizeContent(item.body); - break; - case "create-pull-request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - item.branch = sanitizeContent(item.branch); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); - if (!labelsIssueNumValidation.isValid) { - if (labelsIssueNumValidation.error) errors.push(labelsIssueNumValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label)); - break; - case "update-issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "push-to-pull-request-branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch); - item.message = sanitizeContent(item.message); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push-to-pull-request-branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create-pull-request-review-comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line === "") continue; - } - item.body = sanitizeContent(item.body); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create-pull-request-review-comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create-discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category); - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - break; - case "missing-tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool); - item.reason = sanitizeContent(item.reason); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); - continue; + try { + const item = parseJsonWithRepair(line); + if (item === undefined) { + errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); + continue; } - item.alternatives = sanitizeContent(item.alternatives); - } - break; - case "upload-asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "create-code-scanning-alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); + if (!item.type) { + errors.push(`Line ${i + 1}: Missing required 'type' field`); + continue; } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); - continue; + const itemType = item.type; + if (!expectedOutputTypes[itemType]) { + errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); + continue; } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; + const typeCount = parsedItems.filter(existing => existing.type === itemType).length; + const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); + if (typeCount >= maxAllowed) { + errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); + continue; } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file); - item.severity = sanitizeContent(item.severity); - item.message = sanitizeContent(item.message); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); - continue; - } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; + core.info(`Line ${i + 1}: type '${itemType}'`); + switch (itemType) { + case "create-issue": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-comment": + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); + continue; + } + const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); + if (!issueNumValidation.isValid) { + if (issueNumValidation.error) + errors.push(issueNumValidation.error); + continue; + } + item.body = sanitizeContent(item.body); + break; + case "create-pull-request": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); + continue; + } + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + item.branch = sanitizeContent(item.branch); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-labels": + if (!item.labels || !Array.isArray(item.labels)) { + errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); + continue; + } + if (item.labels.some((label) => typeof label !== "string")) { + errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); + continue; + } + const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); + if (!labelsIssueNumValidation.isValid) { + if (labelsIssueNumValidation.error) + errors.push(labelsIssueNumValidation.error); + continue; + } + item.labels = item.labels.map((label) => sanitizeContent(label)); + break; + case "update-issue": + const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; + if (!hasValidField) { + errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); + continue; + } + if (item.status !== undefined) { + if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { + errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); + continue; + } + } + if (item.title !== undefined) { + if (typeof item.title !== "string") { + errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); + continue; + } + item.title = sanitizeContent(item.title); + } + if (item.body !== undefined) { + if (typeof item.body !== "string") { + errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); + continue; + } + item.body = sanitizeContent(item.body); + } + const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); + if (!updateIssueNumValidation.isValid) { + if (updateIssueNumValidation.error) + errors.push(updateIssueNumValidation.error); + continue; + } + break; + case "push-to-pull-request-branch": + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); + continue; + } + item.branch = sanitizeContent(item.branch); + item.message = sanitizeContent(item.message); + const pushPRNumValidation = validateIssueOrPRNumber(item.pull_request_number, "push-to-pull-request-branch 'pull_request_number'", i + 1); + if (!pushPRNumValidation.isValid) { + if (pushPRNumValidation.error) + errors.push(pushPRNumValidation.error); + continue; + } + break; + case "create-pull-request-review-comment": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); + continue; + } + const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); + if (!lineValidation.isValid) { + if (lineValidation.error) + errors.push(lineValidation.error); + continue; + } + const lineNumber = lineValidation.normalizedValue; + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); + continue; + } + item.body = sanitizeContent(item.body); + const startLineValidation = validateOptionalPositiveInteger(item.start_line, "create-pull-request-review-comment 'start_line'", i + 1); + if (!startLineValidation.isValid) { + if (startLineValidation.error) + errors.push(startLineValidation.error); + continue; + } + if (startLineValidation.normalizedValue !== undefined && + lineNumber !== undefined && + startLineValidation.normalizedValue > lineNumber) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); + continue; + } + if (item.side !== undefined) { + if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); + continue; + } + } + break; + case "create-discussion": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); + continue; + } + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); + continue; + } + item.category = sanitizeContent(item.category); + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + break; + case "missing-tool": + if (!item.tool || typeof item.tool !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); + continue; + } + if (!item.reason || typeof item.reason !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); + continue; + } + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); + continue; + } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "upload-asset": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); + continue; + } + break; + case "create-code-scanning-alert": + if (!item.file || typeof item.file !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); + continue; + } + const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); + if (!alertLineValidation.isValid) { + if (alertLineValidation.error) { + errors.push(alertLineValidation.error); + } + continue; + } + if (!item.severity || typeof item.severity !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); + continue; + } + const allowedSeverities = ["error", "warning", "info", "note"]; + if (!allowedSeverities.includes(item.severity.toLowerCase())) { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}`); + continue; + } + const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); + if (!columnValidation.isValid) { + if (columnValidation.error) + errors.push(columnValidation.error); + continue; + } + if (item.ruleIdSuffix !== undefined) { + if (typeof item.ruleIdSuffix !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); + continue; + } + if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores`); + continue; + } + } + item.severity = item.severity.toLowerCase(); + item.file = sanitizeContent(item.file); + item.severity = sanitizeContent(item.severity); + item.message = sanitizeContent(item.message); + if (item.ruleIdSuffix) { + item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); + } + break; + default: + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); + continue; + } + Object.assign(item, validation.normalizedItem); + } + break; } - Object.assign(item, validation.normalizedItem); - } - break; - } - core.info(`Line ${i + 1}: Valid ${itemType} item`); - parsedItems.push(item); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); + core.info(`Line ${i + 1}: Valid ${itemType} item`); + parsedItems.push(item); + } + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); + } } - } - if (errors.length > 0) { - core.warning("Validation errors found:"); - errors.forEach(error => core.warning(` - ${error}`)); - if (parsedItems.length === 0) { - core.setFailed(errors.map(e => ` - ${e}`).join("\n")); - return; + if (errors.length > 0) { + core.warning("Validation errors found:"); + errors.forEach(error => core.warning(` - ${error}`)); + if (parsedItems.length === 0) { + core.setFailed(errors.map(e => ` - ${e}`).join("\n")); + return; + } } - } - for (const itemType of Object.keys(expectedOutputTypes)) { - const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); - if (minRequired > 0) { - const actualCount = parsedItems.filter(item => item.type === itemType).length; - if (actualCount < minRequired) { - errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); - } + for (const itemType of Object.keys(expectedOutputTypes)) { + const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); + if (minRequired > 0) { + const actualCount = parsedItems.filter(item => item.type === itemType).length; + if (actualCount < minRequired) { + errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); + } + } + } + core.info(`Successfully parsed ${parsedItems.length} valid output items`); + const validatedOutput = { + items: parsedItems, + errors: errors, + }; + const agentOutputFile = "/tmp/agent_output.json"; + const validatedOutputJson = JSON.stringify(validatedOutput); + try { + fs.mkdirSync("/tmp", { recursive: true }); + fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); + core.info(`Stored validated output to: ${agentOutputFile}`); + core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); + } + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.error(`Failed to write agent output file: ${errorMsg}`); + } + core.setOutput("output", JSON.stringify(validatedOutput)); + core.setOutput("raw_output", outputContent); + const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); + core.info(`output_types: ${outputTypes.join(", ")}`); + core.setOutput("output_types", outputTypes.join(",")); + try { + await core.summary + .addRaw("## Processed Output\n\n") + .addRaw("```json\n") + .addRaw(JSON.stringify(validatedOutput)) + .addRaw("\n```\n") + .write(); + core.info("Successfully wrote processed output to step summary"); + } + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.warning(`Failed to write to step summary: ${errorMsg}`); } - } - core.info(`Successfully parsed ${parsedItems.length} valid output items`); - const validatedOutput = { - items: parsedItems, - errors: errors, - }; - const agentOutputFile = "/tmp/agent_output.json"; - const validatedOutputJson = JSON.stringify(validatedOutput); - try { - fs.mkdirSync("/tmp", { recursive: true }); - fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); - core.info(`Stored validated output to: ${agentOutputFile}`); - core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.error(`Failed to write agent output file: ${errorMsg}`); - } - core.setOutput("output", JSON.stringify(validatedOutput)); - core.setOutput("raw_output", outputContent); - const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); - core.info(`output_types: ${outputTypes.join(", ")}`); - core.setOutput("output_types", outputTypes.join(",")); - try { - await core.summary - .addRaw("## Processed Output\n\n") - .addRaw("```json\n") - .addRaw(JSON.stringify(validatedOutput)) - .addRaw("\n```\n") - .write(); - core.info("Successfully wrote processed output to step summary"); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.warning(`Failed to write to step summary: ${errorMsg}`); - } } await main(); - name: Upload sanitized agent output @@ -2841,165 +2841,163 @@ jobs: with: script: | function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace( - /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, - (_m, p1, p2) => `${p1}\`@${p2}\`` - ); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); + if (!content || typeof content !== "string") { + return ""; + } + let sanitized = content.trim(); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); + sanitized = sanitized.replace(/[<>&'"]/g, ""); + return sanitized.trim(); } async function main() { - const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; - const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; - if (!outputContent) { - core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); - return; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - return; - } - const createIssueItems = validatedOutput.items.filter(item => item.type === "create-issue"); - if (createIssueItems.length === 0) { - core.info("No create-issue items found in agent output"); - return; - } - core.info(`Found ${createIssueItems.length} create-issue item(s)`); - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Issues Preview\n\n"; - summaryContent += "The following issues would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createIssueItems.length; i++) { - const item = createIssueItems[i]; - summaryContent += `### Issue ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; - } - if (item.labels && item.labels.length > 0) { - summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; - } - summaryContent += "---\n\n"; + const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; + const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; + if (!outputContent) { + core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); + return; } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Issue creation preview written to step summary"); - return; - } - const parentIssueNumber = context.payload?.issue?.number; - const labelsEnv = process.env.GITHUB_AW_ISSUE_LABELS; - let envLabels = labelsEnv - ? labelsEnv - .split(",") - .map(label => label.trim()) - .filter(label => label) - : []; - const createdIssues = []; - for (let i = 0; i < createIssueItems.length; i++) { - const createIssueItem = createIssueItems[i]; - core.info( - `Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}` - ); - let labels = [...envLabels]; - if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { - labels = [...labels, ...createIssueItem.labels]; - } - labels = labels - .filter(label => label != null && label !== false && label !== 0) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - let title = createIssueItem.title ? createIssueItem.title.trim() : ""; - let bodyLines = createIssueItem.body.split("\n"); - if (!title) { - title = createIssueItem.body || "Agent Output"; - } - const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - if (parentIssueNumber) { - core.info("Detected issue context, parent issue #" + parentIssueNumber); - bodyLines.push(`Related to #${parentIssueNumber}`); + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return; } - const workflowName = process.env.GITHUB_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const runUrl = context.payload.repository - ? `${context.payload.repository.html_url}/actions/runs/${runId}` - : `https://github.com/actions/runs/${runId}`; - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating issue with title: ${title}`); - core.info(`Labels: ${labels}`); - core.info(`Body length: ${body.length}`); + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: body, - labels: labels, - }); - core.info("Created issue #" + issue.number + ": " + issue.html_url); - createdIssues.push(issue); - if (parentIssueNumber) { + validatedOutput = JSON.parse(outputContent); + } + catch (error) { + core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); + return; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + return; + } + const createIssueItems = validatedOutput.items.filter(item => item.type === "create-issue"); + if (createIssueItems.length === 0) { + core.info("No create-issue items found in agent output"); + return; + } + core.info(`Found ${createIssueItems.length} create-issue item(s)`); + if (isStaged) { + let summaryContent = "## 🎭 Staged Mode: Create Issues Preview\n\n"; + summaryContent += "The following issues would be created if staged mode was disabled:\n\n"; + for (let i = 0; i < createIssueItems.length; i++) { + const item = createIssueItems[i]; + summaryContent += `### Issue ${i + 1}\n`; + summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; + if (item.body) { + summaryContent += `**Body:**\n${item.body}\n\n`; + } + if (item.labels && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; + } + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Issue creation preview written to step summary"); + return; + } + const parentIssueNumber = context.payload?.issue?.number; + const labelsEnv = process.env.GITHUB_AW_ISSUE_LABELS; + let envLabels = labelsEnv + ? labelsEnv + .split(",") + .map((label) => label.trim()) + .filter((label) => label) + : []; + const createdIssues = []; + for (let i = 0; i < createIssueItems.length; i++) { + const createIssueItem = createIssueItems[i]; + core.info(`Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}`); + let labels = [...envLabels]; + if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { + labels = [...labels, ...createIssueItem.labels]; + } + labels = labels + .filter(label => label != null && label !== false && label !== 0) + .map(label => String(label).trim()) + .filter(label => label) + .map(label => sanitizeLabelContent(label)) + .filter(label => label) + .map(label => (label.length > 64 ? label.substring(0, 64) : label)) + .filter((label, index, arr) => arr.indexOf(label) === index); + let title = createIssueItem.title ? createIssueItem.title.trim() : ""; + let bodyLines = createIssueItem.body.split("\n"); + if (!title) { + title = createIssueItem.body || "Agent Output"; + } + const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; + if (titlePrefix && !title.startsWith(titlePrefix)) { + title = titlePrefix + title; + } + if (parentIssueNumber) { + core.info("Detected issue context, parent issue #" + parentIssueNumber); + bodyLines.push(`Related to #${parentIssueNumber}`); + } + const workflowName = process.env.GITHUB_AW_WORKFLOW_NAME || "Workflow"; + const runId = context.runId; + const runUrl = context.payload.repository + ? `${context.payload.repository.html_url}/actions/runs/${runId}` + : `https://github.com/actions/runs/${runId}`; + bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); + const body = bodyLines.join("\n").trim(); + core.info(`Creating issue with title: ${title}`); + core.info(`Labels: ${labels}`); + core.info(`Body length: ${body.length}`); try { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: parentIssueNumber, - body: `Created related issue: #${issue.number}`, - }); - core.info("Added comment to parent issue #" + parentIssueNumber); - } catch (error) { - core.info(`Warning: Could not add comment to parent issue: ${error instanceof Error ? error.message : String(error)}`); + const { data: issue } = await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: title, + body: body, + labels: labels, + }); + core.info("Created issue #" + issue.number + ": " + issue.html_url); + createdIssues.push(issue); + if (parentIssueNumber) { + try { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: parentIssueNumber, + body: `Created related issue: #${issue.number}`, + }); + core.info("Added comment to parent issue #" + parentIssueNumber); + } + catch (error) { + core.info(`Warning: Could not add comment to parent issue: ${error instanceof Error ? error.message : String(error)}`); + } + } + if (i === createIssueItems.length - 1) { + core.setOutput("issue_number", issue.number); + core.setOutput("issue_url", issue.html_url); + } + } + catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage.includes("Issues has been disabled in this repository")) { + core.info(`⚠ Cannot create issue "${title}": Issues are disabled for this repository`); + core.info("Consider enabling issues in repository settings if you want to create issues automatically"); + continue; + } + core.error(`✗ Failed to create issue "${title}": ${errorMessage}`); + throw error; } - } - if (i === createIssueItems.length - 1) { - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Issues has been disabled in this repository")) { - core.info(`⚠ Cannot create issue "${title}": Issues are disabled for this repository`); - core.info("Consider enabling issues in repository settings if you want to create issues automatically"); - continue; - } - core.error(`✗ Failed to create issue "${title}": ${errorMessage}`); - throw error; } - } - if (createdIssues.length > 0) { - let summaryContent = "\n\n## GitHub Issues\n"; - for (const issue of createdIssues) { - summaryContent += `- Issue #${issue.number}: [${issue.title}](${issue.html_url})\n`; + if (createdIssues.length > 0) { + let summaryContent = "\n\n## GitHub Issues\n"; + for (const issue of createdIssues) { + summaryContent += `- Issue #${issue.number}: [${issue.title}](${issue.html_url})\n`; + } + await core.summary.addRaw(summaryContent).write(); } - await core.summary.addRaw(summaryContent).write(); - } - core.info(`Successfully created ${createdIssues.length} issue(s)`); + core.info(`Successfully created ${createdIssues.length} issue(s)`); } (async () => { - await main(); + await main(); })(); add_comment: diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml index ddaf8e0bd50..60cc08ba9ba 100644 --- a/.github/workflows/dev.lock.yml +++ b/.github/workflows/dev.lock.yml @@ -1226,742 +1226,742 @@ jobs: with: script: | async function main() { - const fs = require("fs"); - function sanitizeContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; - const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; - const allowedDomains = allowedDomainsEnv - ? allowedDomainsEnv - .split(",") - .map(d => d.trim()) - .filter(d => d) - : defaultAllowedDomains; - let sanitized = content; - sanitized = neutralizeMentions(sanitized); - sanitized = removeXmlComments(sanitized); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitizeUrlProtocols(sanitized); - sanitized = sanitizeUrlDomains(sanitized); - const maxLength = 524288; - if (sanitized.length > maxLength) { - sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; - } - const lines = sanitized.split("\n"); - const maxLines = 65000; - if (lines.length > maxLines) { - sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; - } - sanitized = neutralizeBotTriggers(sanitized); - return sanitized.trim(); - function sanitizeUrlDomains(s) { - return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { - const urlAfterProtocol = match.slice(8); - const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); - const isAllowed = allowedDomains.some(allowedDomain => { - const normalizedAllowed = allowedDomain.toLowerCase(); - return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); - }); - return isAllowed ? match : "(redacted)"; - }); - } - function sanitizeUrlProtocols(s) { - return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { - return protocol.toLowerCase() === "https" ? match : "(redacted)"; - }); - } - function neutralizeMentions(s) { - return s.replace( - /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, - (_m, p1, p2) => `${p1}\`@${p2}\`` - ); - } - function removeXmlComments(s) { - return s.replace(//g, "").replace(//g, ""); - } - function neutralizeBotTriggers(s) { - return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); - } - } - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create-issue": - return 1; - case "add-comment": - return 1; - case "create-pull-request": - return 1; - case "create-pull-request-review-comment": - return 1; - case "add-labels": - return 5; - case "update-issue": - return 1; - case "push-to-pull-request-branch": - return 1; - case "create-discussion": - return 1; - case "missing-tool": - return 1000; - case "create-code-scanning-alert": - return 1000; - case "upload-asset": - return 10; - default: - return 1; - } - } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; - } - return 0; - } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; - } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; + const fs = require("fs"); + function sanitizeContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; + const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; + const allowedDomains = allowedDomainsEnv + ? allowedDomainsEnv + .split(",") + .map(d => d.trim()) + .filter(d => d) + : defaultAllowedDomains; + let sanitized = content; + sanitized = neutralizeMentions(sanitized); + sanitized = removeXmlComments(sanitized); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitizeUrlProtocols(sanitized); + sanitized = sanitizeUrlDomains(sanitized); + const maxLength = 524288; + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; + } + const lines = sanitized.split("\n"); + const maxLines = 65000; + if (lines.length > maxLines) { + sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; + } + sanitized = neutralizeBotTriggers(sanitized); + return sanitized.trim(); + function sanitizeUrlDomains(s) { + return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { + const urlAfterProtocol = match.slice(8); + const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); + const isAllowed = allowedDomains.some(allowedDomain => { + const normalizedAllowed = allowedDomain.toLowerCase(); + return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); + }); + return isAllowed ? match : "(redacted)"; + }); + } + function sanitizeUrlProtocols(s) { + return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { + return protocol.toLowerCase() === "https" ? match : "(redacted)"; + }); + } + function neutralizeMentions(s) { + return s.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); + } + function removeXmlComments(s) { + return s.replace(//g, "").replace(//g, ""); + } + function neutralizeBotTriggers(s) { + return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); + } } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + switch (itemType) { + case "create-issue": + return 1; + case "add-comment": + return 1; + case "create-pull-request": + return 1; + case "create-pull-request-review-comment": + return 1; + case "add-labels": + return 5; + case "update-issue": + return 1; + case "push-to-pull-request-branch": + return 1; + case "create-discussion": + return 1; + case "missing-tool": + return 1000; + case "create-code-scanning-alert": + return 1000; + case "upload-asset": + return 10; + default: + return 1; + } } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; + } + return 0; + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } + else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } + else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; } - return { isValid: true, normalizedValue: parsed }; - } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { + if (inputSchema.required && (value === undefined || value === null)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (value === undefined || value === null) { + return { + isValid: true, + normalizedValue: inputSchema.default || undefined, + }; + } + const inputType = inputSchema.type || "string"; + let normalizedValue = value; + switch (inputType) { + case "string": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string`, + }; + } + normalizedValue = sanitizeContent(value); + break; + case "boolean": + if (typeof value !== "boolean") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a boolean`, + }; + } + break; + case "number": + if (typeof value !== "number") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number`, + }; + } + break; + case "choice": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, + }; + } + if (inputSchema.options && !inputSchema.options.includes(value)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, + }; + } + normalizedValue = sanitizeContent(value); + break; + default: + if (typeof value === "string") { + normalizedValue = sanitizeContent(value); + } + break; + } return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, + isValid: true, + normalizedValue, }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { + function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { + const errors = []; + const normalizedItem = { ...item }; + if (!jobConfig.inputs) { + return { + isValid: true, + errors: [], + normalizedItem: item, + }; + } + for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { + const fieldValue = item[fieldName]; + const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); + if (!validation.isValid && validation.error) { + errors.push(validation.error); + } + else if (validation.normalizedValue !== undefined) { + normalizedItem[fieldName] = validation.normalizedValue; + } + } return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, + isValid: errors.length === 0, + errors, + normalizedItem, }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; - } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + function parseJsonWithRepair(jsonStr) { + try { + return JSON.parse(jsonStr); + } + catch (originalError) { + try { + const repairedJson = repairJson(jsonStr); + return JSON.parse(repairedJson); + } + catch (repairError) { + core.info(`invalid input json: ${jsonStr}`); + const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); + const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); + throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); + } + } } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; + const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; + if (!outputFile) { + core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); + core.setOutput("output", ""); + return; } - return { isValid: true }; - } - function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { - if (inputSchema.required && (value === undefined || value === null)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; + if (!fs.existsSync(outputFile)) { + core.info(`Output file does not exist: ${outputFile}`); + core.setOutput("output", ""); + return; } - if (value === undefined || value === null) { - return { - isValid: true, - normalizedValue: inputSchema.default || undefined, - }; + const outputContent = fs.readFileSync(outputFile, "utf8"); + if (outputContent.trim() === "") { + core.info("Output file is empty"); } - const inputType = inputSchema.type || "string"; - let normalizedValue = value; - switch (inputType) { - case "string": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string`, - }; - } - normalizedValue = sanitizeContent(value); - break; - case "boolean": - if (typeof value !== "boolean") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a boolean`, - }; - } - break; - case "number": - if (typeof value !== "number") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number`, - }; - } - break; - case "choice": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, - }; - } - if (inputSchema.options && !inputSchema.options.includes(value)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, - }; + core.info(`Raw output content length: ${outputContent.length}`); + let expectedOutputTypes = {}; + if (safeOutputsConfig) { + try { + expectedOutputTypes = JSON.parse(safeOutputsConfig); + core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); } - normalizedValue = sanitizeContent(value); - break; - default: - if (typeof value === "string") { - normalizedValue = sanitizeContent(value); + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); } - break; } - return { - isValid: true, - normalizedValue, - }; - } - function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { + const lines = outputContent.trim().split("\n"); + const parsedItems = []; const errors = []; - const normalizedItem = { ...item }; - if (!jobConfig.inputs) { - return { - isValid: true, - errors: [], - normalizedItem: item, - }; - } - for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { - const fieldValue = item[fieldName]; - const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); - if (!validation.isValid && validation.error) { - errors.push(validation.error); - } else if (validation.normalizedValue !== undefined) { - normalizedItem[fieldName] = validation.normalizedValue; - } - } - return { - isValid: errors.length === 0, - errors, - normalizedItem, - }; - } - function parseJsonWithRepair(jsonStr) { - try { - return JSON.parse(jsonStr); - } catch (originalError) { - try { - const repairedJson = repairJson(jsonStr); - return JSON.parse(repairedJson); - } catch (repairError) { - core.info(`invalid input json: ${jsonStr}`); - const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); - const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); - throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); - } - } - } - const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; - const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; - if (!outputFile) { - core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); - core.setOutput("output", ""); - return; - } - if (!fs.existsSync(outputFile)) { - core.info(`Output file does not exist: ${outputFile}`); - core.setOutput("output", ""); - return; - } - const outputContent = fs.readFileSync(outputFile, "utf8"); - if (outputContent.trim() === "") { - core.info("Output file is empty"); - } - core.info(`Raw output content length: ${outputContent.length}`); - let expectedOutputTypes = {}; - if (safeOutputsConfig) { - try { - expectedOutputTypes = JSON.parse(safeOutputsConfig); - core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); - } - } - const lines = outputContent.trim().split("\n"); - const parsedItems = []; - const errors = []; - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); - if (line === "") continue; - try { - const item = parseJsonWithRepair(line); - if (item === undefined) { - errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); - continue; - } - if (!item.type) { - errors.push(`Line ${i + 1}: Missing required 'type' field`); - continue; - } - const itemType = item.type; - if (!expectedOutputTypes[itemType]) { - errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); - continue; - } - const typeCount = parsedItems.filter(existing => existing.type === itemType).length; - const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); - if (typeCount >= maxAllowed) { - errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); - continue; - } - core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create-issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); - if (!issueNumValidation.isValid) { - if (issueNumValidation.error) errors.push(issueNumValidation.error); + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line === "") continue; - } - item.body = sanitizeContent(item.body); - break; - case "create-pull-request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - item.branch = sanitizeContent(item.branch); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); - if (!labelsIssueNumValidation.isValid) { - if (labelsIssueNumValidation.error) errors.push(labelsIssueNumValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label)); - break; - case "update-issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "push-to-pull-request-branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch); - item.message = sanitizeContent(item.message); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push-to-pull-request-branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create-pull-request-review-comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create-pull-request-review-comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; + try { + const item = parseJsonWithRepair(line); + if (item === undefined) { + errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); + continue; } - } - break; - case "create-discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; + if (!item.type) { + errors.push(`Line ${i + 1}: Missing required 'type' field`); + continue; } - item.category = sanitizeContent(item.category); - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - break; - case "missing-tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool); - item.reason = sanitizeContent(item.reason); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); - continue; + const itemType = item.type; + if (!expectedOutputTypes[itemType]) { + errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); + continue; } - item.alternatives = sanitizeContent(item.alternatives); - } - break; - case "upload-asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "create-code-scanning-alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); + const typeCount = parsedItems.filter(existing => existing.type === itemType).length; + const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); + if (typeCount >= maxAllowed) { + errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); + continue; } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); - continue; + core.info(`Line ${i + 1}: type '${itemType}'`); + switch (itemType) { + case "create-issue": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-comment": + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); + continue; + } + const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); + if (!issueNumValidation.isValid) { + if (issueNumValidation.error) + errors.push(issueNumValidation.error); + continue; + } + item.body = sanitizeContent(item.body); + break; + case "create-pull-request": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); + continue; + } + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + item.branch = sanitizeContent(item.branch); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-labels": + if (!item.labels || !Array.isArray(item.labels)) { + errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); + continue; + } + if (item.labels.some((label) => typeof label !== "string")) { + errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); + continue; + } + const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); + if (!labelsIssueNumValidation.isValid) { + if (labelsIssueNumValidation.error) + errors.push(labelsIssueNumValidation.error); + continue; + } + item.labels = item.labels.map((label) => sanitizeContent(label)); + break; + case "update-issue": + const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; + if (!hasValidField) { + errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); + continue; + } + if (item.status !== undefined) { + if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { + errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); + continue; + } + } + if (item.title !== undefined) { + if (typeof item.title !== "string") { + errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); + continue; + } + item.title = sanitizeContent(item.title); + } + if (item.body !== undefined) { + if (typeof item.body !== "string") { + errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); + continue; + } + item.body = sanitizeContent(item.body); + } + const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); + if (!updateIssueNumValidation.isValid) { + if (updateIssueNumValidation.error) + errors.push(updateIssueNumValidation.error); + continue; + } + break; + case "push-to-pull-request-branch": + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); + continue; + } + item.branch = sanitizeContent(item.branch); + item.message = sanitizeContent(item.message); + const pushPRNumValidation = validateIssueOrPRNumber(item.pull_request_number, "push-to-pull-request-branch 'pull_request_number'", i + 1); + if (!pushPRNumValidation.isValid) { + if (pushPRNumValidation.error) + errors.push(pushPRNumValidation.error); + continue; + } + break; + case "create-pull-request-review-comment": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); + continue; + } + const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); + if (!lineValidation.isValid) { + if (lineValidation.error) + errors.push(lineValidation.error); + continue; + } + const lineNumber = lineValidation.normalizedValue; + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); + continue; + } + item.body = sanitizeContent(item.body); + const startLineValidation = validateOptionalPositiveInteger(item.start_line, "create-pull-request-review-comment 'start_line'", i + 1); + if (!startLineValidation.isValid) { + if (startLineValidation.error) + errors.push(startLineValidation.error); + continue; + } + if (startLineValidation.normalizedValue !== undefined && + lineNumber !== undefined && + startLineValidation.normalizedValue > lineNumber) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); + continue; + } + if (item.side !== undefined) { + if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); + continue; + } + } + break; + case "create-discussion": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); + continue; + } + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); + continue; + } + item.category = sanitizeContent(item.category); + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + break; + case "missing-tool": + if (!item.tool || typeof item.tool !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); + continue; + } + if (!item.reason || typeof item.reason !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); + continue; + } + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); + continue; + } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "upload-asset": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); + continue; + } + break; + case "create-code-scanning-alert": + if (!item.file || typeof item.file !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); + continue; + } + const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); + if (!alertLineValidation.isValid) { + if (alertLineValidation.error) { + errors.push(alertLineValidation.error); + } + continue; + } + if (!item.severity || typeof item.severity !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); + continue; + } + const allowedSeverities = ["error", "warning", "info", "note"]; + if (!allowedSeverities.includes(item.severity.toLowerCase())) { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}`); + continue; + } + const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); + if (!columnValidation.isValid) { + if (columnValidation.error) + errors.push(columnValidation.error); + continue; + } + if (item.ruleIdSuffix !== undefined) { + if (typeof item.ruleIdSuffix !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); + continue; + } + if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores`); + continue; + } + } + item.severity = item.severity.toLowerCase(); + item.file = sanitizeContent(item.file); + item.severity = sanitizeContent(item.severity); + item.message = sanitizeContent(item.message); + if (item.ruleIdSuffix) { + item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); + } + break; + default: + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); + continue; + } + Object.assign(item, validation.normalizedItem); + } + break; } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file); - item.severity = sanitizeContent(item.severity); - item.message = sanitizeContent(item.message); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); - continue; - } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; - } - core.info(`Line ${i + 1}: Valid ${itemType} item`); - parsedItems.push(item); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); + core.info(`Line ${i + 1}: Valid ${itemType} item`); + parsedItems.push(item); + } + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); + } } - } - if (errors.length > 0) { - core.warning("Validation errors found:"); - errors.forEach(error => core.warning(` - ${error}`)); - if (parsedItems.length === 0) { - core.setFailed(errors.map(e => ` - ${e}`).join("\n")); - return; + if (errors.length > 0) { + core.warning("Validation errors found:"); + errors.forEach(error => core.warning(` - ${error}`)); + if (parsedItems.length === 0) { + core.setFailed(errors.map(e => ` - ${e}`).join("\n")); + return; + } } - } - for (const itemType of Object.keys(expectedOutputTypes)) { - const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); - if (minRequired > 0) { - const actualCount = parsedItems.filter(item => item.type === itemType).length; - if (actualCount < minRequired) { - errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); - } + for (const itemType of Object.keys(expectedOutputTypes)) { + const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); + if (minRequired > 0) { + const actualCount = parsedItems.filter(item => item.type === itemType).length; + if (actualCount < minRequired) { + errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); + } + } + } + core.info(`Successfully parsed ${parsedItems.length} valid output items`); + const validatedOutput = { + items: parsedItems, + errors: errors, + }; + const agentOutputFile = "/tmp/agent_output.json"; + const validatedOutputJson = JSON.stringify(validatedOutput); + try { + fs.mkdirSync("/tmp", { recursive: true }); + fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); + core.info(`Stored validated output to: ${agentOutputFile}`); + core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); + } + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.error(`Failed to write agent output file: ${errorMsg}`); + } + core.setOutput("output", JSON.stringify(validatedOutput)); + core.setOutput("raw_output", outputContent); + const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); + core.info(`output_types: ${outputTypes.join(", ")}`); + core.setOutput("output_types", outputTypes.join(",")); + try { + await core.summary + .addRaw("## Processed Output\n\n") + .addRaw("```json\n") + .addRaw(JSON.stringify(validatedOutput)) + .addRaw("\n```\n") + .write(); + core.info("Successfully wrote processed output to step summary"); + } + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.warning(`Failed to write to step summary: ${errorMsg}`); } - } - core.info(`Successfully parsed ${parsedItems.length} valid output items`); - const validatedOutput = { - items: parsedItems, - errors: errors, - }; - const agentOutputFile = "/tmp/agent_output.json"; - const validatedOutputJson = JSON.stringify(validatedOutput); - try { - fs.mkdirSync("/tmp", { recursive: true }); - fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); - core.info(`Stored validated output to: ${agentOutputFile}`); - core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.error(`Failed to write agent output file: ${errorMsg}`); - } - core.setOutput("output", JSON.stringify(validatedOutput)); - core.setOutput("raw_output", outputContent); - const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); - core.info(`output_types: ${outputTypes.join(", ")}`); - core.setOutput("output_types", outputTypes.join(",")); - try { - await core.summary - .addRaw("## Processed Output\n\n") - .addRaw("```json\n") - .addRaw(JSON.stringify(validatedOutput)) - .addRaw("\n```\n") - .write(); - core.info("Successfully wrote processed output to step summary"); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.warning(`Failed to write to step summary: ${errorMsg}`); - } } await main(); - name: Upload sanitized agent output @@ -2696,165 +2696,163 @@ jobs: with: script: | function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace( - /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, - (_m, p1, p2) => `${p1}\`@${p2}\`` - ); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); + if (!content || typeof content !== "string") { + return ""; + } + let sanitized = content.trim(); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); + sanitized = sanitized.replace(/[<>&'"]/g, ""); + return sanitized.trim(); } async function main() { - const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; - const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; - if (!outputContent) { - core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); - return; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - return; - } - const createIssueItems = validatedOutput.items.filter(item => item.type === "create-issue"); - if (createIssueItems.length === 0) { - core.info("No create-issue items found in agent output"); - return; - } - core.info(`Found ${createIssueItems.length} create-issue item(s)`); - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Issues Preview\n\n"; - summaryContent += "The following issues would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createIssueItems.length; i++) { - const item = createIssueItems[i]; - summaryContent += `### Issue ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; - } - if (item.labels && item.labels.length > 0) { - summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; - } - summaryContent += "---\n\n"; + const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; + const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; + if (!outputContent) { + core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); + return; } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Issue creation preview written to step summary"); - return; - } - const parentIssueNumber = context.payload?.issue?.number; - const labelsEnv = process.env.GITHUB_AW_ISSUE_LABELS; - let envLabels = labelsEnv - ? labelsEnv - .split(",") - .map(label => label.trim()) - .filter(label => label) - : []; - const createdIssues = []; - for (let i = 0; i < createIssueItems.length; i++) { - const createIssueItem = createIssueItems[i]; - core.info( - `Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}` - ); - let labels = [...envLabels]; - if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { - labels = [...labels, ...createIssueItem.labels]; - } - labels = labels - .filter(label => label != null && label !== false && label !== 0) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - let title = createIssueItem.title ? createIssueItem.title.trim() : ""; - let bodyLines = createIssueItem.body.split("\n"); - if (!title) { - title = createIssueItem.body || "Agent Output"; - } - const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - if (parentIssueNumber) { - core.info("Detected issue context, parent issue #" + parentIssueNumber); - bodyLines.push(`Related to #${parentIssueNumber}`); - } - const workflowName = process.env.GITHUB_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const runUrl = context.payload.repository - ? `${context.payload.repository.html_url}/actions/runs/${runId}` - : `https://github.com/actions/runs/${runId}`; - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating issue with title: ${title}`); - core.info(`Labels: ${labels}`); - core.info(`Body length: ${body.length}`); + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: body, - labels: labels, - }); - core.info("Created issue #" + issue.number + ": " + issue.html_url); - createdIssues.push(issue); - if (parentIssueNumber) { + validatedOutput = JSON.parse(outputContent); + } + catch (error) { + core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); + return; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + return; + } + const createIssueItems = validatedOutput.items.filter(item => item.type === "create-issue"); + if (createIssueItems.length === 0) { + core.info("No create-issue items found in agent output"); + return; + } + core.info(`Found ${createIssueItems.length} create-issue item(s)`); + if (isStaged) { + let summaryContent = "## 🎭 Staged Mode: Create Issues Preview\n\n"; + summaryContent += "The following issues would be created if staged mode was disabled:\n\n"; + for (let i = 0; i < createIssueItems.length; i++) { + const item = createIssueItems[i]; + summaryContent += `### Issue ${i + 1}\n`; + summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; + if (item.body) { + summaryContent += `**Body:**\n${item.body}\n\n`; + } + if (item.labels && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; + } + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Issue creation preview written to step summary"); + return; + } + const parentIssueNumber = context.payload?.issue?.number; + const labelsEnv = process.env.GITHUB_AW_ISSUE_LABELS; + let envLabels = labelsEnv + ? labelsEnv + .split(",") + .map((label) => label.trim()) + .filter((label) => label) + : []; + const createdIssues = []; + for (let i = 0; i < createIssueItems.length; i++) { + const createIssueItem = createIssueItems[i]; + core.info(`Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}`); + let labels = [...envLabels]; + if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { + labels = [...labels, ...createIssueItem.labels]; + } + labels = labels + .filter(label => label != null && label !== false && label !== 0) + .map(label => String(label).trim()) + .filter(label => label) + .map(label => sanitizeLabelContent(label)) + .filter(label => label) + .map(label => (label.length > 64 ? label.substring(0, 64) : label)) + .filter((label, index, arr) => arr.indexOf(label) === index); + let title = createIssueItem.title ? createIssueItem.title.trim() : ""; + let bodyLines = createIssueItem.body.split("\n"); + if (!title) { + title = createIssueItem.body || "Agent Output"; + } + const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; + if (titlePrefix && !title.startsWith(titlePrefix)) { + title = titlePrefix + title; + } + if (parentIssueNumber) { + core.info("Detected issue context, parent issue #" + parentIssueNumber); + bodyLines.push(`Related to #${parentIssueNumber}`); + } + const workflowName = process.env.GITHUB_AW_WORKFLOW_NAME || "Workflow"; + const runId = context.runId; + const runUrl = context.payload.repository + ? `${context.payload.repository.html_url}/actions/runs/${runId}` + : `https://github.com/actions/runs/${runId}`; + bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); + const body = bodyLines.join("\n").trim(); + core.info(`Creating issue with title: ${title}`); + core.info(`Labels: ${labels}`); + core.info(`Body length: ${body.length}`); try { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: parentIssueNumber, - body: `Created related issue: #${issue.number}`, - }); - core.info("Added comment to parent issue #" + parentIssueNumber); - } catch (error) { - core.info(`Warning: Could not add comment to parent issue: ${error instanceof Error ? error.message : String(error)}`); + const { data: issue } = await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: title, + body: body, + labels: labels, + }); + core.info("Created issue #" + issue.number + ": " + issue.html_url); + createdIssues.push(issue); + if (parentIssueNumber) { + try { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: parentIssueNumber, + body: `Created related issue: #${issue.number}`, + }); + core.info("Added comment to parent issue #" + parentIssueNumber); + } + catch (error) { + core.info(`Warning: Could not add comment to parent issue: ${error instanceof Error ? error.message : String(error)}`); + } + } + if (i === createIssueItems.length - 1) { + core.setOutput("issue_number", issue.number); + core.setOutput("issue_url", issue.html_url); + } + } + catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage.includes("Issues has been disabled in this repository")) { + core.info(`⚠ Cannot create issue "${title}": Issues are disabled for this repository`); + core.info("Consider enabling issues in repository settings if you want to create issues automatically"); + continue; + } + core.error(`✗ Failed to create issue "${title}": ${errorMessage}`); + throw error; } - } - if (i === createIssueItems.length - 1) { - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Issues has been disabled in this repository")) { - core.info(`⚠ Cannot create issue "${title}": Issues are disabled for this repository`); - core.info("Consider enabling issues in repository settings if you want to create issues automatically"); - continue; - } - core.error(`✗ Failed to create issue "${title}": ${errorMessage}`); - throw error; } - } - if (createdIssues.length > 0) { - let summaryContent = "\n\n## GitHub Issues\n"; - for (const issue of createdIssues) { - summaryContent += `- Issue #${issue.number}: [${issue.title}](${issue.html_url})\n`; + if (createdIssues.length > 0) { + let summaryContent = "\n\n## GitHub Issues\n"; + for (const issue of createdIssues) { + summaryContent += `- Issue #${issue.number}: [${issue.title}](${issue.html_url})\n`; + } + await core.summary.addRaw(summaryContent).write(); } - await core.summary.addRaw(summaryContent).write(); - } - core.info(`Successfully created ${createdIssues.length} issue(s)`); + core.info(`Successfully created ${createdIssues.length} issue(s)`); } (async () => { - await main(); + await main(); })(); missing_tool: diff --git a/.github/workflows/duplicate-code-detector.lock.yml b/.github/workflows/duplicate-code-detector.lock.yml index f30119b4f14..3b7a28b2daa 100644 --- a/.github/workflows/duplicate-code-detector.lock.yml +++ b/.github/workflows/duplicate-code-detector.lock.yml @@ -1293,742 +1293,742 @@ jobs: with: script: | async function main() { - const fs = require("fs"); - function sanitizeContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; - const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; - const allowedDomains = allowedDomainsEnv - ? allowedDomainsEnv - .split(",") - .map(d => d.trim()) - .filter(d => d) - : defaultAllowedDomains; - let sanitized = content; - sanitized = neutralizeMentions(sanitized); - sanitized = removeXmlComments(sanitized); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitizeUrlProtocols(sanitized); - sanitized = sanitizeUrlDomains(sanitized); - const maxLength = 524288; - if (sanitized.length > maxLength) { - sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; - } - const lines = sanitized.split("\n"); - const maxLines = 65000; - if (lines.length > maxLines) { - sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; - } - sanitized = neutralizeBotTriggers(sanitized); - return sanitized.trim(); - function sanitizeUrlDomains(s) { - return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { - const urlAfterProtocol = match.slice(8); - const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); - const isAllowed = allowedDomains.some(allowedDomain => { - const normalizedAllowed = allowedDomain.toLowerCase(); - return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); - }); - return isAllowed ? match : "(redacted)"; - }); - } - function sanitizeUrlProtocols(s) { - return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { - return protocol.toLowerCase() === "https" ? match : "(redacted)"; - }); - } - function neutralizeMentions(s) { - return s.replace( - /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, - (_m, p1, p2) => `${p1}\`@${p2}\`` - ); - } - function removeXmlComments(s) { - return s.replace(//g, "").replace(//g, ""); - } - function neutralizeBotTriggers(s) { - return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); - } - } - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create-issue": - return 1; - case "add-comment": - return 1; - case "create-pull-request": - return 1; - case "create-pull-request-review-comment": - return 1; - case "add-labels": - return 5; - case "update-issue": - return 1; - case "push-to-pull-request-branch": - return 1; - case "create-discussion": - return 1; - case "missing-tool": - return 1000; - case "create-code-scanning-alert": - return 1000; - case "upload-asset": - return 10; - default: - return 1; + const fs = require("fs"); + function sanitizeContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; + const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; + const allowedDomains = allowedDomainsEnv + ? allowedDomainsEnv + .split(",") + .map(d => d.trim()) + .filter(d => d) + : defaultAllowedDomains; + let sanitized = content; + sanitized = neutralizeMentions(sanitized); + sanitized = removeXmlComments(sanitized); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitizeUrlProtocols(sanitized); + sanitized = sanitizeUrlDomains(sanitized); + const maxLength = 524288; + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; + } + const lines = sanitized.split("\n"); + const maxLines = 65000; + if (lines.length > maxLines) { + sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; + } + sanitized = neutralizeBotTriggers(sanitized); + return sanitized.trim(); + function sanitizeUrlDomains(s) { + return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { + const urlAfterProtocol = match.slice(8); + const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); + const isAllowed = allowedDomains.some(allowedDomain => { + const normalizedAllowed = allowedDomain.toLowerCase(); + return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); + }); + return isAllowed ? match : "(redacted)"; + }); + } + function sanitizeUrlProtocols(s) { + return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { + return protocol.toLowerCase() === "https" ? match : "(redacted)"; + }); + } + function neutralizeMentions(s) { + return s.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); + } + function removeXmlComments(s) { + return s.replace(//g, "").replace(//g, ""); + } + function neutralizeBotTriggers(s) { + return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); + } } - } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + switch (itemType) { + case "create-issue": + return 1; + case "add-comment": + return 1; + case "create-pull-request": + return 1; + case "create-pull-request-review-comment": + return 1; + case "add-labels": + return 5; + case "update-issue": + return 1; + case "push-to-pull-request-branch": + return 1; + case "create-discussion": + return 1; + case "missing-tool": + return 1000; + case "create-code-scanning-alert": + return 1000; + case "upload-asset": + return 10; + default: + return 1; + } } - return 0; - } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; - } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; + } + return 0; + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } + else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } + else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { + if (inputSchema.required && (value === undefined || value === null)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (value === undefined || value === null) { + return { + isValid: true, + normalizedValue: inputSchema.default || undefined, + }; + } + const inputType = inputSchema.type || "string"; + let normalizedValue = value; + switch (inputType) { + case "string": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string`, + }; + } + normalizedValue = sanitizeContent(value); + break; + case "boolean": + if (typeof value !== "boolean") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a boolean`, + }; + } + break; + case "number": + if (typeof value !== "number") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number`, + }; + } + break; + case "choice": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, + }; + } + if (inputSchema.options && !inputSchema.options.includes(value)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, + }; + } + normalizedValue = sanitizeContent(value); + break; + default: + if (typeof value === "string") { + normalizedValue = sanitizeContent(value); + } + break; + } return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, + isValid: true, + normalizedValue, }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; - } - return { isValid: true, normalizedValue: parsed }; - } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { + function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { + const errors = []; + const normalizedItem = { ...item }; + if (!jobConfig.inputs) { + return { + isValid: true, + errors: [], + normalizedItem: item, + }; + } + for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { + const fieldValue = item[fieldName]; + const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); + if (!validation.isValid && validation.error) { + errors.push(validation.error); + } + else if (validation.normalizedValue !== undefined) { + normalizedItem[fieldName] = validation.normalizedValue; + } + } return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, + isValid: errors.length === 0, + errors, + normalizedItem, }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; + function parseJsonWithRepair(jsonStr) { + try { + return JSON.parse(jsonStr); + } + catch (originalError) { + try { + const repairedJson = repairJson(jsonStr); + return JSON.parse(repairedJson); + } + catch (repairError) { + core.info(`invalid input json: ${jsonStr}`); + const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); + const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); + throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); + } + } } - return { isValid: true, normalizedValue: parsed }; - } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; + const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; + if (!outputFile) { + core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); + core.setOutput("output", ""); + return; } - return { isValid: true }; - } - function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { - if (inputSchema.required && (value === undefined || value === null)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; + if (!fs.existsSync(outputFile)) { + core.info(`Output file does not exist: ${outputFile}`); + core.setOutput("output", ""); + return; } - if (value === undefined || value === null) { - return { - isValid: true, - normalizedValue: inputSchema.default || undefined, - }; + const outputContent = fs.readFileSync(outputFile, "utf8"); + if (outputContent.trim() === "") { + core.info("Output file is empty"); } - const inputType = inputSchema.type || "string"; - let normalizedValue = value; - switch (inputType) { - case "string": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string`, - }; - } - normalizedValue = sanitizeContent(value); - break; - case "boolean": - if (typeof value !== "boolean") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a boolean`, - }; - } - break; - case "number": - if (typeof value !== "number") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number`, - }; - } - break; - case "choice": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, - }; - } - if (inputSchema.options && !inputSchema.options.includes(value)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, - }; + core.info(`Raw output content length: ${outputContent.length}`); + let expectedOutputTypes = {}; + if (safeOutputsConfig) { + try { + expectedOutputTypes = JSON.parse(safeOutputsConfig); + core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); } - normalizedValue = sanitizeContent(value); - break; - default: - if (typeof value === "string") { - normalizedValue = sanitizeContent(value); + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); } - break; } - return { - isValid: true, - normalizedValue, - }; - } - function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { + const lines = outputContent.trim().split("\n"); + const parsedItems = []; const errors = []; - const normalizedItem = { ...item }; - if (!jobConfig.inputs) { - return { - isValid: true, - errors: [], - normalizedItem: item, - }; - } - for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { - const fieldValue = item[fieldName]; - const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); - if (!validation.isValid && validation.error) { - errors.push(validation.error); - } else if (validation.normalizedValue !== undefined) { - normalizedItem[fieldName] = validation.normalizedValue; - } - } - return { - isValid: errors.length === 0, - errors, - normalizedItem, - }; - } - function parseJsonWithRepair(jsonStr) { - try { - return JSON.parse(jsonStr); - } catch (originalError) { - try { - const repairedJson = repairJson(jsonStr); - return JSON.parse(repairedJson); - } catch (repairError) { - core.info(`invalid input json: ${jsonStr}`); - const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); - const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); - throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); - } - } - } - const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; - const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; - if (!outputFile) { - core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); - core.setOutput("output", ""); - return; - } - if (!fs.existsSync(outputFile)) { - core.info(`Output file does not exist: ${outputFile}`); - core.setOutput("output", ""); - return; - } - const outputContent = fs.readFileSync(outputFile, "utf8"); - if (outputContent.trim() === "") { - core.info("Output file is empty"); - } - core.info(`Raw output content length: ${outputContent.length}`); - let expectedOutputTypes = {}; - if (safeOutputsConfig) { - try { - expectedOutputTypes = JSON.parse(safeOutputsConfig); - core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); - } - } - const lines = outputContent.trim().split("\n"); - const parsedItems = []; - const errors = []; - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); - if (line === "") continue; - try { - const item = parseJsonWithRepair(line); - if (item === undefined) { - errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); - continue; - } - if (!item.type) { - errors.push(`Line ${i + 1}: Missing required 'type' field`); - continue; - } - const itemType = item.type; - if (!expectedOutputTypes[itemType]) { - errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); - continue; - } - const typeCount = parsedItems.filter(existing => existing.type === itemType).length; - const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); - if (typeCount >= maxAllowed) { - errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); - continue; - } - core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create-issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); - if (!issueNumValidation.isValid) { - if (issueNumValidation.error) errors.push(issueNumValidation.error); - continue; - } - item.body = sanitizeContent(item.body); - break; - case "create-pull-request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - item.branch = sanitizeContent(item.branch); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); - if (!labelsIssueNumValidation.isValid) { - if (labelsIssueNumValidation.error) errors.push(labelsIssueNumValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label)); - break; - case "update-issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "push-to-pull-request-branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch); - item.message = sanitizeContent(item.message); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push-to-pull-request-branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create-pull-request-review-comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line === "") continue; - } - item.body = sanitizeContent(item.body); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create-pull-request-review-comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create-discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category); - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - break; - case "missing-tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool); - item.reason = sanitizeContent(item.reason); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); - continue; + try { + const item = parseJsonWithRepair(line); + if (item === undefined) { + errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); + continue; } - item.alternatives = sanitizeContent(item.alternatives); - } - break; - case "upload-asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "create-code-scanning-alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); + if (!item.type) { + errors.push(`Line ${i + 1}: Missing required 'type' field`); + continue; } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); - continue; + const itemType = item.type; + if (!expectedOutputTypes[itemType]) { + errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); + continue; } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; + const typeCount = parsedItems.filter(existing => existing.type === itemType).length; + const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); + if (typeCount >= maxAllowed) { + errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); + continue; } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file); - item.severity = sanitizeContent(item.severity); - item.message = sanitizeContent(item.message); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); - continue; - } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; + core.info(`Line ${i + 1}: type '${itemType}'`); + switch (itemType) { + case "create-issue": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-comment": + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); + continue; + } + const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); + if (!issueNumValidation.isValid) { + if (issueNumValidation.error) + errors.push(issueNumValidation.error); + continue; + } + item.body = sanitizeContent(item.body); + break; + case "create-pull-request": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); + continue; + } + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + item.branch = sanitizeContent(item.branch); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-labels": + if (!item.labels || !Array.isArray(item.labels)) { + errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); + continue; + } + if (item.labels.some((label) => typeof label !== "string")) { + errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); + continue; + } + const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); + if (!labelsIssueNumValidation.isValid) { + if (labelsIssueNumValidation.error) + errors.push(labelsIssueNumValidation.error); + continue; + } + item.labels = item.labels.map((label) => sanitizeContent(label)); + break; + case "update-issue": + const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; + if (!hasValidField) { + errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); + continue; + } + if (item.status !== undefined) { + if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { + errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); + continue; + } + } + if (item.title !== undefined) { + if (typeof item.title !== "string") { + errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); + continue; + } + item.title = sanitizeContent(item.title); + } + if (item.body !== undefined) { + if (typeof item.body !== "string") { + errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); + continue; + } + item.body = sanitizeContent(item.body); + } + const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); + if (!updateIssueNumValidation.isValid) { + if (updateIssueNumValidation.error) + errors.push(updateIssueNumValidation.error); + continue; + } + break; + case "push-to-pull-request-branch": + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); + continue; + } + item.branch = sanitizeContent(item.branch); + item.message = sanitizeContent(item.message); + const pushPRNumValidation = validateIssueOrPRNumber(item.pull_request_number, "push-to-pull-request-branch 'pull_request_number'", i + 1); + if (!pushPRNumValidation.isValid) { + if (pushPRNumValidation.error) + errors.push(pushPRNumValidation.error); + continue; + } + break; + case "create-pull-request-review-comment": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); + continue; + } + const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); + if (!lineValidation.isValid) { + if (lineValidation.error) + errors.push(lineValidation.error); + continue; + } + const lineNumber = lineValidation.normalizedValue; + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); + continue; + } + item.body = sanitizeContent(item.body); + const startLineValidation = validateOptionalPositiveInteger(item.start_line, "create-pull-request-review-comment 'start_line'", i + 1); + if (!startLineValidation.isValid) { + if (startLineValidation.error) + errors.push(startLineValidation.error); + continue; + } + if (startLineValidation.normalizedValue !== undefined && + lineNumber !== undefined && + startLineValidation.normalizedValue > lineNumber) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); + continue; + } + if (item.side !== undefined) { + if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); + continue; + } + } + break; + case "create-discussion": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); + continue; + } + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); + continue; + } + item.category = sanitizeContent(item.category); + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + break; + case "missing-tool": + if (!item.tool || typeof item.tool !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); + continue; + } + if (!item.reason || typeof item.reason !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); + continue; + } + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); + continue; + } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "upload-asset": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); + continue; + } + break; + case "create-code-scanning-alert": + if (!item.file || typeof item.file !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); + continue; + } + const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); + if (!alertLineValidation.isValid) { + if (alertLineValidation.error) { + errors.push(alertLineValidation.error); + } + continue; + } + if (!item.severity || typeof item.severity !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); + continue; + } + const allowedSeverities = ["error", "warning", "info", "note"]; + if (!allowedSeverities.includes(item.severity.toLowerCase())) { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}`); + continue; + } + const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); + if (!columnValidation.isValid) { + if (columnValidation.error) + errors.push(columnValidation.error); + continue; + } + if (item.ruleIdSuffix !== undefined) { + if (typeof item.ruleIdSuffix !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); + continue; + } + if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores`); + continue; + } + } + item.severity = item.severity.toLowerCase(); + item.file = sanitizeContent(item.file); + item.severity = sanitizeContent(item.severity); + item.message = sanitizeContent(item.message); + if (item.ruleIdSuffix) { + item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); + } + break; + default: + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); + continue; + } + Object.assign(item, validation.normalizedItem); + } + break; } - Object.assign(item, validation.normalizedItem); - } - break; - } - core.info(`Line ${i + 1}: Valid ${itemType} item`); - parsedItems.push(item); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); + core.info(`Line ${i + 1}: Valid ${itemType} item`); + parsedItems.push(item); + } + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); + } } - } - if (errors.length > 0) { - core.warning("Validation errors found:"); - errors.forEach(error => core.warning(` - ${error}`)); - if (parsedItems.length === 0) { - core.setFailed(errors.map(e => ` - ${e}`).join("\n")); - return; + if (errors.length > 0) { + core.warning("Validation errors found:"); + errors.forEach(error => core.warning(` - ${error}`)); + if (parsedItems.length === 0) { + core.setFailed(errors.map(e => ` - ${e}`).join("\n")); + return; + } } - } - for (const itemType of Object.keys(expectedOutputTypes)) { - const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); - if (minRequired > 0) { - const actualCount = parsedItems.filter(item => item.type === itemType).length; - if (actualCount < minRequired) { - errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); - } + for (const itemType of Object.keys(expectedOutputTypes)) { + const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); + if (minRequired > 0) { + const actualCount = parsedItems.filter(item => item.type === itemType).length; + if (actualCount < minRequired) { + errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); + } + } + } + core.info(`Successfully parsed ${parsedItems.length} valid output items`); + const validatedOutput = { + items: parsedItems, + errors: errors, + }; + const agentOutputFile = "/tmp/agent_output.json"; + const validatedOutputJson = JSON.stringify(validatedOutput); + try { + fs.mkdirSync("/tmp", { recursive: true }); + fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); + core.info(`Stored validated output to: ${agentOutputFile}`); + core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); + } + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.error(`Failed to write agent output file: ${errorMsg}`); + } + core.setOutput("output", JSON.stringify(validatedOutput)); + core.setOutput("raw_output", outputContent); + const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); + core.info(`output_types: ${outputTypes.join(", ")}`); + core.setOutput("output_types", outputTypes.join(",")); + try { + await core.summary + .addRaw("## Processed Output\n\n") + .addRaw("```json\n") + .addRaw(JSON.stringify(validatedOutput)) + .addRaw("\n```\n") + .write(); + core.info("Successfully wrote processed output to step summary"); + } + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.warning(`Failed to write to step summary: ${errorMsg}`); } - } - core.info(`Successfully parsed ${parsedItems.length} valid output items`); - const validatedOutput = { - items: parsedItems, - errors: errors, - }; - const agentOutputFile = "/tmp/agent_output.json"; - const validatedOutputJson = JSON.stringify(validatedOutput); - try { - fs.mkdirSync("/tmp", { recursive: true }); - fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); - core.info(`Stored validated output to: ${agentOutputFile}`); - core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.error(`Failed to write agent output file: ${errorMsg}`); - } - core.setOutput("output", JSON.stringify(validatedOutput)); - core.setOutput("raw_output", outputContent); - const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); - core.info(`output_types: ${outputTypes.join(", ")}`); - core.setOutput("output_types", outputTypes.join(",")); - try { - await core.summary - .addRaw("## Processed Output\n\n") - .addRaw("```json\n") - .addRaw(JSON.stringify(validatedOutput)) - .addRaw("\n```\n") - .write(); - core.info("Successfully wrote processed output to step summary"); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.warning(`Failed to write to step summary: ${errorMsg}`); - } } await main(); - name: Upload sanitized agent output @@ -2925,165 +2925,163 @@ jobs: with: script: | function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace( - /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, - (_m, p1, p2) => `${p1}\`@${p2}\`` - ); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); + if (!content || typeof content !== "string") { + return ""; + } + let sanitized = content.trim(); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); + sanitized = sanitized.replace(/[<>&'"]/g, ""); + return sanitized.trim(); } async function main() { - const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; - const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; - if (!outputContent) { - core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); - return; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - return; - } - const createIssueItems = validatedOutput.items.filter(item => item.type === "create-issue"); - if (createIssueItems.length === 0) { - core.info("No create-issue items found in agent output"); - return; - } - core.info(`Found ${createIssueItems.length} create-issue item(s)`); - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Issues Preview\n\n"; - summaryContent += "The following issues would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createIssueItems.length; i++) { - const item = createIssueItems[i]; - summaryContent += `### Issue ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; - } - if (item.labels && item.labels.length > 0) { - summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; - } - summaryContent += "---\n\n"; + const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; + const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; + if (!outputContent) { + core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); + return; } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Issue creation preview written to step summary"); - return; - } - const parentIssueNumber = context.payload?.issue?.number; - const labelsEnv = process.env.GITHUB_AW_ISSUE_LABELS; - let envLabels = labelsEnv - ? labelsEnv - .split(",") - .map(label => label.trim()) - .filter(label => label) - : []; - const createdIssues = []; - for (let i = 0; i < createIssueItems.length; i++) { - const createIssueItem = createIssueItems[i]; - core.info( - `Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}` - ); - let labels = [...envLabels]; - if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { - labels = [...labels, ...createIssueItem.labels]; - } - labels = labels - .filter(label => label != null && label !== false && label !== 0) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - let title = createIssueItem.title ? createIssueItem.title.trim() : ""; - let bodyLines = createIssueItem.body.split("\n"); - if (!title) { - title = createIssueItem.body || "Agent Output"; - } - const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - if (parentIssueNumber) { - core.info("Detected issue context, parent issue #" + parentIssueNumber); - bodyLines.push(`Related to #${parentIssueNumber}`); - } - const workflowName = process.env.GITHUB_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const runUrl = context.payload.repository - ? `${context.payload.repository.html_url}/actions/runs/${runId}` - : `https://github.com/actions/runs/${runId}`; - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating issue with title: ${title}`); - core.info(`Labels: ${labels}`); - core.info(`Body length: ${body.length}`); + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: body, - labels: labels, - }); - core.info("Created issue #" + issue.number + ": " + issue.html_url); - createdIssues.push(issue); - if (parentIssueNumber) { + validatedOutput = JSON.parse(outputContent); + } + catch (error) { + core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); + return; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + return; + } + const createIssueItems = validatedOutput.items.filter(item => item.type === "create-issue"); + if (createIssueItems.length === 0) { + core.info("No create-issue items found in agent output"); + return; + } + core.info(`Found ${createIssueItems.length} create-issue item(s)`); + if (isStaged) { + let summaryContent = "## 🎭 Staged Mode: Create Issues Preview\n\n"; + summaryContent += "The following issues would be created if staged mode was disabled:\n\n"; + for (let i = 0; i < createIssueItems.length; i++) { + const item = createIssueItems[i]; + summaryContent += `### Issue ${i + 1}\n`; + summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; + if (item.body) { + summaryContent += `**Body:**\n${item.body}\n\n`; + } + if (item.labels && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; + } + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Issue creation preview written to step summary"); + return; + } + const parentIssueNumber = context.payload?.issue?.number; + const labelsEnv = process.env.GITHUB_AW_ISSUE_LABELS; + let envLabels = labelsEnv + ? labelsEnv + .split(",") + .map((label) => label.trim()) + .filter((label) => label) + : []; + const createdIssues = []; + for (let i = 0; i < createIssueItems.length; i++) { + const createIssueItem = createIssueItems[i]; + core.info(`Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}`); + let labels = [...envLabels]; + if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { + labels = [...labels, ...createIssueItem.labels]; + } + labels = labels + .filter(label => label != null && label !== false && label !== 0) + .map(label => String(label).trim()) + .filter(label => label) + .map(label => sanitizeLabelContent(label)) + .filter(label => label) + .map(label => (label.length > 64 ? label.substring(0, 64) : label)) + .filter((label, index, arr) => arr.indexOf(label) === index); + let title = createIssueItem.title ? createIssueItem.title.trim() : ""; + let bodyLines = createIssueItem.body.split("\n"); + if (!title) { + title = createIssueItem.body || "Agent Output"; + } + const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; + if (titlePrefix && !title.startsWith(titlePrefix)) { + title = titlePrefix + title; + } + if (parentIssueNumber) { + core.info("Detected issue context, parent issue #" + parentIssueNumber); + bodyLines.push(`Related to #${parentIssueNumber}`); + } + const workflowName = process.env.GITHUB_AW_WORKFLOW_NAME || "Workflow"; + const runId = context.runId; + const runUrl = context.payload.repository + ? `${context.payload.repository.html_url}/actions/runs/${runId}` + : `https://github.com/actions/runs/${runId}`; + bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); + const body = bodyLines.join("\n").trim(); + core.info(`Creating issue with title: ${title}`); + core.info(`Labels: ${labels}`); + core.info(`Body length: ${body.length}`); try { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: parentIssueNumber, - body: `Created related issue: #${issue.number}`, - }); - core.info("Added comment to parent issue #" + parentIssueNumber); - } catch (error) { - core.info(`Warning: Could not add comment to parent issue: ${error instanceof Error ? error.message : String(error)}`); + const { data: issue } = await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: title, + body: body, + labels: labels, + }); + core.info("Created issue #" + issue.number + ": " + issue.html_url); + createdIssues.push(issue); + if (parentIssueNumber) { + try { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: parentIssueNumber, + body: `Created related issue: #${issue.number}`, + }); + core.info("Added comment to parent issue #" + parentIssueNumber); + } + catch (error) { + core.info(`Warning: Could not add comment to parent issue: ${error instanceof Error ? error.message : String(error)}`); + } + } + if (i === createIssueItems.length - 1) { + core.setOutput("issue_number", issue.number); + core.setOutput("issue_url", issue.html_url); + } + } + catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage.includes("Issues has been disabled in this repository")) { + core.info(`⚠ Cannot create issue "${title}": Issues are disabled for this repository`); + core.info("Consider enabling issues in repository settings if you want to create issues automatically"); + continue; + } + core.error(`✗ Failed to create issue "${title}": ${errorMessage}`); + throw error; } - } - if (i === createIssueItems.length - 1) { - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Issues has been disabled in this repository")) { - core.info(`⚠ Cannot create issue "${title}": Issues are disabled for this repository`); - core.info("Consider enabling issues in repository settings if you want to create issues automatically"); - continue; - } - core.error(`✗ Failed to create issue "${title}": ${errorMessage}`); - throw error; } - } - if (createdIssues.length > 0) { - let summaryContent = "\n\n## GitHub Issues\n"; - for (const issue of createdIssues) { - summaryContent += `- Issue #${issue.number}: [${issue.title}](${issue.html_url})\n`; + if (createdIssues.length > 0) { + let summaryContent = "\n\n## GitHub Issues\n"; + for (const issue of createdIssues) { + summaryContent += `- Issue #${issue.number}: [${issue.title}](${issue.html_url})\n`; + } + await core.summary.addRaw(summaryContent).write(); } - await core.summary.addRaw(summaryContent).write(); - } - core.info(`Successfully created ${createdIssues.length} issue(s)`); + core.info(`Successfully created ${createdIssues.length} issue(s)`); } (async () => { - await main(); + await main(); })(); missing_tool: diff --git a/.github/workflows/go-pattern-detector.lock.yml b/.github/workflows/go-pattern-detector.lock.yml index 1905759d58f..a20825efe12 100644 --- a/.github/workflows/go-pattern-detector.lock.yml +++ b/.github/workflows/go-pattern-detector.lock.yml @@ -1340,742 +1340,742 @@ jobs: with: script: | async function main() { - const fs = require("fs"); - function sanitizeContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; - const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; - const allowedDomains = allowedDomainsEnv - ? allowedDomainsEnv - .split(",") - .map(d => d.trim()) - .filter(d => d) - : defaultAllowedDomains; - let sanitized = content; - sanitized = neutralizeMentions(sanitized); - sanitized = removeXmlComments(sanitized); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitizeUrlProtocols(sanitized); - sanitized = sanitizeUrlDomains(sanitized); - const maxLength = 524288; - if (sanitized.length > maxLength) { - sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; - } - const lines = sanitized.split("\n"); - const maxLines = 65000; - if (lines.length > maxLines) { - sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; - } - sanitized = neutralizeBotTriggers(sanitized); - return sanitized.trim(); - function sanitizeUrlDomains(s) { - return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { - const urlAfterProtocol = match.slice(8); - const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); - const isAllowed = allowedDomains.some(allowedDomain => { - const normalizedAllowed = allowedDomain.toLowerCase(); - return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); - }); - return isAllowed ? match : "(redacted)"; - }); - } - function sanitizeUrlProtocols(s) { - return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { - return protocol.toLowerCase() === "https" ? match : "(redacted)"; - }); - } - function neutralizeMentions(s) { - return s.replace( - /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, - (_m, p1, p2) => `${p1}\`@${p2}\`` - ); - } - function removeXmlComments(s) { - return s.replace(//g, "").replace(//g, ""); - } - function neutralizeBotTriggers(s) { - return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); - } - } - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create-issue": - return 1; - case "add-comment": - return 1; - case "create-pull-request": - return 1; - case "create-pull-request-review-comment": - return 1; - case "add-labels": - return 5; - case "update-issue": - return 1; - case "push-to-pull-request-branch": - return 1; - case "create-discussion": - return 1; - case "missing-tool": - return 1000; - case "create-code-scanning-alert": - return 1000; - case "upload-asset": - return 10; - default: - return 1; - } - } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; - } - return 0; - } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; - } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; + const fs = require("fs"); + function sanitizeContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; + const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; + const allowedDomains = allowedDomainsEnv + ? allowedDomainsEnv + .split(",") + .map(d => d.trim()) + .filter(d => d) + : defaultAllowedDomains; + let sanitized = content; + sanitized = neutralizeMentions(sanitized); + sanitized = removeXmlComments(sanitized); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitizeUrlProtocols(sanitized); + sanitized = sanitizeUrlDomains(sanitized); + const maxLength = 524288; + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; + } + const lines = sanitized.split("\n"); + const maxLines = 65000; + if (lines.length > maxLines) { + sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; + } + sanitized = neutralizeBotTriggers(sanitized); + return sanitized.trim(); + function sanitizeUrlDomains(s) { + return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { + const urlAfterProtocol = match.slice(8); + const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); + const isAllowed = allowedDomains.some(allowedDomain => { + const normalizedAllowed = allowedDomain.toLowerCase(); + return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); + }); + return isAllowed ? match : "(redacted)"; + }); + } + function sanitizeUrlProtocols(s) { + return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { + return protocol.toLowerCase() === "https" ? match : "(redacted)"; + }); + } + function neutralizeMentions(s) { + return s.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); + } + function removeXmlComments(s) { + return s.replace(//g, "").replace(//g, ""); + } + function neutralizeBotTriggers(s) { + return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); + } } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + switch (itemType) { + case "create-issue": + return 1; + case "add-comment": + return 1; + case "create-pull-request": + return 1; + case "create-pull-request-review-comment": + return 1; + case "add-labels": + return 5; + case "update-issue": + return 1; + case "push-to-pull-request-branch": + return 1; + case "create-discussion": + return 1; + case "missing-tool": + return 1000; + case "create-code-scanning-alert": + return 1000; + case "upload-asset": + return 10; + default: + return 1; + } } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; + } + return 0; + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } + else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } + else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; } - return { isValid: true, normalizedValue: parsed }; - } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { + if (inputSchema.required && (value === undefined || value === null)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (value === undefined || value === null) { + return { + isValid: true, + normalizedValue: inputSchema.default || undefined, + }; + } + const inputType = inputSchema.type || "string"; + let normalizedValue = value; + switch (inputType) { + case "string": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string`, + }; + } + normalizedValue = sanitizeContent(value); + break; + case "boolean": + if (typeof value !== "boolean") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a boolean`, + }; + } + break; + case "number": + if (typeof value !== "number") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number`, + }; + } + break; + case "choice": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, + }; + } + if (inputSchema.options && !inputSchema.options.includes(value)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, + }; + } + normalizedValue = sanitizeContent(value); + break; + default: + if (typeof value === "string") { + normalizedValue = sanitizeContent(value); + } + break; + } return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, + isValid: true, + normalizedValue, }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { + function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { + const errors = []; + const normalizedItem = { ...item }; + if (!jobConfig.inputs) { + return { + isValid: true, + errors: [], + normalizedItem: item, + }; + } + for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { + const fieldValue = item[fieldName]; + const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); + if (!validation.isValid && validation.error) { + errors.push(validation.error); + } + else if (validation.normalizedValue !== undefined) { + normalizedItem[fieldName] = validation.normalizedValue; + } + } return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, + isValid: errors.length === 0, + errors, + normalizedItem, }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; - } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + function parseJsonWithRepair(jsonStr) { + try { + return JSON.parse(jsonStr); + } + catch (originalError) { + try { + const repairedJson = repairJson(jsonStr); + return JSON.parse(repairedJson); + } + catch (repairError) { + core.info(`invalid input json: ${jsonStr}`); + const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); + const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); + throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); + } + } } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; + const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; + if (!outputFile) { + core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); + core.setOutput("output", ""); + return; } - return { isValid: true }; - } - function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { - if (inputSchema.required && (value === undefined || value === null)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; + if (!fs.existsSync(outputFile)) { + core.info(`Output file does not exist: ${outputFile}`); + core.setOutput("output", ""); + return; } - if (value === undefined || value === null) { - return { - isValid: true, - normalizedValue: inputSchema.default || undefined, - }; + const outputContent = fs.readFileSync(outputFile, "utf8"); + if (outputContent.trim() === "") { + core.info("Output file is empty"); } - const inputType = inputSchema.type || "string"; - let normalizedValue = value; - switch (inputType) { - case "string": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string`, - }; - } - normalizedValue = sanitizeContent(value); - break; - case "boolean": - if (typeof value !== "boolean") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a boolean`, - }; - } - break; - case "number": - if (typeof value !== "number") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number`, - }; - } - break; - case "choice": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, - }; - } - if (inputSchema.options && !inputSchema.options.includes(value)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, - }; + core.info(`Raw output content length: ${outputContent.length}`); + let expectedOutputTypes = {}; + if (safeOutputsConfig) { + try { + expectedOutputTypes = JSON.parse(safeOutputsConfig); + core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); } - normalizedValue = sanitizeContent(value); - break; - default: - if (typeof value === "string") { - normalizedValue = sanitizeContent(value); + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); } - break; } - return { - isValid: true, - normalizedValue, - }; - } - function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { + const lines = outputContent.trim().split("\n"); + const parsedItems = []; const errors = []; - const normalizedItem = { ...item }; - if (!jobConfig.inputs) { - return { - isValid: true, - errors: [], - normalizedItem: item, - }; - } - for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { - const fieldValue = item[fieldName]; - const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); - if (!validation.isValid && validation.error) { - errors.push(validation.error); - } else if (validation.normalizedValue !== undefined) { - normalizedItem[fieldName] = validation.normalizedValue; - } - } - return { - isValid: errors.length === 0, - errors, - normalizedItem, - }; - } - function parseJsonWithRepair(jsonStr) { - try { - return JSON.parse(jsonStr); - } catch (originalError) { - try { - const repairedJson = repairJson(jsonStr); - return JSON.parse(repairedJson); - } catch (repairError) { - core.info(`invalid input json: ${jsonStr}`); - const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); - const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); - throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); - } - } - } - const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; - const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; - if (!outputFile) { - core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); - core.setOutput("output", ""); - return; - } - if (!fs.existsSync(outputFile)) { - core.info(`Output file does not exist: ${outputFile}`); - core.setOutput("output", ""); - return; - } - const outputContent = fs.readFileSync(outputFile, "utf8"); - if (outputContent.trim() === "") { - core.info("Output file is empty"); - } - core.info(`Raw output content length: ${outputContent.length}`); - let expectedOutputTypes = {}; - if (safeOutputsConfig) { - try { - expectedOutputTypes = JSON.parse(safeOutputsConfig); - core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); - } - } - const lines = outputContent.trim().split("\n"); - const parsedItems = []; - const errors = []; - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); - if (line === "") continue; - try { - const item = parseJsonWithRepair(line); - if (item === undefined) { - errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); - continue; - } - if (!item.type) { - errors.push(`Line ${i + 1}: Missing required 'type' field`); - continue; - } - const itemType = item.type; - if (!expectedOutputTypes[itemType]) { - errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); - continue; - } - const typeCount = parsedItems.filter(existing => existing.type === itemType).length; - const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); - if (typeCount >= maxAllowed) { - errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); - continue; - } - core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create-issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); - if (!issueNumValidation.isValid) { - if (issueNumValidation.error) errors.push(issueNumValidation.error); + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line === "") continue; - } - item.body = sanitizeContent(item.body); - break; - case "create-pull-request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - item.branch = sanitizeContent(item.branch); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); - if (!labelsIssueNumValidation.isValid) { - if (labelsIssueNumValidation.error) errors.push(labelsIssueNumValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label)); - break; - case "update-issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "push-to-pull-request-branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch); - item.message = sanitizeContent(item.message); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push-to-pull-request-branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create-pull-request-review-comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create-pull-request-review-comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; + try { + const item = parseJsonWithRepair(line); + if (item === undefined) { + errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); + continue; } - } - break; - case "create-discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; + if (!item.type) { + errors.push(`Line ${i + 1}: Missing required 'type' field`); + continue; } - item.category = sanitizeContent(item.category); - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - break; - case "missing-tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool); - item.reason = sanitizeContent(item.reason); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); - continue; + const itemType = item.type; + if (!expectedOutputTypes[itemType]) { + errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); + continue; } - item.alternatives = sanitizeContent(item.alternatives); - } - break; - case "upload-asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "create-code-scanning-alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); + const typeCount = parsedItems.filter(existing => existing.type === itemType).length; + const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); + if (typeCount >= maxAllowed) { + errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); + continue; } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); - continue; + core.info(`Line ${i + 1}: type '${itemType}'`); + switch (itemType) { + case "create-issue": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-comment": + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); + continue; + } + const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); + if (!issueNumValidation.isValid) { + if (issueNumValidation.error) + errors.push(issueNumValidation.error); + continue; + } + item.body = sanitizeContent(item.body); + break; + case "create-pull-request": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); + continue; + } + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + item.branch = sanitizeContent(item.branch); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-labels": + if (!item.labels || !Array.isArray(item.labels)) { + errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); + continue; + } + if (item.labels.some((label) => typeof label !== "string")) { + errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); + continue; + } + const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); + if (!labelsIssueNumValidation.isValid) { + if (labelsIssueNumValidation.error) + errors.push(labelsIssueNumValidation.error); + continue; + } + item.labels = item.labels.map((label) => sanitizeContent(label)); + break; + case "update-issue": + const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; + if (!hasValidField) { + errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); + continue; + } + if (item.status !== undefined) { + if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { + errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); + continue; + } + } + if (item.title !== undefined) { + if (typeof item.title !== "string") { + errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); + continue; + } + item.title = sanitizeContent(item.title); + } + if (item.body !== undefined) { + if (typeof item.body !== "string") { + errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); + continue; + } + item.body = sanitizeContent(item.body); + } + const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); + if (!updateIssueNumValidation.isValid) { + if (updateIssueNumValidation.error) + errors.push(updateIssueNumValidation.error); + continue; + } + break; + case "push-to-pull-request-branch": + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); + continue; + } + item.branch = sanitizeContent(item.branch); + item.message = sanitizeContent(item.message); + const pushPRNumValidation = validateIssueOrPRNumber(item.pull_request_number, "push-to-pull-request-branch 'pull_request_number'", i + 1); + if (!pushPRNumValidation.isValid) { + if (pushPRNumValidation.error) + errors.push(pushPRNumValidation.error); + continue; + } + break; + case "create-pull-request-review-comment": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); + continue; + } + const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); + if (!lineValidation.isValid) { + if (lineValidation.error) + errors.push(lineValidation.error); + continue; + } + const lineNumber = lineValidation.normalizedValue; + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); + continue; + } + item.body = sanitizeContent(item.body); + const startLineValidation = validateOptionalPositiveInteger(item.start_line, "create-pull-request-review-comment 'start_line'", i + 1); + if (!startLineValidation.isValid) { + if (startLineValidation.error) + errors.push(startLineValidation.error); + continue; + } + if (startLineValidation.normalizedValue !== undefined && + lineNumber !== undefined && + startLineValidation.normalizedValue > lineNumber) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); + continue; + } + if (item.side !== undefined) { + if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); + continue; + } + } + break; + case "create-discussion": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); + continue; + } + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); + continue; + } + item.category = sanitizeContent(item.category); + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + break; + case "missing-tool": + if (!item.tool || typeof item.tool !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); + continue; + } + if (!item.reason || typeof item.reason !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); + continue; + } + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); + continue; + } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "upload-asset": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); + continue; + } + break; + case "create-code-scanning-alert": + if (!item.file || typeof item.file !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); + continue; + } + const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); + if (!alertLineValidation.isValid) { + if (alertLineValidation.error) { + errors.push(alertLineValidation.error); + } + continue; + } + if (!item.severity || typeof item.severity !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); + continue; + } + const allowedSeverities = ["error", "warning", "info", "note"]; + if (!allowedSeverities.includes(item.severity.toLowerCase())) { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}`); + continue; + } + const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); + if (!columnValidation.isValid) { + if (columnValidation.error) + errors.push(columnValidation.error); + continue; + } + if (item.ruleIdSuffix !== undefined) { + if (typeof item.ruleIdSuffix !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); + continue; + } + if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores`); + continue; + } + } + item.severity = item.severity.toLowerCase(); + item.file = sanitizeContent(item.file); + item.severity = sanitizeContent(item.severity); + item.message = sanitizeContent(item.message); + if (item.ruleIdSuffix) { + item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); + } + break; + default: + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); + continue; + } + Object.assign(item, validation.normalizedItem); + } + break; } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file); - item.severity = sanitizeContent(item.severity); - item.message = sanitizeContent(item.message); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); - continue; - } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; - } - core.info(`Line ${i + 1}: Valid ${itemType} item`); - parsedItems.push(item); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); + core.info(`Line ${i + 1}: Valid ${itemType} item`); + parsedItems.push(item); + } + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); + } } - } - if (errors.length > 0) { - core.warning("Validation errors found:"); - errors.forEach(error => core.warning(` - ${error}`)); - if (parsedItems.length === 0) { - core.setFailed(errors.map(e => ` - ${e}`).join("\n")); - return; + if (errors.length > 0) { + core.warning("Validation errors found:"); + errors.forEach(error => core.warning(` - ${error}`)); + if (parsedItems.length === 0) { + core.setFailed(errors.map(e => ` - ${e}`).join("\n")); + return; + } } - } - for (const itemType of Object.keys(expectedOutputTypes)) { - const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); - if (minRequired > 0) { - const actualCount = parsedItems.filter(item => item.type === itemType).length; - if (actualCount < minRequired) { - errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); - } + for (const itemType of Object.keys(expectedOutputTypes)) { + const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); + if (minRequired > 0) { + const actualCount = parsedItems.filter(item => item.type === itemType).length; + if (actualCount < minRequired) { + errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); + } + } + } + core.info(`Successfully parsed ${parsedItems.length} valid output items`); + const validatedOutput = { + items: parsedItems, + errors: errors, + }; + const agentOutputFile = "/tmp/agent_output.json"; + const validatedOutputJson = JSON.stringify(validatedOutput); + try { + fs.mkdirSync("/tmp", { recursive: true }); + fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); + core.info(`Stored validated output to: ${agentOutputFile}`); + core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); + } + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.error(`Failed to write agent output file: ${errorMsg}`); + } + core.setOutput("output", JSON.stringify(validatedOutput)); + core.setOutput("raw_output", outputContent); + const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); + core.info(`output_types: ${outputTypes.join(", ")}`); + core.setOutput("output_types", outputTypes.join(",")); + try { + await core.summary + .addRaw("## Processed Output\n\n") + .addRaw("```json\n") + .addRaw(JSON.stringify(validatedOutput)) + .addRaw("\n```\n") + .write(); + core.info("Successfully wrote processed output to step summary"); + } + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.warning(`Failed to write to step summary: ${errorMsg}`); } - } - core.info(`Successfully parsed ${parsedItems.length} valid output items`); - const validatedOutput = { - items: parsedItems, - errors: errors, - }; - const agentOutputFile = "/tmp/agent_output.json"; - const validatedOutputJson = JSON.stringify(validatedOutput); - try { - fs.mkdirSync("/tmp", { recursive: true }); - fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); - core.info(`Stored validated output to: ${agentOutputFile}`); - core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.error(`Failed to write agent output file: ${errorMsg}`); - } - core.setOutput("output", JSON.stringify(validatedOutput)); - core.setOutput("raw_output", outputContent); - const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); - core.info(`output_types: ${outputTypes.join(", ")}`); - core.setOutput("output_types", outputTypes.join(",")); - try { - await core.summary - .addRaw("## Processed Output\n\n") - .addRaw("```json\n") - .addRaw(JSON.stringify(validatedOutput)) - .addRaw("\n```\n") - .write(); - core.info("Successfully wrote processed output to step summary"); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.warning(`Failed to write to step summary: ${errorMsg}`); - } } await main(); - name: Upload sanitized agent output @@ -2811,165 +2811,163 @@ jobs: with: script: | function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace( - /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, - (_m, p1, p2) => `${p1}\`@${p2}\`` - ); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); + if (!content || typeof content !== "string") { + return ""; + } + let sanitized = content.trim(); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); + sanitized = sanitized.replace(/[<>&'"]/g, ""); + return sanitized.trim(); } async function main() { - const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; - const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; - if (!outputContent) { - core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); - return; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - return; - } - const createIssueItems = validatedOutput.items.filter(item => item.type === "create-issue"); - if (createIssueItems.length === 0) { - core.info("No create-issue items found in agent output"); - return; - } - core.info(`Found ${createIssueItems.length} create-issue item(s)`); - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Issues Preview\n\n"; - summaryContent += "The following issues would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createIssueItems.length; i++) { - const item = createIssueItems[i]; - summaryContent += `### Issue ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; - } - if (item.labels && item.labels.length > 0) { - summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; - } - summaryContent += "---\n\n"; + const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; + const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; + if (!outputContent) { + core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); + return; } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Issue creation preview written to step summary"); - return; - } - const parentIssueNumber = context.payload?.issue?.number; - const labelsEnv = process.env.GITHUB_AW_ISSUE_LABELS; - let envLabels = labelsEnv - ? labelsEnv - .split(",") - .map(label => label.trim()) - .filter(label => label) - : []; - const createdIssues = []; - for (let i = 0; i < createIssueItems.length; i++) { - const createIssueItem = createIssueItems[i]; - core.info( - `Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}` - ); - let labels = [...envLabels]; - if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { - labels = [...labels, ...createIssueItem.labels]; - } - labels = labels - .filter(label => label != null && label !== false && label !== 0) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - let title = createIssueItem.title ? createIssueItem.title.trim() : ""; - let bodyLines = createIssueItem.body.split("\n"); - if (!title) { - title = createIssueItem.body || "Agent Output"; - } - const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - if (parentIssueNumber) { - core.info("Detected issue context, parent issue #" + parentIssueNumber); - bodyLines.push(`Related to #${parentIssueNumber}`); - } - const workflowName = process.env.GITHUB_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const runUrl = context.payload.repository - ? `${context.payload.repository.html_url}/actions/runs/${runId}` - : `https://github.com/actions/runs/${runId}`; - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating issue with title: ${title}`); - core.info(`Labels: ${labels}`); - core.info(`Body length: ${body.length}`); + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: body, - labels: labels, - }); - core.info("Created issue #" + issue.number + ": " + issue.html_url); - createdIssues.push(issue); - if (parentIssueNumber) { + validatedOutput = JSON.parse(outputContent); + } + catch (error) { + core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); + return; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + return; + } + const createIssueItems = validatedOutput.items.filter(item => item.type === "create-issue"); + if (createIssueItems.length === 0) { + core.info("No create-issue items found in agent output"); + return; + } + core.info(`Found ${createIssueItems.length} create-issue item(s)`); + if (isStaged) { + let summaryContent = "## 🎭 Staged Mode: Create Issues Preview\n\n"; + summaryContent += "The following issues would be created if staged mode was disabled:\n\n"; + for (let i = 0; i < createIssueItems.length; i++) { + const item = createIssueItems[i]; + summaryContent += `### Issue ${i + 1}\n`; + summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; + if (item.body) { + summaryContent += `**Body:**\n${item.body}\n\n`; + } + if (item.labels && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; + } + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Issue creation preview written to step summary"); + return; + } + const parentIssueNumber = context.payload?.issue?.number; + const labelsEnv = process.env.GITHUB_AW_ISSUE_LABELS; + let envLabels = labelsEnv + ? labelsEnv + .split(",") + .map((label) => label.trim()) + .filter((label) => label) + : []; + const createdIssues = []; + for (let i = 0; i < createIssueItems.length; i++) { + const createIssueItem = createIssueItems[i]; + core.info(`Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}`); + let labels = [...envLabels]; + if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { + labels = [...labels, ...createIssueItem.labels]; + } + labels = labels + .filter(label => label != null && label !== false && label !== 0) + .map(label => String(label).trim()) + .filter(label => label) + .map(label => sanitizeLabelContent(label)) + .filter(label => label) + .map(label => (label.length > 64 ? label.substring(0, 64) : label)) + .filter((label, index, arr) => arr.indexOf(label) === index); + let title = createIssueItem.title ? createIssueItem.title.trim() : ""; + let bodyLines = createIssueItem.body.split("\n"); + if (!title) { + title = createIssueItem.body || "Agent Output"; + } + const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; + if (titlePrefix && !title.startsWith(titlePrefix)) { + title = titlePrefix + title; + } + if (parentIssueNumber) { + core.info("Detected issue context, parent issue #" + parentIssueNumber); + bodyLines.push(`Related to #${parentIssueNumber}`); + } + const workflowName = process.env.GITHUB_AW_WORKFLOW_NAME || "Workflow"; + const runId = context.runId; + const runUrl = context.payload.repository + ? `${context.payload.repository.html_url}/actions/runs/${runId}` + : `https://github.com/actions/runs/${runId}`; + bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); + const body = bodyLines.join("\n").trim(); + core.info(`Creating issue with title: ${title}`); + core.info(`Labels: ${labels}`); + core.info(`Body length: ${body.length}`); try { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: parentIssueNumber, - body: `Created related issue: #${issue.number}`, - }); - core.info("Added comment to parent issue #" + parentIssueNumber); - } catch (error) { - core.info(`Warning: Could not add comment to parent issue: ${error instanceof Error ? error.message : String(error)}`); + const { data: issue } = await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: title, + body: body, + labels: labels, + }); + core.info("Created issue #" + issue.number + ": " + issue.html_url); + createdIssues.push(issue); + if (parentIssueNumber) { + try { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: parentIssueNumber, + body: `Created related issue: #${issue.number}`, + }); + core.info("Added comment to parent issue #" + parentIssueNumber); + } + catch (error) { + core.info(`Warning: Could not add comment to parent issue: ${error instanceof Error ? error.message : String(error)}`); + } + } + if (i === createIssueItems.length - 1) { + core.setOutput("issue_number", issue.number); + core.setOutput("issue_url", issue.html_url); + } + } + catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage.includes("Issues has been disabled in this repository")) { + core.info(`⚠ Cannot create issue "${title}": Issues are disabled for this repository`); + core.info("Consider enabling issues in repository settings if you want to create issues automatically"); + continue; + } + core.error(`✗ Failed to create issue "${title}": ${errorMessage}`); + throw error; } - } - if (i === createIssueItems.length - 1) { - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Issues has been disabled in this repository")) { - core.info(`⚠ Cannot create issue "${title}": Issues are disabled for this repository`); - core.info("Consider enabling issues in repository settings if you want to create issues automatically"); - continue; - } - core.error(`✗ Failed to create issue "${title}": ${errorMessage}`); - throw error; } - } - if (createdIssues.length > 0) { - let summaryContent = "\n\n## GitHub Issues\n"; - for (const issue of createdIssues) { - summaryContent += `- Issue #${issue.number}: [${issue.title}](${issue.html_url})\n`; + if (createdIssues.length > 0) { + let summaryContent = "\n\n## GitHub Issues\n"; + for (const issue of createdIssues) { + summaryContent += `- Issue #${issue.number}: [${issue.title}](${issue.html_url})\n`; + } + await core.summary.addRaw(summaryContent).write(); } - await core.summary.addRaw(summaryContent).write(); - } - core.info(`Successfully created ${createdIssues.length} issue(s)`); + core.info(`Successfully created ${createdIssues.length} issue(s)`); } (async () => { - await main(); + await main(); })(); missing_tool: diff --git a/.github/workflows/issue-classifier.lock.yml b/.github/workflows/issue-classifier.lock.yml index 0132d3c9775..b70f7eebe6b 100644 --- a/.github/workflows/issue-classifier.lock.yml +++ b/.github/workflows/issue-classifier.lock.yml @@ -1312,742 +1312,742 @@ jobs: with: script: | async function main() { - const fs = require("fs"); - function sanitizeContent(content) { - if (!content || typeof content !== "string") { - return ""; + const fs = require("fs"); + function sanitizeContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; + const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; + const allowedDomains = allowedDomainsEnv + ? allowedDomainsEnv + .split(",") + .map(d => d.trim()) + .filter(d => d) + : defaultAllowedDomains; + let sanitized = content; + sanitized = neutralizeMentions(sanitized); + sanitized = removeXmlComments(sanitized); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitizeUrlProtocols(sanitized); + sanitized = sanitizeUrlDomains(sanitized); + const maxLength = 524288; + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; + } + const lines = sanitized.split("\n"); + const maxLines = 65000; + if (lines.length > maxLines) { + sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; + } + sanitized = neutralizeBotTriggers(sanitized); + return sanitized.trim(); + function sanitizeUrlDomains(s) { + return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { + const urlAfterProtocol = match.slice(8); + const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); + const isAllowed = allowedDomains.some(allowedDomain => { + const normalizedAllowed = allowedDomain.toLowerCase(); + return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); + }); + return isAllowed ? match : "(redacted)"; + }); + } + function sanitizeUrlProtocols(s) { + return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { + return protocol.toLowerCase() === "https" ? match : "(redacted)"; + }); + } + function neutralizeMentions(s) { + return s.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); + } + function removeXmlComments(s) { + return s.replace(//g, "").replace(//g, ""); + } + function neutralizeBotTriggers(s) { + return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); + } } - const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; - const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; - const allowedDomains = allowedDomainsEnv - ? allowedDomainsEnv - .split(",") - .map(d => d.trim()) - .filter(d => d) - : defaultAllowedDomains; - let sanitized = content; - sanitized = neutralizeMentions(sanitized); - sanitized = removeXmlComments(sanitized); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitizeUrlProtocols(sanitized); - sanitized = sanitizeUrlDomains(sanitized); - const maxLength = 524288; - if (sanitized.length > maxLength) { - sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + switch (itemType) { + case "create-issue": + return 1; + case "add-comment": + return 1; + case "create-pull-request": + return 1; + case "create-pull-request-review-comment": + return 1; + case "add-labels": + return 5; + case "update-issue": + return 1; + case "push-to-pull-request-branch": + return 1; + case "create-discussion": + return 1; + case "missing-tool": + return 1000; + case "create-code-scanning-alert": + return 1000; + case "upload-asset": + return 10; + default: + return 1; + } } - const lines = sanitized.split("\n"); - const maxLines = 65000; - if (lines.length > maxLines) { - sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; + } + return 0; } - sanitized = neutralizeBotTriggers(sanitized); - return sanitized.trim(); - function sanitizeUrlDomains(s) { - return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { - const urlAfterProtocol = match.slice(8); - const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); - const isAllowed = allowedDomains.some(allowedDomain => { - const normalizedAllowed = allowedDomain.toLowerCase(); - return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); }); - return isAllowed ? match : "(redacted)"; - }); - } - function sanitizeUrlProtocols(s) { - return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { - return protocol.toLowerCase() === "https" ? match : "(redacted)"; - }); - } - function neutralizeMentions(s) { - return s.replace( - /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, - (_m, p1, p2) => `${p1}\`@${p2}\`` - ); - } - function removeXmlComments(s) { - return s.replace(//g, "").replace(//g, ""); - } - function neutralizeBotTriggers(s) { - return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); - } - } - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create-issue": - return 1; - case "add-comment": - return 1; - case "create-pull-request": - return 1; - case "create-pull-request-review-comment": - return 1; - case "add-labels": - return 5; - case "update-issue": - return 1; - case "push-to-pull-request-branch": - return 1; - case "create-discussion": - return 1; - case "missing-tool": - return 1000; - case "create-code-scanning-alert": - return 1000; - case "upload-asset": - return 10; - default: - return 1; - } - } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; - } - return 0; - } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; - } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } + else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } + else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; } - return { isValid: true, normalizedValue: parsed }; - } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { + function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { + if (inputSchema.required && (value === undefined || value === null)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (value === undefined || value === null) { + return { + isValid: true, + normalizedValue: inputSchema.default || undefined, + }; + } + const inputType = inputSchema.type || "string"; + let normalizedValue = value; + switch (inputType) { + case "string": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string`, + }; + } + normalizedValue = sanitizeContent(value); + break; + case "boolean": + if (typeof value !== "boolean") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a boolean`, + }; + } + break; + case "number": + if (typeof value !== "number") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number`, + }; + } + break; + case "choice": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, + }; + } + if (inputSchema.options && !inputSchema.options.includes(value)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, + }; + } + normalizedValue = sanitizeContent(value); + break; + default: + if (typeof value === "string") { + normalizedValue = sanitizeContent(value); + } + break; + } return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, + isValid: true, + normalizedValue, }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { + function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { + const errors = []; + const normalizedItem = { ...item }; + if (!jobConfig.inputs) { + return { + isValid: true, + errors: [], + normalizedItem: item, + }; + } + for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { + const fieldValue = item[fieldName]; + const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); + if (!validation.isValid && validation.error) { + errors.push(validation.error); + } + else if (validation.normalizedValue !== undefined) { + normalizedItem[fieldName] = validation.normalizedValue; + } + } return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, + isValid: errors.length === 0, + errors, + normalizedItem, }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; - } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + function parseJsonWithRepair(jsonStr) { + try { + return JSON.parse(jsonStr); + } + catch (originalError) { + try { + const repairedJson = repairJson(jsonStr); + return JSON.parse(repairedJson); + } + catch (repairError) { + core.info(`invalid input json: ${jsonStr}`); + const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); + const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); + throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); + } + } } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; + const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; + if (!outputFile) { + core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); + core.setOutput("output", ""); + return; } - return { isValid: true }; - } - function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { - if (inputSchema.required && (value === undefined || value === null)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; + if (!fs.existsSync(outputFile)) { + core.info(`Output file does not exist: ${outputFile}`); + core.setOutput("output", ""); + return; } - if (value === undefined || value === null) { - return { - isValid: true, - normalizedValue: inputSchema.default || undefined, - }; + const outputContent = fs.readFileSync(outputFile, "utf8"); + if (outputContent.trim() === "") { + core.info("Output file is empty"); } - const inputType = inputSchema.type || "string"; - let normalizedValue = value; - switch (inputType) { - case "string": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string`, - }; + core.info(`Raw output content length: ${outputContent.length}`); + let expectedOutputTypes = {}; + if (safeOutputsConfig) { + try { + expectedOutputTypes = JSON.parse(safeOutputsConfig); + core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); } - normalizedValue = sanitizeContent(value); - break; - case "boolean": - if (typeof value !== "boolean") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a boolean`, - }; - } - break; - case "number": - if (typeof value !== "number") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number`, - }; + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); } - break; - case "choice": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, - }; - } - if (inputSchema.options && !inputSchema.options.includes(value)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, - }; - } - normalizedValue = sanitizeContent(value); - break; - default: - if (typeof value === "string") { - normalizedValue = sanitizeContent(value); - } - break; } - return { - isValid: true, - normalizedValue, - }; - } - function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { + const lines = outputContent.trim().split("\n"); + const parsedItems = []; const errors = []; - const normalizedItem = { ...item }; - if (!jobConfig.inputs) { - return { - isValid: true, - errors: [], - normalizedItem: item, - }; - } - for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { - const fieldValue = item[fieldName]; - const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); - if (!validation.isValid && validation.error) { - errors.push(validation.error); - } else if (validation.normalizedValue !== undefined) { - normalizedItem[fieldName] = validation.normalizedValue; - } - } - return { - isValid: errors.length === 0, - errors, - normalizedItem, - }; - } - function parseJsonWithRepair(jsonStr) { - try { - return JSON.parse(jsonStr); - } catch (originalError) { - try { - const repairedJson = repairJson(jsonStr); - return JSON.parse(repairedJson); - } catch (repairError) { - core.info(`invalid input json: ${jsonStr}`); - const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); - const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); - throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); - } - } - } - const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; - const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; - if (!outputFile) { - core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); - core.setOutput("output", ""); - return; - } - if (!fs.existsSync(outputFile)) { - core.info(`Output file does not exist: ${outputFile}`); - core.setOutput("output", ""); - return; - } - const outputContent = fs.readFileSync(outputFile, "utf8"); - if (outputContent.trim() === "") { - core.info("Output file is empty"); - } - core.info(`Raw output content length: ${outputContent.length}`); - let expectedOutputTypes = {}; - if (safeOutputsConfig) { - try { - expectedOutputTypes = JSON.parse(safeOutputsConfig); - core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); - } - } - const lines = outputContent.trim().split("\n"); - const parsedItems = []; - const errors = []; - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); - if (line === "") continue; - try { - const item = parseJsonWithRepair(line); - if (item === undefined) { - errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); - continue; - } - if (!item.type) { - errors.push(`Line ${i + 1}: Missing required 'type' field`); - continue; - } - const itemType = item.type; - if (!expectedOutputTypes[itemType]) { - errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); - continue; - } - const typeCount = parsedItems.filter(existing => existing.type === itemType).length; - const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); - if (typeCount >= maxAllowed) { - errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); - continue; - } - core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create-issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); - if (!issueNumValidation.isValid) { - if (issueNumValidation.error) errors.push(issueNumValidation.error); - continue; - } - item.body = sanitizeContent(item.body); - break; - case "create-pull-request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - item.branch = sanitizeContent(item.branch); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); - if (!labelsIssueNumValidation.isValid) { - if (labelsIssueNumValidation.error) errors.push(labelsIssueNumValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label)); - break; - case "update-issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line === "") continue; - } - break; - case "push-to-pull-request-branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch); - item.message = sanitizeContent(item.message); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push-to-pull-request-branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create-pull-request-review-comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create-pull-request-review-comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; + try { + const item = parseJsonWithRepair(line); + if (item === undefined) { + errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); + continue; } - } - break; - case "create-discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; + if (!item.type) { + errors.push(`Line ${i + 1}: Missing required 'type' field`); + continue; } - item.category = sanitizeContent(item.category); - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - break; - case "missing-tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool); - item.reason = sanitizeContent(item.reason); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); - continue; + const itemType = item.type; + if (!expectedOutputTypes[itemType]) { + errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); + continue; } - item.alternatives = sanitizeContent(item.alternatives); - } - break; - case "upload-asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "create-code-scanning-alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); - continue; + const typeCount = parsedItems.filter(existing => existing.type === itemType).length; + const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); + if (typeCount >= maxAllowed) { + errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); + continue; } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; + core.info(`Line ${i + 1}: type '${itemType}'`); + switch (itemType) { + case "create-issue": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-comment": + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); + continue; + } + const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); + if (!issueNumValidation.isValid) { + if (issueNumValidation.error) + errors.push(issueNumValidation.error); + continue; + } + item.body = sanitizeContent(item.body); + break; + case "create-pull-request": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); + continue; + } + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + item.branch = sanitizeContent(item.branch); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-labels": + if (!item.labels || !Array.isArray(item.labels)) { + errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); + continue; + } + if (item.labels.some((label) => typeof label !== "string")) { + errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); + continue; + } + const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); + if (!labelsIssueNumValidation.isValid) { + if (labelsIssueNumValidation.error) + errors.push(labelsIssueNumValidation.error); + continue; + } + item.labels = item.labels.map((label) => sanitizeContent(label)); + break; + case "update-issue": + const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; + if (!hasValidField) { + errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); + continue; + } + if (item.status !== undefined) { + if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { + errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); + continue; + } + } + if (item.title !== undefined) { + if (typeof item.title !== "string") { + errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); + continue; + } + item.title = sanitizeContent(item.title); + } + if (item.body !== undefined) { + if (typeof item.body !== "string") { + errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); + continue; + } + item.body = sanitizeContent(item.body); + } + const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); + if (!updateIssueNumValidation.isValid) { + if (updateIssueNumValidation.error) + errors.push(updateIssueNumValidation.error); + continue; + } + break; + case "push-to-pull-request-branch": + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); + continue; + } + item.branch = sanitizeContent(item.branch); + item.message = sanitizeContent(item.message); + const pushPRNumValidation = validateIssueOrPRNumber(item.pull_request_number, "push-to-pull-request-branch 'pull_request_number'", i + 1); + if (!pushPRNumValidation.isValid) { + if (pushPRNumValidation.error) + errors.push(pushPRNumValidation.error); + continue; + } + break; + case "create-pull-request-review-comment": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); + continue; + } + const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); + if (!lineValidation.isValid) { + if (lineValidation.error) + errors.push(lineValidation.error); + continue; + } + const lineNumber = lineValidation.normalizedValue; + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); + continue; + } + item.body = sanitizeContent(item.body); + const startLineValidation = validateOptionalPositiveInteger(item.start_line, "create-pull-request-review-comment 'start_line'", i + 1); + if (!startLineValidation.isValid) { + if (startLineValidation.error) + errors.push(startLineValidation.error); + continue; + } + if (startLineValidation.normalizedValue !== undefined && + lineNumber !== undefined && + startLineValidation.normalizedValue > lineNumber) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); + continue; + } + if (item.side !== undefined) { + if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); + continue; + } + } + break; + case "create-discussion": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); + continue; + } + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); + continue; + } + item.category = sanitizeContent(item.category); + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + break; + case "missing-tool": + if (!item.tool || typeof item.tool !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); + continue; + } + if (!item.reason || typeof item.reason !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); + continue; + } + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); + continue; + } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "upload-asset": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); + continue; + } + break; + case "create-code-scanning-alert": + if (!item.file || typeof item.file !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); + continue; + } + const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); + if (!alertLineValidation.isValid) { + if (alertLineValidation.error) { + errors.push(alertLineValidation.error); + } + continue; + } + if (!item.severity || typeof item.severity !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); + continue; + } + const allowedSeverities = ["error", "warning", "info", "note"]; + if (!allowedSeverities.includes(item.severity.toLowerCase())) { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}`); + continue; + } + const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); + if (!columnValidation.isValid) { + if (columnValidation.error) + errors.push(columnValidation.error); + continue; + } + if (item.ruleIdSuffix !== undefined) { + if (typeof item.ruleIdSuffix !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); + continue; + } + if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores`); + continue; + } + } + item.severity = item.severity.toLowerCase(); + item.file = sanitizeContent(item.file); + item.severity = sanitizeContent(item.severity); + item.message = sanitizeContent(item.message); + if (item.ruleIdSuffix) { + item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); + } + break; + default: + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); + continue; + } + Object.assign(item, validation.normalizedItem); + } + break; } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file); - item.severity = sanitizeContent(item.severity); - item.message = sanitizeContent(item.message); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); - continue; - } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; + core.info(`Line ${i + 1}: Valid ${itemType} item`); + parsedItems.push(item); + } + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); + } + } + if (errors.length > 0) { + core.warning("Validation errors found:"); + errors.forEach(error => core.warning(` - ${error}`)); + if (parsedItems.length === 0) { + core.setFailed(errors.map(e => ` - ${e}`).join("\n")); + return; + } + } + for (const itemType of Object.keys(expectedOutputTypes)) { + const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); + if (minRequired > 0) { + const actualCount = parsedItems.filter(item => item.type === itemType).length; + if (actualCount < minRequired) { + errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); } - Object.assign(item, validation.normalizedItem); - } - break; - } - core.info(`Line ${i + 1}: Valid ${itemType} item`); - parsedItems.push(item); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); + } } - } - if (errors.length > 0) { - core.warning("Validation errors found:"); - errors.forEach(error => core.warning(` - ${error}`)); - if (parsedItems.length === 0) { - core.setFailed(errors.map(e => ` - ${e}`).join("\n")); - return; + core.info(`Successfully parsed ${parsedItems.length} valid output items`); + const validatedOutput = { + items: parsedItems, + errors: errors, + }; + const agentOutputFile = "/tmp/agent_output.json"; + const validatedOutputJson = JSON.stringify(validatedOutput); + try { + fs.mkdirSync("/tmp", { recursive: true }); + fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); + core.info(`Stored validated output to: ${agentOutputFile}`); + core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); } - } - for (const itemType of Object.keys(expectedOutputTypes)) { - const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); - if (minRequired > 0) { - const actualCount = parsedItems.filter(item => item.type === itemType).length; - if (actualCount < minRequired) { - errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); - } + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.error(`Failed to write agent output file: ${errorMsg}`); + } + core.setOutput("output", JSON.stringify(validatedOutput)); + core.setOutput("raw_output", outputContent); + const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); + core.info(`output_types: ${outputTypes.join(", ")}`); + core.setOutput("output_types", outputTypes.join(",")); + try { + await core.summary + .addRaw("## Processed Output\n\n") + .addRaw("```json\n") + .addRaw(JSON.stringify(validatedOutput)) + .addRaw("\n```\n") + .write(); + core.info("Successfully wrote processed output to step summary"); + } + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.warning(`Failed to write to step summary: ${errorMsg}`); } - } - core.info(`Successfully parsed ${parsedItems.length} valid output items`); - const validatedOutput = { - items: parsedItems, - errors: errors, - }; - const agentOutputFile = "/tmp/agent_output.json"; - const validatedOutputJson = JSON.stringify(validatedOutput); - try { - fs.mkdirSync("/tmp", { recursive: true }); - fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); - core.info(`Stored validated output to: ${agentOutputFile}`); - core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.error(`Failed to write agent output file: ${errorMsg}`); - } - core.setOutput("output", JSON.stringify(validatedOutput)); - core.setOutput("raw_output", outputContent); - const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); - core.info(`output_types: ${outputTypes.join(", ")}`); - core.setOutput("output_types", outputTypes.join(",")); - try { - await core.summary - .addRaw("## Processed Output\n\n") - .addRaw("```json\n") - .addRaw(JSON.stringify(validatedOutput)) - .addRaw("\n```\n") - .write(); - core.info("Successfully wrote processed output to step summary"); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.warning(`Failed to write to step summary: ${errorMsg}`); - } } await main(); - name: Upload sanitized agent output @@ -2266,200 +2266,203 @@ jobs: with: script: | function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace( - /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, - (_m, p1, p2) => `${p1}\`@${p2}\`` - ); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); + if (!content || typeof content !== "string") { + return ""; + } + let sanitized = content.trim(); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); + sanitized = sanitized.replace(/[<>&'"]/g, ""); + return sanitized.trim(); } async function main() { - const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; - if (!outputContent) { - core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); - return; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return; - } - core.debug(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.warning("No valid items found in agent output"); - return; - } - const labelsItem = validatedOutput.items.find(item => item.type === "add-labels"); - if (!labelsItem) { - core.warning("No add-labels item found in agent output"); - return; - } - core.debug(`Found add-labels item with ${labelsItem.labels.length} labels`); - if (process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Add Labels Preview\n\n"; - summaryContent += "The following labels would be added if staged mode was disabled:\n\n"; - if (labelsItem.issue_number) { - summaryContent += `**Target Issue:** #${labelsItem.issue_number}\n\n`; - } else { - summaryContent += `**Target:** Current issue/PR\n\n`; + const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; + if (!outputContent) { + core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); + return; } - if (labelsItem.labels && labelsItem.labels.length > 0) { - summaryContent += `**Labels to add:** ${labelsItem.labels.join(", ")}\n\n`; + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return; } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Label addition preview written to step summary"); - return; - } - const allowedLabelsEnv = process.env.GITHUB_AW_LABELS_ALLOWED?.trim(); - const allowedLabels = allowedLabelsEnv - ? allowedLabelsEnv - .split(",") - .map(label => label.trim()) - .filter(label => label) - : undefined; - if (allowedLabels) { - core.debug(`Allowed labels: ${JSON.stringify(allowedLabels)}`); - } else { - core.debug("No label restrictions - any labels are allowed"); - } - const maxCountEnv = process.env.GITHUB_AW_LABELS_MAX_COUNT; - const maxCount = maxCountEnv ? parseInt(maxCountEnv, 10) : 3; - if (isNaN(maxCount) || maxCount < 1) { - core.setFailed(`Invalid max value: ${maxCountEnv}. Must be a positive integer`); - return; - } - core.debug(`Max count: ${maxCount}`); - const labelsTarget = process.env.GITHUB_AW_LABELS_TARGET || "triggering"; - core.info(`Labels target configuration: ${labelsTarget}`); - const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; - const isPRContext = - context.eventName === "pull_request" || - context.eventName === "pull_request_review" || - context.eventName === "pull_request_review_comment"; - if (labelsTarget === "triggering" && !isIssueContext && !isPRContext) { - core.info('Target is "triggering" but not running in issue or pull request context, skipping label addition'); - return; - } - let issueNumber; - let contextType; - if (labelsTarget === "*") { - if (labelsItem.issue_number) { - issueNumber = typeof labelsItem.issue_number === "number" ? labelsItem.issue_number : parseInt(String(labelsItem.issue_number), 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - core.setFailed(`Invalid issue number specified: ${labelsItem.issue_number}`); + core.debug(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } + catch (error) { + core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); return; - } - contextType = "issue"; - } else { - core.setFailed('Target is "*" but no issue_number specified in labels item'); - return; } - } else if (labelsTarget && labelsTarget !== "triggering") { - issueNumber = parseInt(labelsTarget, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - core.setFailed(`Invalid issue number in target configuration: ${labelsTarget}`); - return; + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.warning("No valid items found in agent output"); + return; } - contextType = "issue"; - } else { - if (isIssueContext) { - if (context.payload.issue) { - issueNumber = context.payload.issue.number; - contextType = "issue"; - } else { - core.setFailed("Issue context detected but no issue found in payload"); + const labelsItem = validatedOutput.items.find(item => item.type === "add-labels"); + if (!labelsItem) { + core.warning("No add-labels item found in agent output"); return; - } - } else if (isPRContext) { - if (context.payload.pull_request) { - issueNumber = context.payload.pull_request.number; - contextType = "pull request"; - } else { - core.setFailed("Pull request context detected but no pull request found in payload"); + } + core.debug(`Found add-labels item with ${labelsItem.labels.length} labels`); + if (process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true") { + let summaryContent = "## 🎭 Staged Mode: Add Labels Preview\n\n"; + summaryContent += "The following labels would be added if staged mode was disabled:\n\n"; + if (labelsItem.issue_number) { + summaryContent += `**Target Issue:** #${labelsItem.issue_number}\n\n`; + } + else { + summaryContent += `**Target:** Current issue/PR\n\n`; + } + if (labelsItem.labels && labelsItem.labels.length > 0) { + summaryContent += `**Labels to add:** ${labelsItem.labels.join(", ")}\n\n`; + } + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Label addition preview written to step summary"); return; - } } - } - if (!issueNumber) { - core.setFailed("Could not determine issue or pull request number"); - return; - } - const requestedLabels = labelsItem.labels || []; - core.debug(`Requested labels: ${JSON.stringify(requestedLabels)}`); - for (const label of requestedLabels) { - if (label && typeof label === "string" && label.startsWith("-")) { - core.setFailed(`Label removal is not permitted. Found line starting with '-': ${label}`); - return; + const allowedLabelsEnv = process.env.GITHUB_AW_LABELS_ALLOWED?.trim(); + const allowedLabels = allowedLabelsEnv + ? allowedLabelsEnv + .split(",") + .map(label => label.trim()) + .filter(label => label) + : undefined; + if (allowedLabels) { + core.debug(`Allowed labels: ${JSON.stringify(allowedLabels)}`); } - } - let validLabels; - if (allowedLabels) { - validLabels = requestedLabels.filter(label => allowedLabels.includes(label)); - } else { - validLabels = requestedLabels; - } - let uniqueLabels = validLabels - .filter(label => label != null && label !== false && label !== 0) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - if (uniqueLabels.length > maxCount) { - core.debug(`too many labels, keep ${maxCount}`); - uniqueLabels = uniqueLabels.slice(0, maxCount); - } - if (uniqueLabels.length === 0) { - core.info("No labels to add"); - core.setOutput("labels_added", ""); - await core.summary - .addRaw( - ` + else { + core.debug("No label restrictions - any labels are allowed"); + } + const maxCountEnv = process.env.GITHUB_AW_LABELS_MAX_COUNT; + const maxCount = maxCountEnv ? parseInt(maxCountEnv, 10) : 3; + if (isNaN(maxCount) || maxCount < 1) { + core.setFailed(`Invalid max value: ${maxCountEnv}. Must be a positive integer`); + return; + } + core.debug(`Max count: ${maxCount}`); + const labelsTarget = process.env.GITHUB_AW_LABELS_TARGET || "triggering"; + core.info(`Labels target configuration: ${labelsTarget}`); + const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; + const isPRContext = context.eventName === "pull_request" || + context.eventName === "pull_request_review" || + context.eventName === "pull_request_review_comment"; + if (labelsTarget === "triggering" && !isIssueContext && !isPRContext) { + core.info('Target is "triggering" but not running in issue or pull request context, skipping label addition'); + return; + } + let issueNumber; + let contextType; + if (labelsTarget === "*") { + if (labelsItem.issue_number) { + issueNumber = typeof labelsItem.issue_number === "number" ? labelsItem.issue_number : parseInt(String(labelsItem.issue_number), 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + core.setFailed(`Invalid issue number specified: ${labelsItem.issue_number}`); + return; + } + contextType = "issue"; + } + else { + core.setFailed('Target is "*" but no issue_number specified in labels item'); + return; + } + } + else if (labelsTarget && labelsTarget !== "triggering") { + issueNumber = parseInt(labelsTarget, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + core.setFailed(`Invalid issue number in target configuration: ${labelsTarget}`); + return; + } + contextType = "issue"; + } + else { + if (isIssueContext) { + if (context.payload.issue) { + issueNumber = context.payload.issue.number; + contextType = "issue"; + } + else { + core.setFailed("Issue context detected but no issue found in payload"); + return; + } + } + else if (isPRContext) { + if (context.payload.pull_request) { + issueNumber = context.payload.pull_request.number; + contextType = "pull request"; + } + else { + core.setFailed("Pull request context detected but no pull request found in payload"); + return; + } + } + } + if (!issueNumber) { + core.setFailed("Could not determine issue or pull request number"); + return; + } + const requestedLabels = labelsItem.labels || []; + core.debug(`Requested labels: ${JSON.stringify(requestedLabels)}`); + for (const label of requestedLabels) { + if (label && typeof label === "string" && label.startsWith("-")) { + core.setFailed(`Label removal is not permitted. Found line starting with '-': ${label}`); + return; + } + } + let validLabels; + if (allowedLabels) { + validLabels = requestedLabels.filter(label => allowedLabels.includes(label)); + } + else { + validLabels = requestedLabels; + } + let uniqueLabels = validLabels + .filter(label => label != null && label !== false && label !== 0) + .map(label => String(label).trim()) + .filter(label => label) + .map(label => sanitizeLabelContent(label)) + .filter(label => label) + .map(label => (label.length > 64 ? label.substring(0, 64) : label)) + .filter((label, index, arr) => arr.indexOf(label) === index); + if (uniqueLabels.length > maxCount) { + core.debug(`too many labels, keep ${maxCount}`); + uniqueLabels = uniqueLabels.slice(0, maxCount); + } + if (uniqueLabels.length === 0) { + core.info("No labels to add"); + core.setOutput("labels_added", ""); + await core.summary + .addRaw(` ## Label Addition No labels were added (no valid labels found in agent output). - ` - ) - .write(); - return; - } - core.info(`Adding ${uniqueLabels.length} labels to ${contextType} #${issueNumber}: ${JSON.stringify(uniqueLabels)}`); - try { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - labels: uniqueLabels, - }); - core.info(`Successfully added ${uniqueLabels.length} labels to ${contextType} #${issueNumber}`); - core.setOutput("labels_added", uniqueLabels.join("\n")); - const labelsListMarkdown = uniqueLabels.map(label => `- \`${label}\``).join("\n"); - await core.summary - .addRaw( - ` + `) + .write(); + return; + } + core.info(`Adding ${uniqueLabels.length} labels to ${contextType} #${issueNumber}: ${JSON.stringify(uniqueLabels)}`); + try { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + labels: uniqueLabels, + }); + core.info(`Successfully added ${uniqueLabels.length} labels to ${contextType} #${issueNumber}`); + core.setOutput("labels_added", uniqueLabels.join("\n")); + const labelsListMarkdown = uniqueLabels.map(label => `- \`${label}\``).join("\n"); + await core.summary + .addRaw(` ## Label Addition Successfully added ${uniqueLabels.length} label(s) to ${contextType} #${issueNumber}: ${labelsListMarkdown} - ` - ) - .write(); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - core.error(`Failed to add labels: ${errorMessage}`); - core.setFailed(`Failed to add labels: ${errorMessage}`); - } + `) + .write(); + } + catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + core.error(`Failed to add labels: ${errorMessage}`); + core.setFailed(`Failed to add labels: ${errorMessage}`); + } } await main(); diff --git a/.github/workflows/pdf-summary.lock.yml b/.github/workflows/pdf-summary.lock.yml index 2f79934bf67..5c88ce1d3d6 100644 --- a/.github/workflows/pdf-summary.lock.yml +++ b/.github/workflows/pdf-summary.lock.yml @@ -1623,742 +1623,742 @@ jobs: with: script: | async function main() { - const fs = require("fs"); - function sanitizeContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; - const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; - const allowedDomains = allowedDomainsEnv - ? allowedDomainsEnv - .split(",") - .map(d => d.trim()) - .filter(d => d) - : defaultAllowedDomains; - let sanitized = content; - sanitized = neutralizeMentions(sanitized); - sanitized = removeXmlComments(sanitized); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitizeUrlProtocols(sanitized); - sanitized = sanitizeUrlDomains(sanitized); - const maxLength = 524288; - if (sanitized.length > maxLength) { - sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; - } - const lines = sanitized.split("\n"); - const maxLines = 65000; - if (lines.length > maxLines) { - sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; - } - sanitized = neutralizeBotTriggers(sanitized); - return sanitized.trim(); - function sanitizeUrlDomains(s) { - return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { - const urlAfterProtocol = match.slice(8); - const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); - const isAllowed = allowedDomains.some(allowedDomain => { - const normalizedAllowed = allowedDomain.toLowerCase(); - return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); - }); - return isAllowed ? match : "(redacted)"; - }); - } - function sanitizeUrlProtocols(s) { - return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { - return protocol.toLowerCase() === "https" ? match : "(redacted)"; - }); - } - function neutralizeMentions(s) { - return s.replace( - /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, - (_m, p1, p2) => `${p1}\`@${p2}\`` - ); - } - function removeXmlComments(s) { - return s.replace(//g, "").replace(//g, ""); - } - function neutralizeBotTriggers(s) { - return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); - } - } - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create-issue": - return 1; - case "add-comment": - return 1; - case "create-pull-request": - return 1; - case "create-pull-request-review-comment": - return 1; - case "add-labels": - return 5; - case "update-issue": - return 1; - case "push-to-pull-request-branch": - return 1; - case "create-discussion": - return 1; - case "missing-tool": - return 1000; - case "create-code-scanning-alert": - return 1000; - case "upload-asset": - return 10; - default: - return 1; - } - } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; - } - return 0; - } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; - } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; + const fs = require("fs"); + function sanitizeContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; + const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; + const allowedDomains = allowedDomainsEnv + ? allowedDomainsEnv + .split(",") + .map(d => d.trim()) + .filter(d => d) + : defaultAllowedDomains; + let sanitized = content; + sanitized = neutralizeMentions(sanitized); + sanitized = removeXmlComments(sanitized); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitizeUrlProtocols(sanitized); + sanitized = sanitizeUrlDomains(sanitized); + const maxLength = 524288; + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; + } + const lines = sanitized.split("\n"); + const maxLines = 65000; + if (lines.length > maxLines) { + sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; + } + sanitized = neutralizeBotTriggers(sanitized); + return sanitized.trim(); + function sanitizeUrlDomains(s) { + return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { + const urlAfterProtocol = match.slice(8); + const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); + const isAllowed = allowedDomains.some(allowedDomain => { + const normalizedAllowed = allowedDomain.toLowerCase(); + return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); + }); + return isAllowed ? match : "(redacted)"; + }); + } + function sanitizeUrlProtocols(s) { + return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { + return protocol.toLowerCase() === "https" ? match : "(redacted)"; + }); + } + function neutralizeMentions(s) { + return s.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); + } + function removeXmlComments(s) { + return s.replace(//g, "").replace(//g, ""); + } + function neutralizeBotTriggers(s) { + return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); + } } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + switch (itemType) { + case "create-issue": + return 1; + case "add-comment": + return 1; + case "create-pull-request": + return 1; + case "create-pull-request-review-comment": + return 1; + case "add-labels": + return 5; + case "update-issue": + return 1; + case "push-to-pull-request-branch": + return 1; + case "create-discussion": + return 1; + case "missing-tool": + return 1000; + case "create-code-scanning-alert": + return 1000; + case "upload-asset": + return 10; + default: + return 1; + } } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; + } + return 0; + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } + else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } + else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; } - return { isValid: true, normalizedValue: parsed }; - } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { + if (inputSchema.required && (value === undefined || value === null)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (value === undefined || value === null) { + return { + isValid: true, + normalizedValue: inputSchema.default || undefined, + }; + } + const inputType = inputSchema.type || "string"; + let normalizedValue = value; + switch (inputType) { + case "string": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string`, + }; + } + normalizedValue = sanitizeContent(value); + break; + case "boolean": + if (typeof value !== "boolean") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a boolean`, + }; + } + break; + case "number": + if (typeof value !== "number") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number`, + }; + } + break; + case "choice": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, + }; + } + if (inputSchema.options && !inputSchema.options.includes(value)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, + }; + } + normalizedValue = sanitizeContent(value); + break; + default: + if (typeof value === "string") { + normalizedValue = sanitizeContent(value); + } + break; + } return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, + isValid: true, + normalizedValue, }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { + function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { + const errors = []; + const normalizedItem = { ...item }; + if (!jobConfig.inputs) { + return { + isValid: true, + errors: [], + normalizedItem: item, + }; + } + for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { + const fieldValue = item[fieldName]; + const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); + if (!validation.isValid && validation.error) { + errors.push(validation.error); + } + else if (validation.normalizedValue !== undefined) { + normalizedItem[fieldName] = validation.normalizedValue; + } + } return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, + isValid: errors.length === 0, + errors, + normalizedItem, }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; - } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + function parseJsonWithRepair(jsonStr) { + try { + return JSON.parse(jsonStr); + } + catch (originalError) { + try { + const repairedJson = repairJson(jsonStr); + return JSON.parse(repairedJson); + } + catch (repairError) { + core.info(`invalid input json: ${jsonStr}`); + const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); + const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); + throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); + } + } } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; + const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; + if (!outputFile) { + core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); + core.setOutput("output", ""); + return; } - return { isValid: true }; - } - function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { - if (inputSchema.required && (value === undefined || value === null)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; + if (!fs.existsSync(outputFile)) { + core.info(`Output file does not exist: ${outputFile}`); + core.setOutput("output", ""); + return; } - if (value === undefined || value === null) { - return { - isValid: true, - normalizedValue: inputSchema.default || undefined, - }; + const outputContent = fs.readFileSync(outputFile, "utf8"); + if (outputContent.trim() === "") { + core.info("Output file is empty"); } - const inputType = inputSchema.type || "string"; - let normalizedValue = value; - switch (inputType) { - case "string": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string`, - }; - } - normalizedValue = sanitizeContent(value); - break; - case "boolean": - if (typeof value !== "boolean") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a boolean`, - }; + core.info(`Raw output content length: ${outputContent.length}`); + let expectedOutputTypes = {}; + if (safeOutputsConfig) { + try { + expectedOutputTypes = JSON.parse(safeOutputsConfig); + core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); } - break; - case "number": - if (typeof value !== "number") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number`, - }; + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); } - break; - case "choice": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, - }; - } - if (inputSchema.options && !inputSchema.options.includes(value)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, - }; - } - normalizedValue = sanitizeContent(value); - break; - default: - if (typeof value === "string") { - normalizedValue = sanitizeContent(value); - } - break; } - return { - isValid: true, - normalizedValue, - }; - } - function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { + const lines = outputContent.trim().split("\n"); + const parsedItems = []; const errors = []; - const normalizedItem = { ...item }; - if (!jobConfig.inputs) { - return { - isValid: true, - errors: [], - normalizedItem: item, - }; - } - for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { - const fieldValue = item[fieldName]; - const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); - if (!validation.isValid && validation.error) { - errors.push(validation.error); - } else if (validation.normalizedValue !== undefined) { - normalizedItem[fieldName] = validation.normalizedValue; - } - } - return { - isValid: errors.length === 0, - errors, - normalizedItem, - }; - } - function parseJsonWithRepair(jsonStr) { - try { - return JSON.parse(jsonStr); - } catch (originalError) { - try { - const repairedJson = repairJson(jsonStr); - return JSON.parse(repairedJson); - } catch (repairError) { - core.info(`invalid input json: ${jsonStr}`); - const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); - const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); - throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); - } - } - } - const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; - const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; - if (!outputFile) { - core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); - core.setOutput("output", ""); - return; - } - if (!fs.existsSync(outputFile)) { - core.info(`Output file does not exist: ${outputFile}`); - core.setOutput("output", ""); - return; - } - const outputContent = fs.readFileSync(outputFile, "utf8"); - if (outputContent.trim() === "") { - core.info("Output file is empty"); - } - core.info(`Raw output content length: ${outputContent.length}`); - let expectedOutputTypes = {}; - if (safeOutputsConfig) { - try { - expectedOutputTypes = JSON.parse(safeOutputsConfig); - core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); - } - } - const lines = outputContent.trim().split("\n"); - const parsedItems = []; - const errors = []; - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); - if (line === "") continue; - try { - const item = parseJsonWithRepair(line); - if (item === undefined) { - errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); - continue; - } - if (!item.type) { - errors.push(`Line ${i + 1}: Missing required 'type' field`); - continue; - } - const itemType = item.type; - if (!expectedOutputTypes[itemType]) { - errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); - continue; - } - const typeCount = parsedItems.filter(existing => existing.type === itemType).length; - const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); - if (typeCount >= maxAllowed) { - errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); - continue; - } - core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create-issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line === "") continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); - if (!issueNumValidation.isValid) { - if (issueNumValidation.error) errors.push(issueNumValidation.error); - continue; - } - item.body = sanitizeContent(item.body); - break; - case "create-pull-request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - item.branch = sanitizeContent(item.branch); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); - if (!labelsIssueNumValidation.isValid) { - if (labelsIssueNumValidation.error) errors.push(labelsIssueNumValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label)); - break; - case "update-issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "push-to-pull-request-branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch); - item.message = sanitizeContent(item.message); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push-to-pull-request-branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create-pull-request-review-comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create-pull-request-review-comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create-discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category); - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - break; - case "missing-tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool); - item.reason = sanitizeContent(item.reason); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); - continue; + try { + const item = parseJsonWithRepair(line); + if (item === undefined) { + errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); + continue; } - item.alternatives = sanitizeContent(item.alternatives); - } - break; - case "upload-asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "create-code-scanning-alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); + if (!item.type) { + errors.push(`Line ${i + 1}: Missing required 'type' field`); + continue; } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); - continue; + const itemType = item.type; + if (!expectedOutputTypes[itemType]) { + errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); + continue; } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; + const typeCount = parsedItems.filter(existing => existing.type === itemType).length; + const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); + if (typeCount >= maxAllowed) { + errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); + continue; } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file); - item.severity = sanitizeContent(item.severity); - item.message = sanitizeContent(item.message); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); - continue; - } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; + core.info(`Line ${i + 1}: type '${itemType}'`); + switch (itemType) { + case "create-issue": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-comment": + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); + continue; + } + const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); + if (!issueNumValidation.isValid) { + if (issueNumValidation.error) + errors.push(issueNumValidation.error); + continue; + } + item.body = sanitizeContent(item.body); + break; + case "create-pull-request": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); + continue; + } + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + item.branch = sanitizeContent(item.branch); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-labels": + if (!item.labels || !Array.isArray(item.labels)) { + errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); + continue; + } + if (item.labels.some((label) => typeof label !== "string")) { + errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); + continue; + } + const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); + if (!labelsIssueNumValidation.isValid) { + if (labelsIssueNumValidation.error) + errors.push(labelsIssueNumValidation.error); + continue; + } + item.labels = item.labels.map((label) => sanitizeContent(label)); + break; + case "update-issue": + const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; + if (!hasValidField) { + errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); + continue; + } + if (item.status !== undefined) { + if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { + errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); + continue; + } + } + if (item.title !== undefined) { + if (typeof item.title !== "string") { + errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); + continue; + } + item.title = sanitizeContent(item.title); + } + if (item.body !== undefined) { + if (typeof item.body !== "string") { + errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); + continue; + } + item.body = sanitizeContent(item.body); + } + const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); + if (!updateIssueNumValidation.isValid) { + if (updateIssueNumValidation.error) + errors.push(updateIssueNumValidation.error); + continue; + } + break; + case "push-to-pull-request-branch": + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); + continue; + } + item.branch = sanitizeContent(item.branch); + item.message = sanitizeContent(item.message); + const pushPRNumValidation = validateIssueOrPRNumber(item.pull_request_number, "push-to-pull-request-branch 'pull_request_number'", i + 1); + if (!pushPRNumValidation.isValid) { + if (pushPRNumValidation.error) + errors.push(pushPRNumValidation.error); + continue; + } + break; + case "create-pull-request-review-comment": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); + continue; + } + const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); + if (!lineValidation.isValid) { + if (lineValidation.error) + errors.push(lineValidation.error); + continue; + } + const lineNumber = lineValidation.normalizedValue; + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); + continue; + } + item.body = sanitizeContent(item.body); + const startLineValidation = validateOptionalPositiveInteger(item.start_line, "create-pull-request-review-comment 'start_line'", i + 1); + if (!startLineValidation.isValid) { + if (startLineValidation.error) + errors.push(startLineValidation.error); + continue; + } + if (startLineValidation.normalizedValue !== undefined && + lineNumber !== undefined && + startLineValidation.normalizedValue > lineNumber) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); + continue; + } + if (item.side !== undefined) { + if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); + continue; + } + } + break; + case "create-discussion": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); + continue; + } + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); + continue; + } + item.category = sanitizeContent(item.category); + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + break; + case "missing-tool": + if (!item.tool || typeof item.tool !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); + continue; + } + if (!item.reason || typeof item.reason !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); + continue; + } + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); + continue; + } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "upload-asset": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); + continue; + } + break; + case "create-code-scanning-alert": + if (!item.file || typeof item.file !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); + continue; + } + const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); + if (!alertLineValidation.isValid) { + if (alertLineValidation.error) { + errors.push(alertLineValidation.error); + } + continue; + } + if (!item.severity || typeof item.severity !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); + continue; + } + const allowedSeverities = ["error", "warning", "info", "note"]; + if (!allowedSeverities.includes(item.severity.toLowerCase())) { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}`); + continue; + } + const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); + if (!columnValidation.isValid) { + if (columnValidation.error) + errors.push(columnValidation.error); + continue; + } + if (item.ruleIdSuffix !== undefined) { + if (typeof item.ruleIdSuffix !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); + continue; + } + if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores`); + continue; + } + } + item.severity = item.severity.toLowerCase(); + item.file = sanitizeContent(item.file); + item.severity = sanitizeContent(item.severity); + item.message = sanitizeContent(item.message); + if (item.ruleIdSuffix) { + item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); + } + break; + default: + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); + continue; + } + Object.assign(item, validation.normalizedItem); + } + break; } - Object.assign(item, validation.normalizedItem); - } - break; - } - core.info(`Line ${i + 1}: Valid ${itemType} item`); - parsedItems.push(item); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); + core.info(`Line ${i + 1}: Valid ${itemType} item`); + parsedItems.push(item); + } + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); + } } - } - if (errors.length > 0) { - core.warning("Validation errors found:"); - errors.forEach(error => core.warning(` - ${error}`)); - if (parsedItems.length === 0) { - core.setFailed(errors.map(e => ` - ${e}`).join("\n")); - return; + if (errors.length > 0) { + core.warning("Validation errors found:"); + errors.forEach(error => core.warning(` - ${error}`)); + if (parsedItems.length === 0) { + core.setFailed(errors.map(e => ` - ${e}`).join("\n")); + return; + } } - } - for (const itemType of Object.keys(expectedOutputTypes)) { - const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); - if (minRequired > 0) { - const actualCount = parsedItems.filter(item => item.type === itemType).length; - if (actualCount < minRequired) { - errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); - } + for (const itemType of Object.keys(expectedOutputTypes)) { + const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); + if (minRequired > 0) { + const actualCount = parsedItems.filter(item => item.type === itemType).length; + if (actualCount < minRequired) { + errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); + } + } + } + core.info(`Successfully parsed ${parsedItems.length} valid output items`); + const validatedOutput = { + items: parsedItems, + errors: errors, + }; + const agentOutputFile = "/tmp/agent_output.json"; + const validatedOutputJson = JSON.stringify(validatedOutput); + try { + fs.mkdirSync("/tmp", { recursive: true }); + fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); + core.info(`Stored validated output to: ${agentOutputFile}`); + core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); + } + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.error(`Failed to write agent output file: ${errorMsg}`); + } + core.setOutput("output", JSON.stringify(validatedOutput)); + core.setOutput("raw_output", outputContent); + const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); + core.info(`output_types: ${outputTypes.join(", ")}`); + core.setOutput("output_types", outputTypes.join(",")); + try { + await core.summary + .addRaw("## Processed Output\n\n") + .addRaw("```json\n") + .addRaw(JSON.stringify(validatedOutput)) + .addRaw("\n```\n") + .write(); + core.info("Successfully wrote processed output to step summary"); + } + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.warning(`Failed to write to step summary: ${errorMsg}`); } - } - core.info(`Successfully parsed ${parsedItems.length} valid output items`); - const validatedOutput = { - items: parsedItems, - errors: errors, - }; - const agentOutputFile = "/tmp/agent_output.json"; - const validatedOutputJson = JSON.stringify(validatedOutput); - try { - fs.mkdirSync("/tmp", { recursive: true }); - fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); - core.info(`Stored validated output to: ${agentOutputFile}`); - core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.error(`Failed to write agent output file: ${errorMsg}`); - } - core.setOutput("output", JSON.stringify(validatedOutput)); - core.setOutput("raw_output", outputContent); - const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); - core.info(`output_types: ${outputTypes.join(", ")}`); - core.setOutput("output_types", outputTypes.join(",")); - try { - await core.summary - .addRaw("## Processed Output\n\n") - .addRaw("```json\n") - .addRaw(JSON.stringify(validatedOutput)) - .addRaw("\n```\n") - .write(); - core.info("Successfully wrote processed output to step summary"); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.warning(`Failed to write to step summary: ${errorMsg}`); - } } await main(); - name: Upload sanitized agent output diff --git a/.github/workflows/poem-bot.lock.yml b/.github/workflows/poem-bot.lock.yml index 59264ffc224..704c4d90cc9 100644 --- a/.github/workflows/poem-bot.lock.yml +++ b/.github/workflows/poem-bot.lock.yml @@ -1598,742 +1598,742 @@ jobs: with: script: | async function main() { - const fs = require("fs"); - function sanitizeContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; - const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; - const allowedDomains = allowedDomainsEnv - ? allowedDomainsEnv - .split(",") - .map(d => d.trim()) - .filter(d => d) - : defaultAllowedDomains; - let sanitized = content; - sanitized = neutralizeMentions(sanitized); - sanitized = removeXmlComments(sanitized); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitizeUrlProtocols(sanitized); - sanitized = sanitizeUrlDomains(sanitized); - const maxLength = 524288; - if (sanitized.length > maxLength) { - sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; - } - const lines = sanitized.split("\n"); - const maxLines = 65000; - if (lines.length > maxLines) { - sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; - } - sanitized = neutralizeBotTriggers(sanitized); - return sanitized.trim(); - function sanitizeUrlDomains(s) { - return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { - const urlAfterProtocol = match.slice(8); - const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); - const isAllowed = allowedDomains.some(allowedDomain => { - const normalizedAllowed = allowedDomain.toLowerCase(); - return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); - }); - return isAllowed ? match : "(redacted)"; - }); - } - function sanitizeUrlProtocols(s) { - return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { - return protocol.toLowerCase() === "https" ? match : "(redacted)"; - }); - } - function neutralizeMentions(s) { - return s.replace( - /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, - (_m, p1, p2) => `${p1}\`@${p2}\`` - ); - } - function removeXmlComments(s) { - return s.replace(//g, "").replace(//g, ""); - } - function neutralizeBotTriggers(s) { - return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); - } - } - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create-issue": - return 1; - case "add-comment": - return 1; - case "create-pull-request": - return 1; - case "create-pull-request-review-comment": - return 1; - case "add-labels": - return 5; - case "update-issue": - return 1; - case "push-to-pull-request-branch": - return 1; - case "create-discussion": - return 1; - case "missing-tool": - return 1000; - case "create-code-scanning-alert": - return 1000; - case "upload-asset": - return 10; - default: - return 1; - } - } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; - } - return 0; - } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; - } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; + const fs = require("fs"); + function sanitizeContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; + const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; + const allowedDomains = allowedDomainsEnv + ? allowedDomainsEnv + .split(",") + .map(d => d.trim()) + .filter(d => d) + : defaultAllowedDomains; + let sanitized = content; + sanitized = neutralizeMentions(sanitized); + sanitized = removeXmlComments(sanitized); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitizeUrlProtocols(sanitized); + sanitized = sanitizeUrlDomains(sanitized); + const maxLength = 524288; + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; + } + const lines = sanitized.split("\n"); + const maxLines = 65000; + if (lines.length > maxLines) { + sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; + } + sanitized = neutralizeBotTriggers(sanitized); + return sanitized.trim(); + function sanitizeUrlDomains(s) { + return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { + const urlAfterProtocol = match.slice(8); + const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); + const isAllowed = allowedDomains.some(allowedDomain => { + const normalizedAllowed = allowedDomain.toLowerCase(); + return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); + }); + return isAllowed ? match : "(redacted)"; + }); + } + function sanitizeUrlProtocols(s) { + return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { + return protocol.toLowerCase() === "https" ? match : "(redacted)"; + }); + } + function neutralizeMentions(s) { + return s.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); + } + function removeXmlComments(s) { + return s.replace(//g, "").replace(//g, ""); + } + function neutralizeBotTriggers(s) { + return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); + } } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + switch (itemType) { + case "create-issue": + return 1; + case "add-comment": + return 1; + case "create-pull-request": + return 1; + case "create-pull-request-review-comment": + return 1; + case "add-labels": + return 5; + case "update-issue": + return 1; + case "push-to-pull-request-branch": + return 1; + case "create-discussion": + return 1; + case "missing-tool": + return 1000; + case "create-code-scanning-alert": + return 1000; + case "upload-asset": + return 10; + default: + return 1; + } } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; + } + return 0; + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } + else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } + else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; } - return { isValid: true, normalizedValue: parsed }; - } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { + if (inputSchema.required && (value === undefined || value === null)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (value === undefined || value === null) { + return { + isValid: true, + normalizedValue: inputSchema.default || undefined, + }; + } + const inputType = inputSchema.type || "string"; + let normalizedValue = value; + switch (inputType) { + case "string": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string`, + }; + } + normalizedValue = sanitizeContent(value); + break; + case "boolean": + if (typeof value !== "boolean") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a boolean`, + }; + } + break; + case "number": + if (typeof value !== "number") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number`, + }; + } + break; + case "choice": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, + }; + } + if (inputSchema.options && !inputSchema.options.includes(value)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, + }; + } + normalizedValue = sanitizeContent(value); + break; + default: + if (typeof value === "string") { + normalizedValue = sanitizeContent(value); + } + break; + } return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, + isValid: true, + normalizedValue, }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { + function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { + const errors = []; + const normalizedItem = { ...item }; + if (!jobConfig.inputs) { + return { + isValid: true, + errors: [], + normalizedItem: item, + }; + } + for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { + const fieldValue = item[fieldName]; + const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); + if (!validation.isValid && validation.error) { + errors.push(validation.error); + } + else if (validation.normalizedValue !== undefined) { + normalizedItem[fieldName] = validation.normalizedValue; + } + } return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, + isValid: errors.length === 0, + errors, + normalizedItem, }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; - } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + function parseJsonWithRepair(jsonStr) { + try { + return JSON.parse(jsonStr); + } + catch (originalError) { + try { + const repairedJson = repairJson(jsonStr); + return JSON.parse(repairedJson); + } + catch (repairError) { + core.info(`invalid input json: ${jsonStr}`); + const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); + const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); + throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); + } + } } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; + const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; + if (!outputFile) { + core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); + core.setOutput("output", ""); + return; } - return { isValid: true }; - } - function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { - if (inputSchema.required && (value === undefined || value === null)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; + if (!fs.existsSync(outputFile)) { + core.info(`Output file does not exist: ${outputFile}`); + core.setOutput("output", ""); + return; } - if (value === undefined || value === null) { - return { - isValid: true, - normalizedValue: inputSchema.default || undefined, - }; + const outputContent = fs.readFileSync(outputFile, "utf8"); + if (outputContent.trim() === "") { + core.info("Output file is empty"); } - const inputType = inputSchema.type || "string"; - let normalizedValue = value; - switch (inputType) { - case "string": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string`, - }; - } - normalizedValue = sanitizeContent(value); - break; - case "boolean": - if (typeof value !== "boolean") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a boolean`, - }; - } - break; - case "number": - if (typeof value !== "number") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number`, - }; - } - break; - case "choice": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, - }; - } - if (inputSchema.options && !inputSchema.options.includes(value)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, - }; + core.info(`Raw output content length: ${outputContent.length}`); + let expectedOutputTypes = {}; + if (safeOutputsConfig) { + try { + expectedOutputTypes = JSON.parse(safeOutputsConfig); + core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); } - normalizedValue = sanitizeContent(value); - break; - default: - if (typeof value === "string") { - normalizedValue = sanitizeContent(value); + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); } - break; } - return { - isValid: true, - normalizedValue, - }; - } - function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { + const lines = outputContent.trim().split("\n"); + const parsedItems = []; const errors = []; - const normalizedItem = { ...item }; - if (!jobConfig.inputs) { - return { - isValid: true, - errors: [], - normalizedItem: item, - }; - } - for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { - const fieldValue = item[fieldName]; - const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); - if (!validation.isValid && validation.error) { - errors.push(validation.error); - } else if (validation.normalizedValue !== undefined) { - normalizedItem[fieldName] = validation.normalizedValue; - } - } - return { - isValid: errors.length === 0, - errors, - normalizedItem, - }; - } - function parseJsonWithRepair(jsonStr) { - try { - return JSON.parse(jsonStr); - } catch (originalError) { - try { - const repairedJson = repairJson(jsonStr); - return JSON.parse(repairedJson); - } catch (repairError) { - core.info(`invalid input json: ${jsonStr}`); - const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); - const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); - throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); - } - } - } - const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; - const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; - if (!outputFile) { - core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); - core.setOutput("output", ""); - return; - } - if (!fs.existsSync(outputFile)) { - core.info(`Output file does not exist: ${outputFile}`); - core.setOutput("output", ""); - return; - } - const outputContent = fs.readFileSync(outputFile, "utf8"); - if (outputContent.trim() === "") { - core.info("Output file is empty"); - } - core.info(`Raw output content length: ${outputContent.length}`); - let expectedOutputTypes = {}; - if (safeOutputsConfig) { - try { - expectedOutputTypes = JSON.parse(safeOutputsConfig); - core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); - } - } - const lines = outputContent.trim().split("\n"); - const parsedItems = []; - const errors = []; - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); - if (line === "") continue; - try { - const item = parseJsonWithRepair(line); - if (item === undefined) { - errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); - continue; - } - if (!item.type) { - errors.push(`Line ${i + 1}: Missing required 'type' field`); - continue; - } - const itemType = item.type; - if (!expectedOutputTypes[itemType]) { - errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); - continue; - } - const typeCount = parsedItems.filter(existing => existing.type === itemType).length; - const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); - if (typeCount >= maxAllowed) { - errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); - continue; - } - core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create-issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); - if (!issueNumValidation.isValid) { - if (issueNumValidation.error) errors.push(issueNumValidation.error); - continue; - } - item.body = sanitizeContent(item.body); - break; - case "create-pull-request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line === "") continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - item.branch = sanitizeContent(item.branch); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); - if (!labelsIssueNumValidation.isValid) { - if (labelsIssueNumValidation.error) errors.push(labelsIssueNumValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label)); - break; - case "update-issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "push-to-pull-request-branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch); - item.message = sanitizeContent(item.message); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push-to-pull-request-branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create-pull-request-review-comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create-pull-request-review-comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create-discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category); - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - break; - case "missing-tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool); - item.reason = sanitizeContent(item.reason); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); - continue; + try { + const item = parseJsonWithRepair(line); + if (item === undefined) { + errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); + continue; } - item.alternatives = sanitizeContent(item.alternatives); - } - break; - case "upload-asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "create-code-scanning-alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); + if (!item.type) { + errors.push(`Line ${i + 1}: Missing required 'type' field`); + continue; } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); - continue; + const itemType = item.type; + if (!expectedOutputTypes[itemType]) { + errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); + continue; } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; + const typeCount = parsedItems.filter(existing => existing.type === itemType).length; + const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); + if (typeCount >= maxAllowed) { + errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); + continue; } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file); - item.severity = sanitizeContent(item.severity); - item.message = sanitizeContent(item.message); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); - continue; - } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; + core.info(`Line ${i + 1}: type '${itemType}'`); + switch (itemType) { + case "create-issue": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-comment": + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); + continue; + } + const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); + if (!issueNumValidation.isValid) { + if (issueNumValidation.error) + errors.push(issueNumValidation.error); + continue; + } + item.body = sanitizeContent(item.body); + break; + case "create-pull-request": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); + continue; + } + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + item.branch = sanitizeContent(item.branch); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-labels": + if (!item.labels || !Array.isArray(item.labels)) { + errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); + continue; + } + if (item.labels.some((label) => typeof label !== "string")) { + errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); + continue; + } + const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); + if (!labelsIssueNumValidation.isValid) { + if (labelsIssueNumValidation.error) + errors.push(labelsIssueNumValidation.error); + continue; + } + item.labels = item.labels.map((label) => sanitizeContent(label)); + break; + case "update-issue": + const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; + if (!hasValidField) { + errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); + continue; + } + if (item.status !== undefined) { + if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { + errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); + continue; + } + } + if (item.title !== undefined) { + if (typeof item.title !== "string") { + errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); + continue; + } + item.title = sanitizeContent(item.title); + } + if (item.body !== undefined) { + if (typeof item.body !== "string") { + errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); + continue; + } + item.body = sanitizeContent(item.body); + } + const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); + if (!updateIssueNumValidation.isValid) { + if (updateIssueNumValidation.error) + errors.push(updateIssueNumValidation.error); + continue; + } + break; + case "push-to-pull-request-branch": + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); + continue; + } + item.branch = sanitizeContent(item.branch); + item.message = sanitizeContent(item.message); + const pushPRNumValidation = validateIssueOrPRNumber(item.pull_request_number, "push-to-pull-request-branch 'pull_request_number'", i + 1); + if (!pushPRNumValidation.isValid) { + if (pushPRNumValidation.error) + errors.push(pushPRNumValidation.error); + continue; + } + break; + case "create-pull-request-review-comment": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); + continue; + } + const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); + if (!lineValidation.isValid) { + if (lineValidation.error) + errors.push(lineValidation.error); + continue; + } + const lineNumber = lineValidation.normalizedValue; + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); + continue; + } + item.body = sanitizeContent(item.body); + const startLineValidation = validateOptionalPositiveInteger(item.start_line, "create-pull-request-review-comment 'start_line'", i + 1); + if (!startLineValidation.isValid) { + if (startLineValidation.error) + errors.push(startLineValidation.error); + continue; + } + if (startLineValidation.normalizedValue !== undefined && + lineNumber !== undefined && + startLineValidation.normalizedValue > lineNumber) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); + continue; + } + if (item.side !== undefined) { + if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); + continue; + } + } + break; + case "create-discussion": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); + continue; + } + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); + continue; + } + item.category = sanitizeContent(item.category); + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + break; + case "missing-tool": + if (!item.tool || typeof item.tool !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); + continue; + } + if (!item.reason || typeof item.reason !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); + continue; + } + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); + continue; + } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "upload-asset": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); + continue; + } + break; + case "create-code-scanning-alert": + if (!item.file || typeof item.file !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); + continue; + } + const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); + if (!alertLineValidation.isValid) { + if (alertLineValidation.error) { + errors.push(alertLineValidation.error); + } + continue; + } + if (!item.severity || typeof item.severity !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); + continue; + } + const allowedSeverities = ["error", "warning", "info", "note"]; + if (!allowedSeverities.includes(item.severity.toLowerCase())) { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}`); + continue; + } + const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); + if (!columnValidation.isValid) { + if (columnValidation.error) + errors.push(columnValidation.error); + continue; + } + if (item.ruleIdSuffix !== undefined) { + if (typeof item.ruleIdSuffix !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); + continue; + } + if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores`); + continue; + } + } + item.severity = item.severity.toLowerCase(); + item.file = sanitizeContent(item.file); + item.severity = sanitizeContent(item.severity); + item.message = sanitizeContent(item.message); + if (item.ruleIdSuffix) { + item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); + } + break; + default: + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); + continue; + } + Object.assign(item, validation.normalizedItem); + } + break; } - Object.assign(item, validation.normalizedItem); - } - break; - } - core.info(`Line ${i + 1}: Valid ${itemType} item`); - parsedItems.push(item); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); - } - } - if (errors.length > 0) { - core.warning("Validation errors found:"); - errors.forEach(error => core.warning(` - ${error}`)); - if (parsedItems.length === 0) { - core.setFailed(errors.map(e => ` - ${e}`).join("\n")); - return; + core.info(`Line ${i + 1}: Valid ${itemType} item`); + parsedItems.push(item); + } + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); + } } - } - for (const itemType of Object.keys(expectedOutputTypes)) { - const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); - if (minRequired > 0) { - const actualCount = parsedItems.filter(item => item.type === itemType).length; - if (actualCount < minRequired) { - errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); - } + if (errors.length > 0) { + core.warning("Validation errors found:"); + errors.forEach(error => core.warning(` - ${error}`)); + if (parsedItems.length === 0) { + core.setFailed(errors.map(e => ` - ${e}`).join("\n")); + return; + } + } + for (const itemType of Object.keys(expectedOutputTypes)) { + const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); + if (minRequired > 0) { + const actualCount = parsedItems.filter(item => item.type === itemType).length; + if (actualCount < minRequired) { + errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); + } + } + } + core.info(`Successfully parsed ${parsedItems.length} valid output items`); + const validatedOutput = { + items: parsedItems, + errors: errors, + }; + const agentOutputFile = "/tmp/agent_output.json"; + const validatedOutputJson = JSON.stringify(validatedOutput); + try { + fs.mkdirSync("/tmp", { recursive: true }); + fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); + core.info(`Stored validated output to: ${agentOutputFile}`); + core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); + } + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.error(`Failed to write agent output file: ${errorMsg}`); + } + core.setOutput("output", JSON.stringify(validatedOutput)); + core.setOutput("raw_output", outputContent); + const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); + core.info(`output_types: ${outputTypes.join(", ")}`); + core.setOutput("output_types", outputTypes.join(",")); + try { + await core.summary + .addRaw("## Processed Output\n\n") + .addRaw("```json\n") + .addRaw(JSON.stringify(validatedOutput)) + .addRaw("\n```\n") + .write(); + core.info("Successfully wrote processed output to step summary"); + } + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.warning(`Failed to write to step summary: ${errorMsg}`); } - } - core.info(`Successfully parsed ${parsedItems.length} valid output items`); - const validatedOutput = { - items: parsedItems, - errors: errors, - }; - const agentOutputFile = "/tmp/agent_output.json"; - const validatedOutputJson = JSON.stringify(validatedOutput); - try { - fs.mkdirSync("/tmp", { recursive: true }); - fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); - core.info(`Stored validated output to: ${agentOutputFile}`); - core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.error(`Failed to write agent output file: ${errorMsg}`); - } - core.setOutput("output", JSON.stringify(validatedOutput)); - core.setOutput("raw_output", outputContent); - const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); - core.info(`output_types: ${outputTypes.join(", ")}`); - core.setOutput("output_types", outputTypes.join(",")); - try { - await core.summary - .addRaw("## Processed Output\n\n") - .addRaw("```json\n") - .addRaw(JSON.stringify(validatedOutput)) - .addRaw("\n```\n") - .write(); - core.info("Successfully wrote processed output to step summary"); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.warning(`Failed to write to step summary: ${errorMsg}`); - } } await main(); - name: Upload sanitized agent output @@ -3326,165 +3326,163 @@ jobs: with: script: | function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace( - /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, - (_m, p1, p2) => `${p1}\`@${p2}\`` - ); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); + if (!content || typeof content !== "string") { + return ""; + } + let sanitized = content.trim(); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); + sanitized = sanitized.replace(/[<>&'"]/g, ""); + return sanitized.trim(); } async function main() { - const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; - const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; - if (!outputContent) { - core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); - return; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - return; - } - const createIssueItems = validatedOutput.items.filter(item => item.type === "create-issue"); - if (createIssueItems.length === 0) { - core.info("No create-issue items found in agent output"); - return; - } - core.info(`Found ${createIssueItems.length} create-issue item(s)`); - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Issues Preview\n\n"; - summaryContent += "The following issues would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createIssueItems.length; i++) { - const item = createIssueItems[i]; - summaryContent += `### Issue ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; - } - if (item.labels && item.labels.length > 0) { - summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; - } - summaryContent += "---\n\n"; + const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; + const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; + if (!outputContent) { + core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); + return; } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Issue creation preview written to step summary"); - return; - } - const parentIssueNumber = context.payload?.issue?.number; - const labelsEnv = process.env.GITHUB_AW_ISSUE_LABELS; - let envLabels = labelsEnv - ? labelsEnv - .split(",") - .map(label => label.trim()) - .filter(label => label) - : []; - const createdIssues = []; - for (let i = 0; i < createIssueItems.length; i++) { - const createIssueItem = createIssueItems[i]; - core.info( - `Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}` - ); - let labels = [...envLabels]; - if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { - labels = [...labels, ...createIssueItem.labels]; - } - labels = labels - .filter(label => label != null && label !== false && label !== 0) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - let title = createIssueItem.title ? createIssueItem.title.trim() : ""; - let bodyLines = createIssueItem.body.split("\n"); - if (!title) { - title = createIssueItem.body || "Agent Output"; - } - const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - if (parentIssueNumber) { - core.info("Detected issue context, parent issue #" + parentIssueNumber); - bodyLines.push(`Related to #${parentIssueNumber}`); + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return; } - const workflowName = process.env.GITHUB_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const runUrl = context.payload.repository - ? `${context.payload.repository.html_url}/actions/runs/${runId}` - : `https://github.com/actions/runs/${runId}`; - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating issue with title: ${title}`); - core.info(`Labels: ${labels}`); - core.info(`Body length: ${body.length}`); + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: body, - labels: labels, - }); - core.info("Created issue #" + issue.number + ": " + issue.html_url); - createdIssues.push(issue); - if (parentIssueNumber) { + validatedOutput = JSON.parse(outputContent); + } + catch (error) { + core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); + return; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + return; + } + const createIssueItems = validatedOutput.items.filter(item => item.type === "create-issue"); + if (createIssueItems.length === 0) { + core.info("No create-issue items found in agent output"); + return; + } + core.info(`Found ${createIssueItems.length} create-issue item(s)`); + if (isStaged) { + let summaryContent = "## 🎭 Staged Mode: Create Issues Preview\n\n"; + summaryContent += "The following issues would be created if staged mode was disabled:\n\n"; + for (let i = 0; i < createIssueItems.length; i++) { + const item = createIssueItems[i]; + summaryContent += `### Issue ${i + 1}\n`; + summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; + if (item.body) { + summaryContent += `**Body:**\n${item.body}\n\n`; + } + if (item.labels && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; + } + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Issue creation preview written to step summary"); + return; + } + const parentIssueNumber = context.payload?.issue?.number; + const labelsEnv = process.env.GITHUB_AW_ISSUE_LABELS; + let envLabels = labelsEnv + ? labelsEnv + .split(",") + .map((label) => label.trim()) + .filter((label) => label) + : []; + const createdIssues = []; + for (let i = 0; i < createIssueItems.length; i++) { + const createIssueItem = createIssueItems[i]; + core.info(`Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}`); + let labels = [...envLabels]; + if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { + labels = [...labels, ...createIssueItem.labels]; + } + labels = labels + .filter(label => label != null && label !== false && label !== 0) + .map(label => String(label).trim()) + .filter(label => label) + .map(label => sanitizeLabelContent(label)) + .filter(label => label) + .map(label => (label.length > 64 ? label.substring(0, 64) : label)) + .filter((label, index, arr) => arr.indexOf(label) === index); + let title = createIssueItem.title ? createIssueItem.title.trim() : ""; + let bodyLines = createIssueItem.body.split("\n"); + if (!title) { + title = createIssueItem.body || "Agent Output"; + } + const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; + if (titlePrefix && !title.startsWith(titlePrefix)) { + title = titlePrefix + title; + } + if (parentIssueNumber) { + core.info("Detected issue context, parent issue #" + parentIssueNumber); + bodyLines.push(`Related to #${parentIssueNumber}`); + } + const workflowName = process.env.GITHUB_AW_WORKFLOW_NAME || "Workflow"; + const runId = context.runId; + const runUrl = context.payload.repository + ? `${context.payload.repository.html_url}/actions/runs/${runId}` + : `https://github.com/actions/runs/${runId}`; + bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); + const body = bodyLines.join("\n").trim(); + core.info(`Creating issue with title: ${title}`); + core.info(`Labels: ${labels}`); + core.info(`Body length: ${body.length}`); try { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: parentIssueNumber, - body: `Created related issue: #${issue.number}`, - }); - core.info("Added comment to parent issue #" + parentIssueNumber); - } catch (error) { - core.info(`Warning: Could not add comment to parent issue: ${error instanceof Error ? error.message : String(error)}`); + const { data: issue } = await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: title, + body: body, + labels: labels, + }); + core.info("Created issue #" + issue.number + ": " + issue.html_url); + createdIssues.push(issue); + if (parentIssueNumber) { + try { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: parentIssueNumber, + body: `Created related issue: #${issue.number}`, + }); + core.info("Added comment to parent issue #" + parentIssueNumber); + } + catch (error) { + core.info(`Warning: Could not add comment to parent issue: ${error instanceof Error ? error.message : String(error)}`); + } + } + if (i === createIssueItems.length - 1) { + core.setOutput("issue_number", issue.number); + core.setOutput("issue_url", issue.html_url); + } + } + catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage.includes("Issues has been disabled in this repository")) { + core.info(`⚠ Cannot create issue "${title}": Issues are disabled for this repository`); + core.info("Consider enabling issues in repository settings if you want to create issues automatically"); + continue; + } + core.error(`✗ Failed to create issue "${title}": ${errorMessage}`); + throw error; } - } - if (i === createIssueItems.length - 1) { - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Issues has been disabled in this repository")) { - core.info(`⚠ Cannot create issue "${title}": Issues are disabled for this repository`); - core.info("Consider enabling issues in repository settings if you want to create issues automatically"); - continue; - } - core.error(`✗ Failed to create issue "${title}": ${errorMessage}`); - throw error; } - } - if (createdIssues.length > 0) { - let summaryContent = "\n\n## GitHub Issues\n"; - for (const issue of createdIssues) { - summaryContent += `- Issue #${issue.number}: [${issue.title}](${issue.html_url})\n`; + if (createdIssues.length > 0) { + let summaryContent = "\n\n## GitHub Issues\n"; + for (const issue of createdIssues) { + summaryContent += `- Issue #${issue.number}: [${issue.title}](${issue.html_url})\n`; + } + await core.summary.addRaw(summaryContent).write(); } - await core.summary.addRaw(summaryContent).write(); - } - core.info(`Successfully created ${createdIssues.length} issue(s)`); + core.info(`Successfully created ${createdIssues.length} issue(s)`); } (async () => { - await main(); + await main(); })(); add_comment: @@ -4332,200 +4330,203 @@ jobs: with: script: | function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace( - /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, - (_m, p1, p2) => `${p1}\`@${p2}\`` - ); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); + if (!content || typeof content !== "string") { + return ""; + } + let sanitized = content.trim(); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); + sanitized = sanitized.replace(/[<>&'"]/g, ""); + return sanitized.trim(); } async function main() { - const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; - if (!outputContent) { - core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); - return; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return; - } - core.debug(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.warning("No valid items found in agent output"); - return; - } - const labelsItem = validatedOutput.items.find(item => item.type === "add-labels"); - if (!labelsItem) { - core.warning("No add-labels item found in agent output"); - return; - } - core.debug(`Found add-labels item with ${labelsItem.labels.length} labels`); - if (process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Add Labels Preview\n\n"; - summaryContent += "The following labels would be added if staged mode was disabled:\n\n"; - if (labelsItem.issue_number) { - summaryContent += `**Target Issue:** #${labelsItem.issue_number}\n\n`; - } else { - summaryContent += `**Target:** Current issue/PR\n\n`; + const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; + if (!outputContent) { + core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); + return; } - if (labelsItem.labels && labelsItem.labels.length > 0) { - summaryContent += `**Labels to add:** ${labelsItem.labels.join(", ")}\n\n`; + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return; } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Label addition preview written to step summary"); - return; - } - const allowedLabelsEnv = process.env.GITHUB_AW_LABELS_ALLOWED?.trim(); - const allowedLabels = allowedLabelsEnv - ? allowedLabelsEnv - .split(",") - .map(label => label.trim()) - .filter(label => label) - : undefined; - if (allowedLabels) { - core.debug(`Allowed labels: ${JSON.stringify(allowedLabels)}`); - } else { - core.debug("No label restrictions - any labels are allowed"); - } - const maxCountEnv = process.env.GITHUB_AW_LABELS_MAX_COUNT; - const maxCount = maxCountEnv ? parseInt(maxCountEnv, 10) : 3; - if (isNaN(maxCount) || maxCount < 1) { - core.setFailed(`Invalid max value: ${maxCountEnv}. Must be a positive integer`); - return; - } - core.debug(`Max count: ${maxCount}`); - const labelsTarget = process.env.GITHUB_AW_LABELS_TARGET || "triggering"; - core.info(`Labels target configuration: ${labelsTarget}`); - const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; - const isPRContext = - context.eventName === "pull_request" || - context.eventName === "pull_request_review" || - context.eventName === "pull_request_review_comment"; - if (labelsTarget === "triggering" && !isIssueContext && !isPRContext) { - core.info('Target is "triggering" but not running in issue or pull request context, skipping label addition'); - return; - } - let issueNumber; - let contextType; - if (labelsTarget === "*") { - if (labelsItem.issue_number) { - issueNumber = typeof labelsItem.issue_number === "number" ? labelsItem.issue_number : parseInt(String(labelsItem.issue_number), 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - core.setFailed(`Invalid issue number specified: ${labelsItem.issue_number}`); + core.debug(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } + catch (error) { + core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); return; - } - contextType = "issue"; - } else { - core.setFailed('Target is "*" but no issue_number specified in labels item'); - return; } - } else if (labelsTarget && labelsTarget !== "triggering") { - issueNumber = parseInt(labelsTarget, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - core.setFailed(`Invalid issue number in target configuration: ${labelsTarget}`); - return; + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.warning("No valid items found in agent output"); + return; } - contextType = "issue"; - } else { - if (isIssueContext) { - if (context.payload.issue) { - issueNumber = context.payload.issue.number; - contextType = "issue"; - } else { - core.setFailed("Issue context detected but no issue found in payload"); + const labelsItem = validatedOutput.items.find(item => item.type === "add-labels"); + if (!labelsItem) { + core.warning("No add-labels item found in agent output"); return; - } - } else if (isPRContext) { - if (context.payload.pull_request) { - issueNumber = context.payload.pull_request.number; - contextType = "pull request"; - } else { - core.setFailed("Pull request context detected but no pull request found in payload"); + } + core.debug(`Found add-labels item with ${labelsItem.labels.length} labels`); + if (process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true") { + let summaryContent = "## 🎭 Staged Mode: Add Labels Preview\n\n"; + summaryContent += "The following labels would be added if staged mode was disabled:\n\n"; + if (labelsItem.issue_number) { + summaryContent += `**Target Issue:** #${labelsItem.issue_number}\n\n`; + } + else { + summaryContent += `**Target:** Current issue/PR\n\n`; + } + if (labelsItem.labels && labelsItem.labels.length > 0) { + summaryContent += `**Labels to add:** ${labelsItem.labels.join(", ")}\n\n`; + } + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Label addition preview written to step summary"); return; - } } - } - if (!issueNumber) { - core.setFailed("Could not determine issue or pull request number"); - return; - } - const requestedLabels = labelsItem.labels || []; - core.debug(`Requested labels: ${JSON.stringify(requestedLabels)}`); - for (const label of requestedLabels) { - if (label && typeof label === "string" && label.startsWith("-")) { - core.setFailed(`Label removal is not permitted. Found line starting with '-': ${label}`); - return; + const allowedLabelsEnv = process.env.GITHUB_AW_LABELS_ALLOWED?.trim(); + const allowedLabels = allowedLabelsEnv + ? allowedLabelsEnv + .split(",") + .map(label => label.trim()) + .filter(label => label) + : undefined; + if (allowedLabels) { + core.debug(`Allowed labels: ${JSON.stringify(allowedLabels)}`); + } + else { + core.debug("No label restrictions - any labels are allowed"); + } + const maxCountEnv = process.env.GITHUB_AW_LABELS_MAX_COUNT; + const maxCount = maxCountEnv ? parseInt(maxCountEnv, 10) : 3; + if (isNaN(maxCount) || maxCount < 1) { + core.setFailed(`Invalid max value: ${maxCountEnv}. Must be a positive integer`); + return; } - } - let validLabels; - if (allowedLabels) { - validLabels = requestedLabels.filter(label => allowedLabels.includes(label)); - } else { - validLabels = requestedLabels; - } - let uniqueLabels = validLabels - .filter(label => label != null && label !== false && label !== 0) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - if (uniqueLabels.length > maxCount) { - core.debug(`too many labels, keep ${maxCount}`); - uniqueLabels = uniqueLabels.slice(0, maxCount); - } - if (uniqueLabels.length === 0) { - core.info("No labels to add"); - core.setOutput("labels_added", ""); - await core.summary - .addRaw( - ` + core.debug(`Max count: ${maxCount}`); + const labelsTarget = process.env.GITHUB_AW_LABELS_TARGET || "triggering"; + core.info(`Labels target configuration: ${labelsTarget}`); + const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; + const isPRContext = context.eventName === "pull_request" || + context.eventName === "pull_request_review" || + context.eventName === "pull_request_review_comment"; + if (labelsTarget === "triggering" && !isIssueContext && !isPRContext) { + core.info('Target is "triggering" but not running in issue or pull request context, skipping label addition'); + return; + } + let issueNumber; + let contextType; + if (labelsTarget === "*") { + if (labelsItem.issue_number) { + issueNumber = typeof labelsItem.issue_number === "number" ? labelsItem.issue_number : parseInt(String(labelsItem.issue_number), 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + core.setFailed(`Invalid issue number specified: ${labelsItem.issue_number}`); + return; + } + contextType = "issue"; + } + else { + core.setFailed('Target is "*" but no issue_number specified in labels item'); + return; + } + } + else if (labelsTarget && labelsTarget !== "triggering") { + issueNumber = parseInt(labelsTarget, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + core.setFailed(`Invalid issue number in target configuration: ${labelsTarget}`); + return; + } + contextType = "issue"; + } + else { + if (isIssueContext) { + if (context.payload.issue) { + issueNumber = context.payload.issue.number; + contextType = "issue"; + } + else { + core.setFailed("Issue context detected but no issue found in payload"); + return; + } + } + else if (isPRContext) { + if (context.payload.pull_request) { + issueNumber = context.payload.pull_request.number; + contextType = "pull request"; + } + else { + core.setFailed("Pull request context detected but no pull request found in payload"); + return; + } + } + } + if (!issueNumber) { + core.setFailed("Could not determine issue or pull request number"); + return; + } + const requestedLabels = labelsItem.labels || []; + core.debug(`Requested labels: ${JSON.stringify(requestedLabels)}`); + for (const label of requestedLabels) { + if (label && typeof label === "string" && label.startsWith("-")) { + core.setFailed(`Label removal is not permitted. Found line starting with '-': ${label}`); + return; + } + } + let validLabels; + if (allowedLabels) { + validLabels = requestedLabels.filter(label => allowedLabels.includes(label)); + } + else { + validLabels = requestedLabels; + } + let uniqueLabels = validLabels + .filter(label => label != null && label !== false && label !== 0) + .map(label => String(label).trim()) + .filter(label => label) + .map(label => sanitizeLabelContent(label)) + .filter(label => label) + .map(label => (label.length > 64 ? label.substring(0, 64) : label)) + .filter((label, index, arr) => arr.indexOf(label) === index); + if (uniqueLabels.length > maxCount) { + core.debug(`too many labels, keep ${maxCount}`); + uniqueLabels = uniqueLabels.slice(0, maxCount); + } + if (uniqueLabels.length === 0) { + core.info("No labels to add"); + core.setOutput("labels_added", ""); + await core.summary + .addRaw(` ## Label Addition No labels were added (no valid labels found in agent output). - ` - ) - .write(); - return; - } - core.info(`Adding ${uniqueLabels.length} labels to ${contextType} #${issueNumber}: ${JSON.stringify(uniqueLabels)}`); - try { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - labels: uniqueLabels, - }); - core.info(`Successfully added ${uniqueLabels.length} labels to ${contextType} #${issueNumber}`); - core.setOutput("labels_added", uniqueLabels.join("\n")); - const labelsListMarkdown = uniqueLabels.map(label => `- \`${label}\``).join("\n"); - await core.summary - .addRaw( - ` + `) + .write(); + return; + } + core.info(`Adding ${uniqueLabels.length} labels to ${contextType} #${issueNumber}: ${JSON.stringify(uniqueLabels)}`); + try { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + labels: uniqueLabels, + }); + core.info(`Successfully added ${uniqueLabels.length} labels to ${contextType} #${issueNumber}`); + core.setOutput("labels_added", uniqueLabels.join("\n")); + const labelsListMarkdown = uniqueLabels.map(label => `- \`${label}\``).join("\n"); + await core.summary + .addRaw(` ## Label Addition Successfully added ${uniqueLabels.length} label(s) to ${contextType} #${issueNumber}: ${labelsListMarkdown} - ` - ) - .write(); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - core.error(`Failed to add labels: ${errorMessage}`); - core.setFailed(`Failed to add labels: ${errorMessage}`); - } + `) + .write(); + } + catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + core.error(`Failed to add labels: ${errorMessage}`); + core.setFailed(`Failed to add labels: ${errorMessage}`); + } } await main(); diff --git a/.github/workflows/scout.lock.yml b/.github/workflows/scout.lock.yml index 8877d43962a..bac0c0537e0 100644 --- a/.github/workflows/scout.lock.yml +++ b/.github/workflows/scout.lock.yml @@ -1614,742 +1614,742 @@ jobs: with: script: | async function main() { - const fs = require("fs"); - function sanitizeContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; - const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; - const allowedDomains = allowedDomainsEnv - ? allowedDomainsEnv - .split(",") - .map(d => d.trim()) - .filter(d => d) - : defaultAllowedDomains; - let sanitized = content; - sanitized = neutralizeMentions(sanitized); - sanitized = removeXmlComments(sanitized); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitizeUrlProtocols(sanitized); - sanitized = sanitizeUrlDomains(sanitized); - const maxLength = 524288; - if (sanitized.length > maxLength) { - sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; - } - const lines = sanitized.split("\n"); - const maxLines = 65000; - if (lines.length > maxLines) { - sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; - } - sanitized = neutralizeBotTriggers(sanitized); - return sanitized.trim(); - function sanitizeUrlDomains(s) { - return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { - const urlAfterProtocol = match.slice(8); - const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); - const isAllowed = allowedDomains.some(allowedDomain => { - const normalizedAllowed = allowedDomain.toLowerCase(); - return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); - }); - return isAllowed ? match : "(redacted)"; - }); - } - function sanitizeUrlProtocols(s) { - return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { - return protocol.toLowerCase() === "https" ? match : "(redacted)"; - }); - } - function neutralizeMentions(s) { - return s.replace( - /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, - (_m, p1, p2) => `${p1}\`@${p2}\`` - ); - } - function removeXmlComments(s) { - return s.replace(//g, "").replace(//g, ""); - } - function neutralizeBotTriggers(s) { - return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); - } - } - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create-issue": - return 1; - case "add-comment": - return 1; - case "create-pull-request": - return 1; - case "create-pull-request-review-comment": - return 1; - case "add-labels": - return 5; - case "update-issue": - return 1; - case "push-to-pull-request-branch": - return 1; - case "create-discussion": - return 1; - case "missing-tool": - return 1000; - case "create-code-scanning-alert": - return 1000; - case "upload-asset": - return 10; - default: - return 1; - } - } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; - } - return 0; - } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; - } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; + const fs = require("fs"); + function sanitizeContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; + const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; + const allowedDomains = allowedDomainsEnv + ? allowedDomainsEnv + .split(",") + .map(d => d.trim()) + .filter(d => d) + : defaultAllowedDomains; + let sanitized = content; + sanitized = neutralizeMentions(sanitized); + sanitized = removeXmlComments(sanitized); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitizeUrlProtocols(sanitized); + sanitized = sanitizeUrlDomains(sanitized); + const maxLength = 524288; + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; + } + const lines = sanitized.split("\n"); + const maxLines = 65000; + if (lines.length > maxLines) { + sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; + } + sanitized = neutralizeBotTriggers(sanitized); + return sanitized.trim(); + function sanitizeUrlDomains(s) { + return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { + const urlAfterProtocol = match.slice(8); + const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); + const isAllowed = allowedDomains.some(allowedDomain => { + const normalizedAllowed = allowedDomain.toLowerCase(); + return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); + }); + return isAllowed ? match : "(redacted)"; + }); + } + function sanitizeUrlProtocols(s) { + return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { + return protocol.toLowerCase() === "https" ? match : "(redacted)"; + }); + } + function neutralizeMentions(s) { + return s.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); + } + function removeXmlComments(s) { + return s.replace(//g, "").replace(//g, ""); + } + function neutralizeBotTriggers(s) { + return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); + } } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + switch (itemType) { + case "create-issue": + return 1; + case "add-comment": + return 1; + case "create-pull-request": + return 1; + case "create-pull-request-review-comment": + return 1; + case "add-labels": + return 5; + case "update-issue": + return 1; + case "push-to-pull-request-branch": + return 1; + case "create-discussion": + return 1; + case "missing-tool": + return 1000; + case "create-code-scanning-alert": + return 1000; + case "upload-asset": + return 10; + default: + return 1; + } } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; + } + return 0; + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } + else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } + else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; } - return { isValid: true, normalizedValue: parsed }; - } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { + if (inputSchema.required && (value === undefined || value === null)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (value === undefined || value === null) { + return { + isValid: true, + normalizedValue: inputSchema.default || undefined, + }; + } + const inputType = inputSchema.type || "string"; + let normalizedValue = value; + switch (inputType) { + case "string": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string`, + }; + } + normalizedValue = sanitizeContent(value); + break; + case "boolean": + if (typeof value !== "boolean") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a boolean`, + }; + } + break; + case "number": + if (typeof value !== "number") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number`, + }; + } + break; + case "choice": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, + }; + } + if (inputSchema.options && !inputSchema.options.includes(value)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, + }; + } + normalizedValue = sanitizeContent(value); + break; + default: + if (typeof value === "string") { + normalizedValue = sanitizeContent(value); + } + break; + } return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, + isValid: true, + normalizedValue, }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { + function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { + const errors = []; + const normalizedItem = { ...item }; + if (!jobConfig.inputs) { + return { + isValid: true, + errors: [], + normalizedItem: item, + }; + } + for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { + const fieldValue = item[fieldName]; + const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); + if (!validation.isValid && validation.error) { + errors.push(validation.error); + } + else if (validation.normalizedValue !== undefined) { + normalizedItem[fieldName] = validation.normalizedValue; + } + } return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, + isValid: errors.length === 0, + errors, + normalizedItem, }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; - } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + function parseJsonWithRepair(jsonStr) { + try { + return JSON.parse(jsonStr); + } + catch (originalError) { + try { + const repairedJson = repairJson(jsonStr); + return JSON.parse(repairedJson); + } + catch (repairError) { + core.info(`invalid input json: ${jsonStr}`); + const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); + const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); + throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); + } + } } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; + const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; + if (!outputFile) { + core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); + core.setOutput("output", ""); + return; } - return { isValid: true }; - } - function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { - if (inputSchema.required && (value === undefined || value === null)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; + if (!fs.existsSync(outputFile)) { + core.info(`Output file does not exist: ${outputFile}`); + core.setOutput("output", ""); + return; } - if (value === undefined || value === null) { - return { - isValid: true, - normalizedValue: inputSchema.default || undefined, - }; + const outputContent = fs.readFileSync(outputFile, "utf8"); + if (outputContent.trim() === "") { + core.info("Output file is empty"); } - const inputType = inputSchema.type || "string"; - let normalizedValue = value; - switch (inputType) { - case "string": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string`, - }; - } - normalizedValue = sanitizeContent(value); - break; - case "boolean": - if (typeof value !== "boolean") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a boolean`, - }; + core.info(`Raw output content length: ${outputContent.length}`); + let expectedOutputTypes = {}; + if (safeOutputsConfig) { + try { + expectedOutputTypes = JSON.parse(safeOutputsConfig); + core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); } - break; - case "number": - if (typeof value !== "number") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number`, - }; + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); } - break; - case "choice": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, - }; - } - if (inputSchema.options && !inputSchema.options.includes(value)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, - }; - } - normalizedValue = sanitizeContent(value); - break; - default: - if (typeof value === "string") { - normalizedValue = sanitizeContent(value); - } - break; } - return { - isValid: true, - normalizedValue, - }; - } - function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { + const lines = outputContent.trim().split("\n"); + const parsedItems = []; const errors = []; - const normalizedItem = { ...item }; - if (!jobConfig.inputs) { - return { - isValid: true, - errors: [], - normalizedItem: item, - }; - } - for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { - const fieldValue = item[fieldName]; - const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); - if (!validation.isValid && validation.error) { - errors.push(validation.error); - } else if (validation.normalizedValue !== undefined) { - normalizedItem[fieldName] = validation.normalizedValue; - } - } - return { - isValid: errors.length === 0, - errors, - normalizedItem, - }; - } - function parseJsonWithRepair(jsonStr) { - try { - return JSON.parse(jsonStr); - } catch (originalError) { - try { - const repairedJson = repairJson(jsonStr); - return JSON.parse(repairedJson); - } catch (repairError) { - core.info(`invalid input json: ${jsonStr}`); - const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); - const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); - throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); - } - } - } - const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; - const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; - if (!outputFile) { - core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); - core.setOutput("output", ""); - return; - } - if (!fs.existsSync(outputFile)) { - core.info(`Output file does not exist: ${outputFile}`); - core.setOutput("output", ""); - return; - } - const outputContent = fs.readFileSync(outputFile, "utf8"); - if (outputContent.trim() === "") { - core.info("Output file is empty"); - } - core.info(`Raw output content length: ${outputContent.length}`); - let expectedOutputTypes = {}; - if (safeOutputsConfig) { - try { - expectedOutputTypes = JSON.parse(safeOutputsConfig); - core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); - } - } - const lines = outputContent.trim().split("\n"); - const parsedItems = []; - const errors = []; - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); - if (line === "") continue; - try { - const item = parseJsonWithRepair(line); - if (item === undefined) { - errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); - continue; - } - if (!item.type) { - errors.push(`Line ${i + 1}: Missing required 'type' field`); - continue; - } - const itemType = item.type; - if (!expectedOutputTypes[itemType]) { - errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); - continue; - } - const typeCount = parsedItems.filter(existing => existing.type === itemType).length; - const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); - if (typeCount >= maxAllowed) { - errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); - continue; - } - core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create-issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line === "") continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); - if (!issueNumValidation.isValid) { - if (issueNumValidation.error) errors.push(issueNumValidation.error); - continue; - } - item.body = sanitizeContent(item.body); - break; - case "create-pull-request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - item.branch = sanitizeContent(item.branch); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); - if (!labelsIssueNumValidation.isValid) { - if (labelsIssueNumValidation.error) errors.push(labelsIssueNumValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label)); - break; - case "update-issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "push-to-pull-request-branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch); - item.message = sanitizeContent(item.message); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push-to-pull-request-branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create-pull-request-review-comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create-pull-request-review-comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create-discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category); - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - break; - case "missing-tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool); - item.reason = sanitizeContent(item.reason); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); - continue; + try { + const item = parseJsonWithRepair(line); + if (item === undefined) { + errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); + continue; } - item.alternatives = sanitizeContent(item.alternatives); - } - break; - case "upload-asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "create-code-scanning-alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); + if (!item.type) { + errors.push(`Line ${i + 1}: Missing required 'type' field`); + continue; } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); - continue; + const itemType = item.type; + if (!expectedOutputTypes[itemType]) { + errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); + continue; } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; + const typeCount = parsedItems.filter(existing => existing.type === itemType).length; + const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); + if (typeCount >= maxAllowed) { + errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); + continue; } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file); - item.severity = sanitizeContent(item.severity); - item.message = sanitizeContent(item.message); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); - continue; - } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; + core.info(`Line ${i + 1}: type '${itemType}'`); + switch (itemType) { + case "create-issue": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-comment": + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); + continue; + } + const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); + if (!issueNumValidation.isValid) { + if (issueNumValidation.error) + errors.push(issueNumValidation.error); + continue; + } + item.body = sanitizeContent(item.body); + break; + case "create-pull-request": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); + continue; + } + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + item.branch = sanitizeContent(item.branch); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-labels": + if (!item.labels || !Array.isArray(item.labels)) { + errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); + continue; + } + if (item.labels.some((label) => typeof label !== "string")) { + errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); + continue; + } + const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); + if (!labelsIssueNumValidation.isValid) { + if (labelsIssueNumValidation.error) + errors.push(labelsIssueNumValidation.error); + continue; + } + item.labels = item.labels.map((label) => sanitizeContent(label)); + break; + case "update-issue": + const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; + if (!hasValidField) { + errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); + continue; + } + if (item.status !== undefined) { + if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { + errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); + continue; + } + } + if (item.title !== undefined) { + if (typeof item.title !== "string") { + errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); + continue; + } + item.title = sanitizeContent(item.title); + } + if (item.body !== undefined) { + if (typeof item.body !== "string") { + errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); + continue; + } + item.body = sanitizeContent(item.body); + } + const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); + if (!updateIssueNumValidation.isValid) { + if (updateIssueNumValidation.error) + errors.push(updateIssueNumValidation.error); + continue; + } + break; + case "push-to-pull-request-branch": + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); + continue; + } + item.branch = sanitizeContent(item.branch); + item.message = sanitizeContent(item.message); + const pushPRNumValidation = validateIssueOrPRNumber(item.pull_request_number, "push-to-pull-request-branch 'pull_request_number'", i + 1); + if (!pushPRNumValidation.isValid) { + if (pushPRNumValidation.error) + errors.push(pushPRNumValidation.error); + continue; + } + break; + case "create-pull-request-review-comment": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); + continue; + } + const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); + if (!lineValidation.isValid) { + if (lineValidation.error) + errors.push(lineValidation.error); + continue; + } + const lineNumber = lineValidation.normalizedValue; + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); + continue; + } + item.body = sanitizeContent(item.body); + const startLineValidation = validateOptionalPositiveInteger(item.start_line, "create-pull-request-review-comment 'start_line'", i + 1); + if (!startLineValidation.isValid) { + if (startLineValidation.error) + errors.push(startLineValidation.error); + continue; + } + if (startLineValidation.normalizedValue !== undefined && + lineNumber !== undefined && + startLineValidation.normalizedValue > lineNumber) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); + continue; + } + if (item.side !== undefined) { + if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); + continue; + } + } + break; + case "create-discussion": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); + continue; + } + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); + continue; + } + item.category = sanitizeContent(item.category); + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + break; + case "missing-tool": + if (!item.tool || typeof item.tool !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); + continue; + } + if (!item.reason || typeof item.reason !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); + continue; + } + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); + continue; + } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "upload-asset": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); + continue; + } + break; + case "create-code-scanning-alert": + if (!item.file || typeof item.file !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); + continue; + } + const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); + if (!alertLineValidation.isValid) { + if (alertLineValidation.error) { + errors.push(alertLineValidation.error); + } + continue; + } + if (!item.severity || typeof item.severity !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); + continue; + } + const allowedSeverities = ["error", "warning", "info", "note"]; + if (!allowedSeverities.includes(item.severity.toLowerCase())) { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}`); + continue; + } + const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); + if (!columnValidation.isValid) { + if (columnValidation.error) + errors.push(columnValidation.error); + continue; + } + if (item.ruleIdSuffix !== undefined) { + if (typeof item.ruleIdSuffix !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); + continue; + } + if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores`); + continue; + } + } + item.severity = item.severity.toLowerCase(); + item.file = sanitizeContent(item.file); + item.severity = sanitizeContent(item.severity); + item.message = sanitizeContent(item.message); + if (item.ruleIdSuffix) { + item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); + } + break; + default: + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); + continue; + } + Object.assign(item, validation.normalizedItem); + } + break; } - Object.assign(item, validation.normalizedItem); - } - break; - } - core.info(`Line ${i + 1}: Valid ${itemType} item`); - parsedItems.push(item); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); + core.info(`Line ${i + 1}: Valid ${itemType} item`); + parsedItems.push(item); + } + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); + } } - } - if (errors.length > 0) { - core.warning("Validation errors found:"); - errors.forEach(error => core.warning(` - ${error}`)); - if (parsedItems.length === 0) { - core.setFailed(errors.map(e => ` - ${e}`).join("\n")); - return; + if (errors.length > 0) { + core.warning("Validation errors found:"); + errors.forEach(error => core.warning(` - ${error}`)); + if (parsedItems.length === 0) { + core.setFailed(errors.map(e => ` - ${e}`).join("\n")); + return; + } } - } - for (const itemType of Object.keys(expectedOutputTypes)) { - const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); - if (minRequired > 0) { - const actualCount = parsedItems.filter(item => item.type === itemType).length; - if (actualCount < minRequired) { - errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); - } + for (const itemType of Object.keys(expectedOutputTypes)) { + const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); + if (minRequired > 0) { + const actualCount = parsedItems.filter(item => item.type === itemType).length; + if (actualCount < minRequired) { + errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); + } + } + } + core.info(`Successfully parsed ${parsedItems.length} valid output items`); + const validatedOutput = { + items: parsedItems, + errors: errors, + }; + const agentOutputFile = "/tmp/agent_output.json"; + const validatedOutputJson = JSON.stringify(validatedOutput); + try { + fs.mkdirSync("/tmp", { recursive: true }); + fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); + core.info(`Stored validated output to: ${agentOutputFile}`); + core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); + } + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.error(`Failed to write agent output file: ${errorMsg}`); + } + core.setOutput("output", JSON.stringify(validatedOutput)); + core.setOutput("raw_output", outputContent); + const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); + core.info(`output_types: ${outputTypes.join(", ")}`); + core.setOutput("output_types", outputTypes.join(",")); + try { + await core.summary + .addRaw("## Processed Output\n\n") + .addRaw("```json\n") + .addRaw(JSON.stringify(validatedOutput)) + .addRaw("\n```\n") + .write(); + core.info("Successfully wrote processed output to step summary"); + } + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.warning(`Failed to write to step summary: ${errorMsg}`); } - } - core.info(`Successfully parsed ${parsedItems.length} valid output items`); - const validatedOutput = { - items: parsedItems, - errors: errors, - }; - const agentOutputFile = "/tmp/agent_output.json"; - const validatedOutputJson = JSON.stringify(validatedOutput); - try { - fs.mkdirSync("/tmp", { recursive: true }); - fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); - core.info(`Stored validated output to: ${agentOutputFile}`); - core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.error(`Failed to write agent output file: ${errorMsg}`); - } - core.setOutput("output", JSON.stringify(validatedOutput)); - core.setOutput("raw_output", outputContent); - const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); - core.info(`output_types: ${outputTypes.join(", ")}`); - core.setOutput("output_types", outputTypes.join(",")); - try { - await core.summary - .addRaw("## Processed Output\n\n") - .addRaw("```json\n") - .addRaw(JSON.stringify(validatedOutput)) - .addRaw("\n```\n") - .write(); - core.info("Successfully wrote processed output to step summary"); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.warning(`Failed to write to step summary: ${errorMsg}`); - } } await main(); - name: Upload sanitized agent output diff --git a/.github/workflows/technical-doc-writer.lock.yml b/.github/workflows/technical-doc-writer.lock.yml index 1e0d86d34e0..589cfb479b8 100644 --- a/.github/workflows/technical-doc-writer.lock.yml +++ b/.github/workflows/technical-doc-writer.lock.yml @@ -1435,742 +1435,742 @@ jobs: with: script: | async function main() { - const fs = require("fs"); - function sanitizeContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; - const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; - const allowedDomains = allowedDomainsEnv - ? allowedDomainsEnv - .split(",") - .map(d => d.trim()) - .filter(d => d) - : defaultAllowedDomains; - let sanitized = content; - sanitized = neutralizeMentions(sanitized); - sanitized = removeXmlComments(sanitized); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitizeUrlProtocols(sanitized); - sanitized = sanitizeUrlDomains(sanitized); - const maxLength = 524288; - if (sanitized.length > maxLength) { - sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; - } - const lines = sanitized.split("\n"); - const maxLines = 65000; - if (lines.length > maxLines) { - sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; - } - sanitized = neutralizeBotTriggers(sanitized); - return sanitized.trim(); - function sanitizeUrlDomains(s) { - return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { - const urlAfterProtocol = match.slice(8); - const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); - const isAllowed = allowedDomains.some(allowedDomain => { - const normalizedAllowed = allowedDomain.toLowerCase(); - return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); - }); - return isAllowed ? match : "(redacted)"; - }); - } - function sanitizeUrlProtocols(s) { - return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { - return protocol.toLowerCase() === "https" ? match : "(redacted)"; - }); - } - function neutralizeMentions(s) { - return s.replace( - /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, - (_m, p1, p2) => `${p1}\`@${p2}\`` - ); - } - function removeXmlComments(s) { - return s.replace(//g, "").replace(//g, ""); - } - function neutralizeBotTriggers(s) { - return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); - } - } - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create-issue": - return 1; - case "add-comment": - return 1; - case "create-pull-request": - return 1; - case "create-pull-request-review-comment": - return 1; - case "add-labels": - return 5; - case "update-issue": - return 1; - case "push-to-pull-request-branch": - return 1; - case "create-discussion": - return 1; - case "missing-tool": - return 1000; - case "create-code-scanning-alert": - return 1000; - case "upload-asset": - return 10; - default: - return 1; - } - } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; - } - return 0; - } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; - } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; + const fs = require("fs"); + function sanitizeContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; + const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; + const allowedDomains = allowedDomainsEnv + ? allowedDomainsEnv + .split(",") + .map(d => d.trim()) + .filter(d => d) + : defaultAllowedDomains; + let sanitized = content; + sanitized = neutralizeMentions(sanitized); + sanitized = removeXmlComments(sanitized); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitizeUrlProtocols(sanitized); + sanitized = sanitizeUrlDomains(sanitized); + const maxLength = 524288; + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; + } + const lines = sanitized.split("\n"); + const maxLines = 65000; + if (lines.length > maxLines) { + sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; + } + sanitized = neutralizeBotTriggers(sanitized); + return sanitized.trim(); + function sanitizeUrlDomains(s) { + return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { + const urlAfterProtocol = match.slice(8); + const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); + const isAllowed = allowedDomains.some(allowedDomain => { + const normalizedAllowed = allowedDomain.toLowerCase(); + return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); + }); + return isAllowed ? match : "(redacted)"; + }); + } + function sanitizeUrlProtocols(s) { + return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { + return protocol.toLowerCase() === "https" ? match : "(redacted)"; + }); + } + function neutralizeMentions(s) { + return s.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); + } + function removeXmlComments(s) { + return s.replace(//g, "").replace(//g, ""); + } + function neutralizeBotTriggers(s) { + return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); + } } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + switch (itemType) { + case "create-issue": + return 1; + case "add-comment": + return 1; + case "create-pull-request": + return 1; + case "create-pull-request-review-comment": + return 1; + case "add-labels": + return 5; + case "update-issue": + return 1; + case "push-to-pull-request-branch": + return 1; + case "create-discussion": + return 1; + case "missing-tool": + return 1000; + case "create-code-scanning-alert": + return 1000; + case "upload-asset": + return 10; + default: + return 1; + } } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; + } + return 0; + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } + else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } + else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; } - return { isValid: true, normalizedValue: parsed }; - } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { + if (inputSchema.required && (value === undefined || value === null)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (value === undefined || value === null) { + return { + isValid: true, + normalizedValue: inputSchema.default || undefined, + }; + } + const inputType = inputSchema.type || "string"; + let normalizedValue = value; + switch (inputType) { + case "string": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string`, + }; + } + normalizedValue = sanitizeContent(value); + break; + case "boolean": + if (typeof value !== "boolean") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a boolean`, + }; + } + break; + case "number": + if (typeof value !== "number") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number`, + }; + } + break; + case "choice": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, + }; + } + if (inputSchema.options && !inputSchema.options.includes(value)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, + }; + } + normalizedValue = sanitizeContent(value); + break; + default: + if (typeof value === "string") { + normalizedValue = sanitizeContent(value); + } + break; + } return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, + isValid: true, + normalizedValue, }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { + function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { + const errors = []; + const normalizedItem = { ...item }; + if (!jobConfig.inputs) { + return { + isValid: true, + errors: [], + normalizedItem: item, + }; + } + for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { + const fieldValue = item[fieldName]; + const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); + if (!validation.isValid && validation.error) { + errors.push(validation.error); + } + else if (validation.normalizedValue !== undefined) { + normalizedItem[fieldName] = validation.normalizedValue; + } + } return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, + isValid: errors.length === 0, + errors, + normalizedItem, }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; - } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + function parseJsonWithRepair(jsonStr) { + try { + return JSON.parse(jsonStr); + } + catch (originalError) { + try { + const repairedJson = repairJson(jsonStr); + return JSON.parse(repairedJson); + } + catch (repairError) { + core.info(`invalid input json: ${jsonStr}`); + const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); + const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); + throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); + } + } } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; + const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; + if (!outputFile) { + core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); + core.setOutput("output", ""); + return; } - return { isValid: true }; - } - function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { - if (inputSchema.required && (value === undefined || value === null)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; + if (!fs.existsSync(outputFile)) { + core.info(`Output file does not exist: ${outputFile}`); + core.setOutput("output", ""); + return; } - if (value === undefined || value === null) { - return { - isValid: true, - normalizedValue: inputSchema.default || undefined, - }; + const outputContent = fs.readFileSync(outputFile, "utf8"); + if (outputContent.trim() === "") { + core.info("Output file is empty"); } - const inputType = inputSchema.type || "string"; - let normalizedValue = value; - switch (inputType) { - case "string": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string`, - }; - } - normalizedValue = sanitizeContent(value); - break; - case "boolean": - if (typeof value !== "boolean") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a boolean`, - }; - } - break; - case "number": - if (typeof value !== "number") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number`, - }; - } - break; - case "choice": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, - }; + core.info(`Raw output content length: ${outputContent.length}`); + let expectedOutputTypes = {}; + if (safeOutputsConfig) { + try { + expectedOutputTypes = JSON.parse(safeOutputsConfig); + core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); } - if (inputSchema.options && !inputSchema.options.includes(value)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, - }; + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); } - normalizedValue = sanitizeContent(value); - break; - default: - if (typeof value === "string") { - normalizedValue = sanitizeContent(value); - } - break; } - return { - isValid: true, - normalizedValue, - }; - } - function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { + const lines = outputContent.trim().split("\n"); + const parsedItems = []; const errors = []; - const normalizedItem = { ...item }; - if (!jobConfig.inputs) { - return { - isValid: true, - errors: [], - normalizedItem: item, - }; - } - for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { - const fieldValue = item[fieldName]; - const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); - if (!validation.isValid && validation.error) { - errors.push(validation.error); - } else if (validation.normalizedValue !== undefined) { - normalizedItem[fieldName] = validation.normalizedValue; - } - } - return { - isValid: errors.length === 0, - errors, - normalizedItem, - }; - } - function parseJsonWithRepair(jsonStr) { - try { - return JSON.parse(jsonStr); - } catch (originalError) { - try { - const repairedJson = repairJson(jsonStr); - return JSON.parse(repairedJson); - } catch (repairError) { - core.info(`invalid input json: ${jsonStr}`); - const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); - const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); - throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); - } - } - } - const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; - const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; - if (!outputFile) { - core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); - core.setOutput("output", ""); - return; - } - if (!fs.existsSync(outputFile)) { - core.info(`Output file does not exist: ${outputFile}`); - core.setOutput("output", ""); - return; - } - const outputContent = fs.readFileSync(outputFile, "utf8"); - if (outputContent.trim() === "") { - core.info("Output file is empty"); - } - core.info(`Raw output content length: ${outputContent.length}`); - let expectedOutputTypes = {}; - if (safeOutputsConfig) { - try { - expectedOutputTypes = JSON.parse(safeOutputsConfig); - core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); - } - } - const lines = outputContent.trim().split("\n"); - const parsedItems = []; - const errors = []; - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); - if (line === "") continue; - try { - const item = parseJsonWithRepair(line); - if (item === undefined) { - errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); - continue; - } - if (!item.type) { - errors.push(`Line ${i + 1}: Missing required 'type' field`); - continue; - } - const itemType = item.type; - if (!expectedOutputTypes[itemType]) { - errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); - continue; - } - const typeCount = parsedItems.filter(existing => existing.type === itemType).length; - const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); - if (typeCount >= maxAllowed) { - errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); - continue; - } - core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create-issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); - if (!issueNumValidation.isValid) { - if (issueNumValidation.error) errors.push(issueNumValidation.error); - continue; - } - item.body = sanitizeContent(item.body); - break; - case "create-pull-request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - item.branch = sanitizeContent(item.branch); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); - if (!labelsIssueNumValidation.isValid) { - if (labelsIssueNumValidation.error) errors.push(labelsIssueNumValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label)); - break; - case "update-issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line === "") continue; - } - break; - case "push-to-pull-request-branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch); - item.message = sanitizeContent(item.message); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push-to-pull-request-branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create-pull-request-review-comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create-pull-request-review-comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; + try { + const item = parseJsonWithRepair(line); + if (item === undefined) { + errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); + continue; } - } - break; - case "create-discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; + if (!item.type) { + errors.push(`Line ${i + 1}: Missing required 'type' field`); + continue; } - item.category = sanitizeContent(item.category); - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - break; - case "missing-tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool); - item.reason = sanitizeContent(item.reason); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); - continue; + const itemType = item.type; + if (!expectedOutputTypes[itemType]) { + errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); + continue; } - item.alternatives = sanitizeContent(item.alternatives); - } - break; - case "upload-asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "create-code-scanning-alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); + const typeCount = parsedItems.filter(existing => existing.type === itemType).length; + const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); + if (typeCount >= maxAllowed) { + errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); + continue; } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); - continue; + core.info(`Line ${i + 1}: type '${itemType}'`); + switch (itemType) { + case "create-issue": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-comment": + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); + continue; + } + const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); + if (!issueNumValidation.isValid) { + if (issueNumValidation.error) + errors.push(issueNumValidation.error); + continue; + } + item.body = sanitizeContent(item.body); + break; + case "create-pull-request": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); + continue; + } + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + item.branch = sanitizeContent(item.branch); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-labels": + if (!item.labels || !Array.isArray(item.labels)) { + errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); + continue; + } + if (item.labels.some((label) => typeof label !== "string")) { + errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); + continue; + } + const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); + if (!labelsIssueNumValidation.isValid) { + if (labelsIssueNumValidation.error) + errors.push(labelsIssueNumValidation.error); + continue; + } + item.labels = item.labels.map((label) => sanitizeContent(label)); + break; + case "update-issue": + const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; + if (!hasValidField) { + errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); + continue; + } + if (item.status !== undefined) { + if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { + errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); + continue; + } + } + if (item.title !== undefined) { + if (typeof item.title !== "string") { + errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); + continue; + } + item.title = sanitizeContent(item.title); + } + if (item.body !== undefined) { + if (typeof item.body !== "string") { + errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); + continue; + } + item.body = sanitizeContent(item.body); + } + const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); + if (!updateIssueNumValidation.isValid) { + if (updateIssueNumValidation.error) + errors.push(updateIssueNumValidation.error); + continue; + } + break; + case "push-to-pull-request-branch": + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); + continue; + } + item.branch = sanitizeContent(item.branch); + item.message = sanitizeContent(item.message); + const pushPRNumValidation = validateIssueOrPRNumber(item.pull_request_number, "push-to-pull-request-branch 'pull_request_number'", i + 1); + if (!pushPRNumValidation.isValid) { + if (pushPRNumValidation.error) + errors.push(pushPRNumValidation.error); + continue; + } + break; + case "create-pull-request-review-comment": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); + continue; + } + const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); + if (!lineValidation.isValid) { + if (lineValidation.error) + errors.push(lineValidation.error); + continue; + } + const lineNumber = lineValidation.normalizedValue; + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); + continue; + } + item.body = sanitizeContent(item.body); + const startLineValidation = validateOptionalPositiveInteger(item.start_line, "create-pull-request-review-comment 'start_line'", i + 1); + if (!startLineValidation.isValid) { + if (startLineValidation.error) + errors.push(startLineValidation.error); + continue; + } + if (startLineValidation.normalizedValue !== undefined && + lineNumber !== undefined && + startLineValidation.normalizedValue > lineNumber) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); + continue; + } + if (item.side !== undefined) { + if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); + continue; + } + } + break; + case "create-discussion": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); + continue; + } + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); + continue; + } + item.category = sanitizeContent(item.category); + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + break; + case "missing-tool": + if (!item.tool || typeof item.tool !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); + continue; + } + if (!item.reason || typeof item.reason !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); + continue; + } + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); + continue; + } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "upload-asset": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); + continue; + } + break; + case "create-code-scanning-alert": + if (!item.file || typeof item.file !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); + continue; + } + const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); + if (!alertLineValidation.isValid) { + if (alertLineValidation.error) { + errors.push(alertLineValidation.error); + } + continue; + } + if (!item.severity || typeof item.severity !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); + continue; + } + const allowedSeverities = ["error", "warning", "info", "note"]; + if (!allowedSeverities.includes(item.severity.toLowerCase())) { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}`); + continue; + } + const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); + if (!columnValidation.isValid) { + if (columnValidation.error) + errors.push(columnValidation.error); + continue; + } + if (item.ruleIdSuffix !== undefined) { + if (typeof item.ruleIdSuffix !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); + continue; + } + if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores`); + continue; + } + } + item.severity = item.severity.toLowerCase(); + item.file = sanitizeContent(item.file); + item.severity = sanitizeContent(item.severity); + item.message = sanitizeContent(item.message); + if (item.ruleIdSuffix) { + item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); + } + break; + default: + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); + continue; + } + Object.assign(item, validation.normalizedItem); + } + break; } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file); - item.severity = sanitizeContent(item.severity); - item.message = sanitizeContent(item.message); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); - continue; - } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; + core.info(`Line ${i + 1}: Valid ${itemType} item`); + parsedItems.push(item); + } + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); + } + } + if (errors.length > 0) { + core.warning("Validation errors found:"); + errors.forEach(error => core.warning(` - ${error}`)); + if (parsedItems.length === 0) { + core.setFailed(errors.map(e => ` - ${e}`).join("\n")); + return; + } + } + for (const itemType of Object.keys(expectedOutputTypes)) { + const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); + if (minRequired > 0) { + const actualCount = parsedItems.filter(item => item.type === itemType).length; + if (actualCount < minRequired) { + errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); } - Object.assign(item, validation.normalizedItem); - } - break; - } - core.info(`Line ${i + 1}: Valid ${itemType} item`); - parsedItems.push(item); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); + } } - } - if (errors.length > 0) { - core.warning("Validation errors found:"); - errors.forEach(error => core.warning(` - ${error}`)); - if (parsedItems.length === 0) { - core.setFailed(errors.map(e => ` - ${e}`).join("\n")); - return; + core.info(`Successfully parsed ${parsedItems.length} valid output items`); + const validatedOutput = { + items: parsedItems, + errors: errors, + }; + const agentOutputFile = "/tmp/agent_output.json"; + const validatedOutputJson = JSON.stringify(validatedOutput); + try { + fs.mkdirSync("/tmp", { recursive: true }); + fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); + core.info(`Stored validated output to: ${agentOutputFile}`); + core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); + } + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.error(`Failed to write agent output file: ${errorMsg}`); + } + core.setOutput("output", JSON.stringify(validatedOutput)); + core.setOutput("raw_output", outputContent); + const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); + core.info(`output_types: ${outputTypes.join(", ")}`); + core.setOutput("output_types", outputTypes.join(",")); + try { + await core.summary + .addRaw("## Processed Output\n\n") + .addRaw("```json\n") + .addRaw(JSON.stringify(validatedOutput)) + .addRaw("\n```\n") + .write(); + core.info("Successfully wrote processed output to step summary"); } - } - for (const itemType of Object.keys(expectedOutputTypes)) { - const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); - if (minRequired > 0) { - const actualCount = parsedItems.filter(item => item.type === itemType).length; - if (actualCount < minRequired) { - errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); - } + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.warning(`Failed to write to step summary: ${errorMsg}`); } - } - core.info(`Successfully parsed ${parsedItems.length} valid output items`); - const validatedOutput = { - items: parsedItems, - errors: errors, - }; - const agentOutputFile = "/tmp/agent_output.json"; - const validatedOutputJson = JSON.stringify(validatedOutput); - try { - fs.mkdirSync("/tmp", { recursive: true }); - fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); - core.info(`Stored validated output to: ${agentOutputFile}`); - core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.error(`Failed to write agent output file: ${errorMsg}`); - } - core.setOutput("output", JSON.stringify(validatedOutput)); - core.setOutput("raw_output", outputContent); - const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); - core.info(`output_types: ${outputTypes.join(", ")}`); - core.setOutput("output_types", outputTypes.join(",")); - try { - await core.summary - .addRaw("## Processed Output\n\n") - .addRaw("```json\n") - .addRaw(JSON.stringify(validatedOutput)) - .addRaw("\n```\n") - .write(); - core.info("Successfully wrote processed output to step summary"); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.warning(`Failed to write to step summary: ${errorMsg}`); - } } await main(); - name: Upload sanitized agent output diff --git a/.github/workflows/tidy.lock.yml b/.github/workflows/tidy.lock.yml index 20616bb5319..5366e5cc9d0 100644 --- a/.github/workflows/tidy.lock.yml +++ b/.github/workflows/tidy.lock.yml @@ -1370,742 +1370,742 @@ jobs: with: script: | async function main() { - const fs = require("fs"); - function sanitizeContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; - const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; - const allowedDomains = allowedDomainsEnv - ? allowedDomainsEnv - .split(",") - .map(d => d.trim()) - .filter(d => d) - : defaultAllowedDomains; - let sanitized = content; - sanitized = neutralizeMentions(sanitized); - sanitized = removeXmlComments(sanitized); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitizeUrlProtocols(sanitized); - sanitized = sanitizeUrlDomains(sanitized); - const maxLength = 524288; - if (sanitized.length > maxLength) { - sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; - } - const lines = sanitized.split("\n"); - const maxLines = 65000; - if (lines.length > maxLines) { - sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; - } - sanitized = neutralizeBotTriggers(sanitized); - return sanitized.trim(); - function sanitizeUrlDomains(s) { - return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { - const urlAfterProtocol = match.slice(8); - const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); - const isAllowed = allowedDomains.some(allowedDomain => { - const normalizedAllowed = allowedDomain.toLowerCase(); - return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); - }); - return isAllowed ? match : "(redacted)"; - }); - } - function sanitizeUrlProtocols(s) { - return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { - return protocol.toLowerCase() === "https" ? match : "(redacted)"; - }); - } - function neutralizeMentions(s) { - return s.replace( - /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, - (_m, p1, p2) => `${p1}\`@${p2}\`` - ); - } - function removeXmlComments(s) { - return s.replace(//g, "").replace(//g, ""); - } - function neutralizeBotTriggers(s) { - return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); - } - } - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create-issue": - return 1; - case "add-comment": - return 1; - case "create-pull-request": - return 1; - case "create-pull-request-review-comment": - return 1; - case "add-labels": - return 5; - case "update-issue": - return 1; - case "push-to-pull-request-branch": - return 1; - case "create-discussion": - return 1; - case "missing-tool": - return 1000; - case "create-code-scanning-alert": - return 1000; - case "upload-asset": - return 10; - default: - return 1; - } - } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; - } - return 0; - } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; - } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; + const fs = require("fs"); + function sanitizeContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; + const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; + const allowedDomains = allowedDomainsEnv + ? allowedDomainsEnv + .split(",") + .map(d => d.trim()) + .filter(d => d) + : defaultAllowedDomains; + let sanitized = content; + sanitized = neutralizeMentions(sanitized); + sanitized = removeXmlComments(sanitized); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitizeUrlProtocols(sanitized); + sanitized = sanitizeUrlDomains(sanitized); + const maxLength = 524288; + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; + } + const lines = sanitized.split("\n"); + const maxLines = 65000; + if (lines.length > maxLines) { + sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; + } + sanitized = neutralizeBotTriggers(sanitized); + return sanitized.trim(); + function sanitizeUrlDomains(s) { + return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { + const urlAfterProtocol = match.slice(8); + const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); + const isAllowed = allowedDomains.some(allowedDomain => { + const normalizedAllowed = allowedDomain.toLowerCase(); + return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); + }); + return isAllowed ? match : "(redacted)"; + }); + } + function sanitizeUrlProtocols(s) { + return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { + return protocol.toLowerCase() === "https" ? match : "(redacted)"; + }); + } + function neutralizeMentions(s) { + return s.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); + } + function removeXmlComments(s) { + return s.replace(//g, "").replace(//g, ""); + } + function neutralizeBotTriggers(s) { + return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); + } } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + switch (itemType) { + case "create-issue": + return 1; + case "add-comment": + return 1; + case "create-pull-request": + return 1; + case "create-pull-request-review-comment": + return 1; + case "add-labels": + return 5; + case "update-issue": + return 1; + case "push-to-pull-request-branch": + return 1; + case "create-discussion": + return 1; + case "missing-tool": + return 1000; + case "create-code-scanning-alert": + return 1000; + case "upload-asset": + return 10; + default: + return 1; + } } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; + } + return 0; + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } + else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } + else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; } - return { isValid: true, normalizedValue: parsed }; - } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { + if (inputSchema.required && (value === undefined || value === null)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (value === undefined || value === null) { + return { + isValid: true, + normalizedValue: inputSchema.default || undefined, + }; + } + const inputType = inputSchema.type || "string"; + let normalizedValue = value; + switch (inputType) { + case "string": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string`, + }; + } + normalizedValue = sanitizeContent(value); + break; + case "boolean": + if (typeof value !== "boolean") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a boolean`, + }; + } + break; + case "number": + if (typeof value !== "number") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number`, + }; + } + break; + case "choice": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, + }; + } + if (inputSchema.options && !inputSchema.options.includes(value)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, + }; + } + normalizedValue = sanitizeContent(value); + break; + default: + if (typeof value === "string") { + normalizedValue = sanitizeContent(value); + } + break; + } return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, + isValid: true, + normalizedValue, }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { + function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { + const errors = []; + const normalizedItem = { ...item }; + if (!jobConfig.inputs) { + return { + isValid: true, + errors: [], + normalizedItem: item, + }; + } + for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { + const fieldValue = item[fieldName]; + const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); + if (!validation.isValid && validation.error) { + errors.push(validation.error); + } + else if (validation.normalizedValue !== undefined) { + normalizedItem[fieldName] = validation.normalizedValue; + } + } return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, + isValid: errors.length === 0, + errors, + normalizedItem, }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; - } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + function parseJsonWithRepair(jsonStr) { + try { + return JSON.parse(jsonStr); + } + catch (originalError) { + try { + const repairedJson = repairJson(jsonStr); + return JSON.parse(repairedJson); + } + catch (repairError) { + core.info(`invalid input json: ${jsonStr}`); + const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); + const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); + throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); + } + } } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; + const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; + if (!outputFile) { + core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); + core.setOutput("output", ""); + return; } - return { isValid: true }; - } - function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { - if (inputSchema.required && (value === undefined || value === null)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; + if (!fs.existsSync(outputFile)) { + core.info(`Output file does not exist: ${outputFile}`); + core.setOutput("output", ""); + return; } - if (value === undefined || value === null) { - return { - isValid: true, - normalizedValue: inputSchema.default || undefined, - }; + const outputContent = fs.readFileSync(outputFile, "utf8"); + if (outputContent.trim() === "") { + core.info("Output file is empty"); } - const inputType = inputSchema.type || "string"; - let normalizedValue = value; - switch (inputType) { - case "string": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string`, - }; - } - normalizedValue = sanitizeContent(value); - break; - case "boolean": - if (typeof value !== "boolean") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a boolean`, - }; - } - break; - case "number": - if (typeof value !== "number") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number`, - }; + core.info(`Raw output content length: ${outputContent.length}`); + let expectedOutputTypes = {}; + if (safeOutputsConfig) { + try { + expectedOutputTypes = JSON.parse(safeOutputsConfig); + core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); } - break; - case "choice": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, - }; - } - if (inputSchema.options && !inputSchema.options.includes(value)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, - }; + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); } - normalizedValue = sanitizeContent(value); - break; - default: - if (typeof value === "string") { - normalizedValue = sanitizeContent(value); - } - break; } - return { - isValid: true, - normalizedValue, - }; - } - function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { + const lines = outputContent.trim().split("\n"); + const parsedItems = []; const errors = []; - const normalizedItem = { ...item }; - if (!jobConfig.inputs) { - return { - isValid: true, - errors: [], - normalizedItem: item, - }; - } - for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { - const fieldValue = item[fieldName]; - const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); - if (!validation.isValid && validation.error) { - errors.push(validation.error); - } else if (validation.normalizedValue !== undefined) { - normalizedItem[fieldName] = validation.normalizedValue; - } - } - return { - isValid: errors.length === 0, - errors, - normalizedItem, - }; - } - function parseJsonWithRepair(jsonStr) { - try { - return JSON.parse(jsonStr); - } catch (originalError) { - try { - const repairedJson = repairJson(jsonStr); - return JSON.parse(repairedJson); - } catch (repairError) { - core.info(`invalid input json: ${jsonStr}`); - const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); - const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); - throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); - } - } - } - const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; - const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; - if (!outputFile) { - core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); - core.setOutput("output", ""); - return; - } - if (!fs.existsSync(outputFile)) { - core.info(`Output file does not exist: ${outputFile}`); - core.setOutput("output", ""); - return; - } - const outputContent = fs.readFileSync(outputFile, "utf8"); - if (outputContent.trim() === "") { - core.info("Output file is empty"); - } - core.info(`Raw output content length: ${outputContent.length}`); - let expectedOutputTypes = {}; - if (safeOutputsConfig) { - try { - expectedOutputTypes = JSON.parse(safeOutputsConfig); - core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); - } - } - const lines = outputContent.trim().split("\n"); - const parsedItems = []; - const errors = []; - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); - if (line === "") continue; - try { - const item = parseJsonWithRepair(line); - if (item === undefined) { - errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); - continue; - } - if (!item.type) { - errors.push(`Line ${i + 1}: Missing required 'type' field`); - continue; - } - const itemType = item.type; - if (!expectedOutputTypes[itemType]) { - errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); - continue; - } - const typeCount = parsedItems.filter(existing => existing.type === itemType).length; - const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); - if (typeCount >= maxAllowed) { - errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); - continue; - } - core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create-issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); - if (!issueNumValidation.isValid) { - if (issueNumValidation.error) errors.push(issueNumValidation.error); - continue; - } - item.body = sanitizeContent(item.body); - break; - case "create-pull-request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - item.branch = sanitizeContent(item.branch); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); - if (!labelsIssueNumValidation.isValid) { - if (labelsIssueNumValidation.error) errors.push(labelsIssueNumValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label)); - break; - case "update-issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "push-to-pull-request-branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch); - item.message = sanitizeContent(item.message); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push-to-pull-request-branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create-pull-request-review-comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line === "") continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create-pull-request-review-comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create-discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; + try { + const item = parseJsonWithRepair(line); + if (item === undefined) { + errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); + continue; } - item.category = sanitizeContent(item.category); - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - break; - case "missing-tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool); - item.reason = sanitizeContent(item.reason); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); - continue; + if (!item.type) { + errors.push(`Line ${i + 1}: Missing required 'type' field`); + continue; } - item.alternatives = sanitizeContent(item.alternatives); - } - break; - case "upload-asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "create-code-scanning-alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); + const itemType = item.type; + if (!expectedOutputTypes[itemType]) { + errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); + continue; } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); - continue; + const typeCount = parsedItems.filter(existing => existing.type === itemType).length; + const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); + if (typeCount >= maxAllowed) { + errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); + continue; } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; + core.info(`Line ${i + 1}: type '${itemType}'`); + switch (itemType) { + case "create-issue": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-comment": + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); + continue; + } + const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); + if (!issueNumValidation.isValid) { + if (issueNumValidation.error) + errors.push(issueNumValidation.error); + continue; + } + item.body = sanitizeContent(item.body); + break; + case "create-pull-request": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); + continue; + } + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + item.branch = sanitizeContent(item.branch); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-labels": + if (!item.labels || !Array.isArray(item.labels)) { + errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); + continue; + } + if (item.labels.some((label) => typeof label !== "string")) { + errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); + continue; + } + const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); + if (!labelsIssueNumValidation.isValid) { + if (labelsIssueNumValidation.error) + errors.push(labelsIssueNumValidation.error); + continue; + } + item.labels = item.labels.map((label) => sanitizeContent(label)); + break; + case "update-issue": + const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; + if (!hasValidField) { + errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); + continue; + } + if (item.status !== undefined) { + if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { + errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); + continue; + } + } + if (item.title !== undefined) { + if (typeof item.title !== "string") { + errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); + continue; + } + item.title = sanitizeContent(item.title); + } + if (item.body !== undefined) { + if (typeof item.body !== "string") { + errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); + continue; + } + item.body = sanitizeContent(item.body); + } + const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); + if (!updateIssueNumValidation.isValid) { + if (updateIssueNumValidation.error) + errors.push(updateIssueNumValidation.error); + continue; + } + break; + case "push-to-pull-request-branch": + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); + continue; + } + item.branch = sanitizeContent(item.branch); + item.message = sanitizeContent(item.message); + const pushPRNumValidation = validateIssueOrPRNumber(item.pull_request_number, "push-to-pull-request-branch 'pull_request_number'", i + 1); + if (!pushPRNumValidation.isValid) { + if (pushPRNumValidation.error) + errors.push(pushPRNumValidation.error); + continue; + } + break; + case "create-pull-request-review-comment": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); + continue; + } + const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); + if (!lineValidation.isValid) { + if (lineValidation.error) + errors.push(lineValidation.error); + continue; + } + const lineNumber = lineValidation.normalizedValue; + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); + continue; + } + item.body = sanitizeContent(item.body); + const startLineValidation = validateOptionalPositiveInteger(item.start_line, "create-pull-request-review-comment 'start_line'", i + 1); + if (!startLineValidation.isValid) { + if (startLineValidation.error) + errors.push(startLineValidation.error); + continue; + } + if (startLineValidation.normalizedValue !== undefined && + lineNumber !== undefined && + startLineValidation.normalizedValue > lineNumber) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); + continue; + } + if (item.side !== undefined) { + if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); + continue; + } + } + break; + case "create-discussion": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); + continue; + } + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); + continue; + } + item.category = sanitizeContent(item.category); + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + break; + case "missing-tool": + if (!item.tool || typeof item.tool !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); + continue; + } + if (!item.reason || typeof item.reason !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); + continue; + } + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); + continue; + } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "upload-asset": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); + continue; + } + break; + case "create-code-scanning-alert": + if (!item.file || typeof item.file !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); + continue; + } + const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); + if (!alertLineValidation.isValid) { + if (alertLineValidation.error) { + errors.push(alertLineValidation.error); + } + continue; + } + if (!item.severity || typeof item.severity !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); + continue; + } + const allowedSeverities = ["error", "warning", "info", "note"]; + if (!allowedSeverities.includes(item.severity.toLowerCase())) { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}`); + continue; + } + const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); + if (!columnValidation.isValid) { + if (columnValidation.error) + errors.push(columnValidation.error); + continue; + } + if (item.ruleIdSuffix !== undefined) { + if (typeof item.ruleIdSuffix !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); + continue; + } + if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores`); + continue; + } + } + item.severity = item.severity.toLowerCase(); + item.file = sanitizeContent(item.file); + item.severity = sanitizeContent(item.severity); + item.message = sanitizeContent(item.message); + if (item.ruleIdSuffix) { + item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); + } + break; + default: + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); + continue; + } + Object.assign(item, validation.normalizedItem); + } + break; } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file); - item.severity = sanitizeContent(item.severity); - item.message = sanitizeContent(item.message); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); - continue; - } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; + core.info(`Line ${i + 1}: Valid ${itemType} item`); + parsedItems.push(item); + } + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); + } + } + if (errors.length > 0) { + core.warning("Validation errors found:"); + errors.forEach(error => core.warning(` - ${error}`)); + if (parsedItems.length === 0) { + core.setFailed(errors.map(e => ` - ${e}`).join("\n")); + return; + } + } + for (const itemType of Object.keys(expectedOutputTypes)) { + const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); + if (minRequired > 0) { + const actualCount = parsedItems.filter(item => item.type === itemType).length; + if (actualCount < minRequired) { + errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); } - Object.assign(item, validation.normalizedItem); - } - break; - } - core.info(`Line ${i + 1}: Valid ${itemType} item`); - parsedItems.push(item); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); + } } - } - if (errors.length > 0) { - core.warning("Validation errors found:"); - errors.forEach(error => core.warning(` - ${error}`)); - if (parsedItems.length === 0) { - core.setFailed(errors.map(e => ` - ${e}`).join("\n")); - return; + core.info(`Successfully parsed ${parsedItems.length} valid output items`); + const validatedOutput = { + items: parsedItems, + errors: errors, + }; + const agentOutputFile = "/tmp/agent_output.json"; + const validatedOutputJson = JSON.stringify(validatedOutput); + try { + fs.mkdirSync("/tmp", { recursive: true }); + fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); + core.info(`Stored validated output to: ${agentOutputFile}`); + core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); + } + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.error(`Failed to write agent output file: ${errorMsg}`); + } + core.setOutput("output", JSON.stringify(validatedOutput)); + core.setOutput("raw_output", outputContent); + const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); + core.info(`output_types: ${outputTypes.join(", ")}`); + core.setOutput("output_types", outputTypes.join(",")); + try { + await core.summary + .addRaw("## Processed Output\n\n") + .addRaw("```json\n") + .addRaw(JSON.stringify(validatedOutput)) + .addRaw("\n```\n") + .write(); + core.info("Successfully wrote processed output to step summary"); } - } - for (const itemType of Object.keys(expectedOutputTypes)) { - const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); - if (minRequired > 0) { - const actualCount = parsedItems.filter(item => item.type === itemType).length; - if (actualCount < minRequired) { - errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); - } + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.warning(`Failed to write to step summary: ${errorMsg}`); } - } - core.info(`Successfully parsed ${parsedItems.length} valid output items`); - const validatedOutput = { - items: parsedItems, - errors: errors, - }; - const agentOutputFile = "/tmp/agent_output.json"; - const validatedOutputJson = JSON.stringify(validatedOutput); - try { - fs.mkdirSync("/tmp", { recursive: true }); - fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); - core.info(`Stored validated output to: ${agentOutputFile}`); - core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.error(`Failed to write agent output file: ${errorMsg}`); - } - core.setOutput("output", JSON.stringify(validatedOutput)); - core.setOutput("raw_output", outputContent); - const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); - core.info(`output_types: ${outputTypes.join(", ")}`); - core.setOutput("output_types", outputTypes.join(",")); - try { - await core.summary - .addRaw("## Processed Output\n\n") - .addRaw("```json\n") - .addRaw(JSON.stringify(validatedOutput)) - .addRaw("\n```\n") - .write(); - core.info("Successfully wrote processed output to step summary"); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.warning(`Failed to write to step summary: ${errorMsg}`); - } } await main(); - name: Upload sanitized agent output diff --git a/pkg/workflow/js/add_labels.js b/pkg/workflow/js/add_labels.js index 3bb1f76a682..b4c6e1d1062 100644 --- a/pkg/workflow/js/add_labels.js +++ b/pkg/workflow/js/add_labels.js @@ -1,200 +1,203 @@ function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace( - /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, - (_m, p1, p2) => `${p1}\`@${p2}\`` - ); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); + if (!content || typeof content !== "string") { + return ""; + } + let sanitized = content.trim(); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); + sanitized = sanitized.replace(/[<>&'"]/g, ""); + return sanitized.trim(); } async function main() { - const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; - if (!outputContent) { - core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); - return; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return; - } - core.debug(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.warning("No valid items found in agent output"); - return; - } - const labelsItem = validatedOutput.items.find(item => item.type === "add-labels"); - if (!labelsItem) { - core.warning("No add-labels item found in agent output"); - return; - } - core.debug(`Found add-labels item with ${labelsItem.labels.length} labels`); - if (process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Add Labels Preview\n\n"; - summaryContent += "The following labels would be added if staged mode was disabled:\n\n"; - if (labelsItem.issue_number) { - summaryContent += `**Target Issue:** #${labelsItem.issue_number}\n\n`; - } else { - summaryContent += `**Target:** Current issue/PR\n\n`; - } - if (labelsItem.labels && labelsItem.labels.length > 0) { - summaryContent += `**Labels to add:** ${labelsItem.labels.join(", ")}\n\n`; - } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Label addition preview written to step summary"); - return; - } - const allowedLabelsEnv = process.env.GITHUB_AW_LABELS_ALLOWED?.trim(); - const allowedLabels = allowedLabelsEnv - ? allowedLabelsEnv - .split(",") - .map(label => label.trim()) - .filter(label => label) - : undefined; - if (allowedLabels) { - core.debug(`Allowed labels: ${JSON.stringify(allowedLabels)}`); - } else { - core.debug("No label restrictions - any labels are allowed"); - } - const maxCountEnv = process.env.GITHUB_AW_LABELS_MAX_COUNT; - const maxCount = maxCountEnv ? parseInt(maxCountEnv, 10) : 3; - if (isNaN(maxCount) || maxCount < 1) { - core.setFailed(`Invalid max value: ${maxCountEnv}. Must be a positive integer`); - return; - } - core.debug(`Max count: ${maxCount}`); - const labelsTarget = process.env.GITHUB_AW_LABELS_TARGET || "triggering"; - core.info(`Labels target configuration: ${labelsTarget}`); - const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; - const isPRContext = - context.eventName === "pull_request" || - context.eventName === "pull_request_review" || - context.eventName === "pull_request_review_comment"; - if (labelsTarget === "triggering" && !isIssueContext && !isPRContext) { - core.info('Target is "triggering" but not running in issue or pull request context, skipping label addition'); - return; - } - let issueNumber; - let contextType; - if (labelsTarget === "*") { - if (labelsItem.issue_number) { - issueNumber = typeof labelsItem.issue_number === "number" ? labelsItem.issue_number : parseInt(String(labelsItem.issue_number), 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - core.setFailed(`Invalid issue number specified: ${labelsItem.issue_number}`); + const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; + if (!outputContent) { + core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); return; - } - contextType = "issue"; - } else { - core.setFailed('Target is "*" but no issue_number specified in labels item'); - return; - } - } else if (labelsTarget && labelsTarget !== "triggering") { - issueNumber = parseInt(labelsTarget, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - core.setFailed(`Invalid issue number in target configuration: ${labelsTarget}`); - return; - } - contextType = "issue"; - } else { - if (isIssueContext) { - if (context.payload.issue) { - issueNumber = context.payload.issue.number; - contextType = "issue"; - } else { - core.setFailed("Issue context detected but no issue found in payload"); + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return; + } + core.debug(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } + catch (error) { + core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); + return; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.warning("No valid items found in agent output"); return; - } - } else if (isPRContext) { - if (context.payload.pull_request) { - issueNumber = context.payload.pull_request.number; - contextType = "pull request"; - } else { - core.setFailed("Pull request context detected but no pull request found in payload"); + } + const labelsItem = validatedOutput.items.find(item => item.type === "add-labels"); + if (!labelsItem) { + core.warning("No add-labels item found in agent output"); + return; + } + core.debug(`Found add-labels item with ${labelsItem.labels.length} labels`); + if (process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true") { + let summaryContent = "## 🎭 Staged Mode: Add Labels Preview\n\n"; + summaryContent += "The following labels would be added if staged mode was disabled:\n\n"; + if (labelsItem.issue_number) { + summaryContent += `**Target Issue:** #${labelsItem.issue_number}\n\n`; + } + else { + summaryContent += `**Target:** Current issue/PR\n\n`; + } + if (labelsItem.labels && labelsItem.labels.length > 0) { + summaryContent += `**Labels to add:** ${labelsItem.labels.join(", ")}\n\n`; + } + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Label addition preview written to step summary"); + return; + } + const allowedLabelsEnv = process.env.GITHUB_AW_LABELS_ALLOWED?.trim(); + const allowedLabels = allowedLabelsEnv + ? allowedLabelsEnv + .split(",") + .map(label => label.trim()) + .filter(label => label) + : undefined; + if (allowedLabels) { + core.debug(`Allowed labels: ${JSON.stringify(allowedLabels)}`); + } + else { + core.debug("No label restrictions - any labels are allowed"); + } + const maxCountEnv = process.env.GITHUB_AW_LABELS_MAX_COUNT; + const maxCount = maxCountEnv ? parseInt(maxCountEnv, 10) : 3; + if (isNaN(maxCount) || maxCount < 1) { + core.setFailed(`Invalid max value: ${maxCountEnv}. Must be a positive integer`); + return; + } + core.debug(`Max count: ${maxCount}`); + const labelsTarget = process.env.GITHUB_AW_LABELS_TARGET || "triggering"; + core.info(`Labels target configuration: ${labelsTarget}`); + const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; + const isPRContext = context.eventName === "pull_request" || + context.eventName === "pull_request_review" || + context.eventName === "pull_request_review_comment"; + if (labelsTarget === "triggering" && !isIssueContext && !isPRContext) { + core.info('Target is "triggering" but not running in issue or pull request context, skipping label addition'); return; - } - } - } - if (!issueNumber) { - core.setFailed("Could not determine issue or pull request number"); - return; - } - const requestedLabels = labelsItem.labels || []; - core.debug(`Requested labels: ${JSON.stringify(requestedLabels)}`); - for (const label of requestedLabels) { - if (label && typeof label === "string" && label.startsWith("-")) { - core.setFailed(`Label removal is not permitted. Found line starting with '-': ${label}`); - return; - } - } - let validLabels; - if (allowedLabels) { - validLabels = requestedLabels.filter(label => allowedLabels.includes(label)); - } else { - validLabels = requestedLabels; - } - let uniqueLabels = validLabels - .filter(label => label != null && label !== false && label !== 0) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - if (uniqueLabels.length > maxCount) { - core.debug(`too many labels, keep ${maxCount}`); - uniqueLabels = uniqueLabels.slice(0, maxCount); - } - if (uniqueLabels.length === 0) { - core.info("No labels to add"); - core.setOutput("labels_added", ""); - await core.summary - .addRaw( - ` + } + let issueNumber; + let contextType; + if (labelsTarget === "*") { + if (labelsItem.issue_number) { + issueNumber = typeof labelsItem.issue_number === "number" ? labelsItem.issue_number : parseInt(String(labelsItem.issue_number), 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + core.setFailed(`Invalid issue number specified: ${labelsItem.issue_number}`); + return; + } + contextType = "issue"; + } + else { + core.setFailed('Target is "*" but no issue_number specified in labels item'); + return; + } + } + else if (labelsTarget && labelsTarget !== "triggering") { + issueNumber = parseInt(labelsTarget, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + core.setFailed(`Invalid issue number in target configuration: ${labelsTarget}`); + return; + } + contextType = "issue"; + } + else { + if (isIssueContext) { + if (context.payload.issue) { + issueNumber = context.payload.issue.number; + contextType = "issue"; + } + else { + core.setFailed("Issue context detected but no issue found in payload"); + return; + } + } + else if (isPRContext) { + if (context.payload.pull_request) { + issueNumber = context.payload.pull_request.number; + contextType = "pull request"; + } + else { + core.setFailed("Pull request context detected but no pull request found in payload"); + return; + } + } + } + if (!issueNumber) { + core.setFailed("Could not determine issue or pull request number"); + return; + } + const requestedLabels = labelsItem.labels || []; + core.debug(`Requested labels: ${JSON.stringify(requestedLabels)}`); + for (const label of requestedLabels) { + if (label && typeof label === "string" && label.startsWith("-")) { + core.setFailed(`Label removal is not permitted. Found line starting with '-': ${label}`); + return; + } + } + let validLabels; + if (allowedLabels) { + validLabels = requestedLabels.filter(label => allowedLabels.includes(label)); + } + else { + validLabels = requestedLabels; + } + let uniqueLabels = validLabels + .filter(label => label != null && label !== false && label !== 0) + .map(label => String(label).trim()) + .filter(label => label) + .map(label => sanitizeLabelContent(label)) + .filter(label => label) + .map(label => (label.length > 64 ? label.substring(0, 64) : label)) + .filter((label, index, arr) => arr.indexOf(label) === index); + if (uniqueLabels.length > maxCount) { + core.debug(`too many labels, keep ${maxCount}`); + uniqueLabels = uniqueLabels.slice(0, maxCount); + } + if (uniqueLabels.length === 0) { + core.info("No labels to add"); + core.setOutput("labels_added", ""); + await core.summary + .addRaw(` ## Label Addition No labels were added (no valid labels found in agent output). -` - ) - .write(); - return; - } - core.info(`Adding ${uniqueLabels.length} labels to ${contextType} #${issueNumber}: ${JSON.stringify(uniqueLabels)}`); - try { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - labels: uniqueLabels, - }); - core.info(`Successfully added ${uniqueLabels.length} labels to ${contextType} #${issueNumber}`); - core.setOutput("labels_added", uniqueLabels.join("\n")); - const labelsListMarkdown = uniqueLabels.map(label => `- \`${label}\``).join("\n"); - await core.summary - .addRaw( - ` +`) + .write(); + return; + } + core.info(`Adding ${uniqueLabels.length} labels to ${contextType} #${issueNumber}: ${JSON.stringify(uniqueLabels)}`); + try { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + labels: uniqueLabels, + }); + core.info(`Successfully added ${uniqueLabels.length} labels to ${contextType} #${issueNumber}`); + core.setOutput("labels_added", uniqueLabels.join("\n")); + const labelsListMarkdown = uniqueLabels.map(label => `- \`${label}\``).join("\n"); + await core.summary + .addRaw(` ## Label Addition Successfully added ${uniqueLabels.length} label(s) to ${contextType} #${issueNumber}: ${labelsListMarkdown} -` - ) - .write(); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - core.error(`Failed to add labels: ${errorMessage}`); - core.setFailed(`Failed to add labels: ${errorMessage}`); - } +`) + .write(); + } + catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + core.error(`Failed to add labels: ${errorMessage}`); + core.setFailed(`Failed to add labels: ${errorMessage}`); + } } await main(); diff --git a/pkg/workflow/js/collect_ndjson_output.js b/pkg/workflow/js/collect_ndjson_output.js index 7f090702b0a..19e898c5d13 100644 --- a/pkg/workflow/js/collect_ndjson_output.js +++ b/pkg/workflow/js/collect_ndjson_output.js @@ -1,739 +1,739 @@ async function main() { - const fs = require("fs"); - function sanitizeContent(content) { - if (!content || typeof content !== "string") { - return ""; + const fs = require("fs"); + function sanitizeContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; + const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; + const allowedDomains = allowedDomainsEnv + ? allowedDomainsEnv + .split(",") + .map(d => d.trim()) + .filter(d => d) + : defaultAllowedDomains; + let sanitized = content; + sanitized = neutralizeMentions(sanitized); + sanitized = removeXmlComments(sanitized); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitizeUrlProtocols(sanitized); + sanitized = sanitizeUrlDomains(sanitized); + const maxLength = 524288; + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; + } + const lines = sanitized.split("\n"); + const maxLines = 65000; + if (lines.length > maxLines) { + sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; + } + sanitized = neutralizeBotTriggers(sanitized); + return sanitized.trim(); + function sanitizeUrlDomains(s) { + return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { + const urlAfterProtocol = match.slice(8); + const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); + const isAllowed = allowedDomains.some(allowedDomain => { + const normalizedAllowed = allowedDomain.toLowerCase(); + return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); + }); + return isAllowed ? match : "(redacted)"; + }); + } + function sanitizeUrlProtocols(s) { + return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { + return protocol.toLowerCase() === "https" ? match : "(redacted)"; + }); + } + function neutralizeMentions(s) { + return s.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); + } + function removeXmlComments(s) { + return s.replace(//g, "").replace(//g, ""); + } + function neutralizeBotTriggers(s) { + return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); + } } - const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; - const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; - const allowedDomains = allowedDomainsEnv - ? allowedDomainsEnv - .split(",") - .map(d => d.trim()) - .filter(d => d) - : defaultAllowedDomains; - let sanitized = content; - sanitized = neutralizeMentions(sanitized); - sanitized = removeXmlComments(sanitized); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitizeUrlProtocols(sanitized); - sanitized = sanitizeUrlDomains(sanitized); - const maxLength = 524288; - if (sanitized.length > maxLength) { - sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + switch (itemType) { + case "create-issue": + return 1; + case "add-comment": + return 1; + case "create-pull-request": + return 1; + case "create-pull-request-review-comment": + return 1; + case "add-labels": + return 5; + case "update-issue": + return 1; + case "push-to-pull-request-branch": + return 1; + case "create-discussion": + return 1; + case "missing-tool": + return 1000; + case "create-code-scanning-alert": + return 1000; + case "upload-asset": + return 10; + default: + return 1; + } } - const lines = sanitized.split("\n"); - const maxLines = 65000; - if (lines.length > maxLines) { - sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; + } + return 0; } - sanitized = neutralizeBotTriggers(sanitized); - return sanitized.trim(); - function sanitizeUrlDomains(s) { - return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { - const urlAfterProtocol = match.slice(8); - const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); - const isAllowed = allowedDomains.some(allowedDomain => { - const normalizedAllowed = allowedDomain.toLowerCase(); - return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); }); - return isAllowed ? match : "(redacted)"; - }); - } - function sanitizeUrlProtocols(s) { - return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { - return protocol.toLowerCase() === "https" ? match : "(redacted)"; - }); - } - function neutralizeMentions(s) { - return s.replace( - /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, - (_m, p1, p2) => `${p1}\`@${p2}\`` - ); - } - function removeXmlComments(s) { - return s.replace(//g, "").replace(//g, ""); - } - function neutralizeBotTriggers(s) { - return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); - } - } - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create-issue": - return 1; - case "add-comment": - return 1; - case "create-pull-request": - return 1; - case "create-pull-request-review-comment": - return 1; - case "add-labels": - return 5; - case "update-issue": - return 1; - case "push-to-pull-request-branch": - return 1; - case "create-discussion": - return 1; - case "missing-tool": - return 1000; - case "create-code-scanning-alert": - return 1000; - case "upload-asset": - return 10; - default: - return 1; - } - } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; - } - return 0; - } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; - } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } + else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } + else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; } - return { isValid: true, normalizedValue: parsed }; - } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { + function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { + if (inputSchema.required && (value === undefined || value === null)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (value === undefined || value === null) { + return { + isValid: true, + normalizedValue: inputSchema.default || undefined, + }; + } + const inputType = inputSchema.type || "string"; + let normalizedValue = value; + switch (inputType) { + case "string": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string`, + }; + } + normalizedValue = sanitizeContent(value); + break; + case "boolean": + if (typeof value !== "boolean") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a boolean`, + }; + } + break; + case "number": + if (typeof value !== "number") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number`, + }; + } + break; + case "choice": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, + }; + } + if (inputSchema.options && !inputSchema.options.includes(value)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, + }; + } + normalizedValue = sanitizeContent(value); + break; + default: + if (typeof value === "string") { + normalizedValue = sanitizeContent(value); + } + break; + } return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, + isValid: true, + normalizedValue, }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { + function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { + const errors = []; + const normalizedItem = { ...item }; + if (!jobConfig.inputs) { + return { + isValid: true, + errors: [], + normalizedItem: item, + }; + } + for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { + const fieldValue = item[fieldName]; + const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); + if (!validation.isValid && validation.error) { + errors.push(validation.error); + } + else if (validation.normalizedValue !== undefined) { + normalizedItem[fieldName] = validation.normalizedValue; + } + } return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, + isValid: errors.length === 0, + errors, + normalizedItem, }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; - } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + function parseJsonWithRepair(jsonStr) { + try { + return JSON.parse(jsonStr); + } + catch (originalError) { + try { + const repairedJson = repairJson(jsonStr); + return JSON.parse(repairedJson); + } + catch (repairError) { + core.info(`invalid input json: ${jsonStr}`); + const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); + const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); + throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); + } + } } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; + const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; + if (!outputFile) { + core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); + core.setOutput("output", ""); + return; } - return { isValid: true }; - } - function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { - if (inputSchema.required && (value === undefined || value === null)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; + if (!fs.existsSync(outputFile)) { + core.info(`Output file does not exist: ${outputFile}`); + core.setOutput("output", ""); + return; } - if (value === undefined || value === null) { - return { - isValid: true, - normalizedValue: inputSchema.default || undefined, - }; + const outputContent = fs.readFileSync(outputFile, "utf8"); + if (outputContent.trim() === "") { + core.info("Output file is empty"); } - const inputType = inputSchema.type || "string"; - let normalizedValue = value; - switch (inputType) { - case "string": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string`, - }; - } - normalizedValue = sanitizeContent(value); - break; - case "boolean": - if (typeof value !== "boolean") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a boolean`, - }; - } - break; - case "number": - if (typeof value !== "number") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number`, - }; - } - break; - case "choice": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, - }; - } - if (inputSchema.options && !inputSchema.options.includes(value)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, - }; - } - normalizedValue = sanitizeContent(value); - break; - default: - if (typeof value === "string") { - normalizedValue = sanitizeContent(value); - } - break; + core.info(`Raw output content length: ${outputContent.length}`); + let expectedOutputTypes = {}; + if (safeOutputsConfig) { + try { + expectedOutputTypes = JSON.parse(safeOutputsConfig); + core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); + } + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); + } } - return { - isValid: true, - normalizedValue, - }; - } - function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { + const lines = outputContent.trim().split("\n"); + const parsedItems = []; const errors = []; - const normalizedItem = { ...item }; - if (!jobConfig.inputs) { - return { - isValid: true, - errors: [], - normalizedItem: item, - }; - } - for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { - const fieldValue = item[fieldName]; - const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); - if (!validation.isValid && validation.error) { - errors.push(validation.error); - } else if (validation.normalizedValue !== undefined) { - normalizedItem[fieldName] = validation.normalizedValue; - } - } - return { - isValid: errors.length === 0, - errors, - normalizedItem, - }; - } - function parseJsonWithRepair(jsonStr) { - try { - return JSON.parse(jsonStr); - } catch (originalError) { - try { - const repairedJson = repairJson(jsonStr); - return JSON.parse(repairedJson); - } catch (repairError) { - core.info(`invalid input json: ${jsonStr}`); - const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); - const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); - throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); - } - } - } - const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; - const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; - if (!outputFile) { - core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); - core.setOutput("output", ""); - return; - } - if (!fs.existsSync(outputFile)) { - core.info(`Output file does not exist: ${outputFile}`); - core.setOutput("output", ""); - return; - } - const outputContent = fs.readFileSync(outputFile, "utf8"); - if (outputContent.trim() === "") { - core.info("Output file is empty"); - } - core.info(`Raw output content length: ${outputContent.length}`); - let expectedOutputTypes = {}; - if (safeOutputsConfig) { - try { - expectedOutputTypes = JSON.parse(safeOutputsConfig); - core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); - } - } - const lines = outputContent.trim().split("\n"); - const parsedItems = []; - const errors = []; - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); - if (line === "") continue; - try { - const item = parseJsonWithRepair(line); - if (item === undefined) { - errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); - continue; - } - if (!item.type) { - errors.push(`Line ${i + 1}: Missing required 'type' field`); - continue; - } - const itemType = item.type; - if (!expectedOutputTypes[itemType]) { - errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); - continue; - } - const typeCount = parsedItems.filter(existing => existing.type === itemType).length; - const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); - if (typeCount >= maxAllowed) { - errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); - continue; - } - core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create-issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); - if (!issueNumValidation.isValid) { - if (issueNumValidation.error) errors.push(issueNumValidation.error); - continue; - } - item.body = sanitizeContent(item.body); - break; - case "create-pull-request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - item.branch = sanitizeContent(item.branch); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); - if (!labelsIssueNumValidation.isValid) { - if (labelsIssueNumValidation.error) errors.push(labelsIssueNumValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label)); - break; - case "update-issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line === "") + continue; + try { + const item = parseJsonWithRepair(line); + if (item === undefined) { + errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); + continue; } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); - continue; + if (!item.type) { + errors.push(`Line ${i + 1}: Missing required 'type' field`); + continue; } - item.title = sanitizeContent(item.title); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); - continue; + const itemType = item.type; + if (!expectedOutputTypes[itemType]) { + errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); + continue; } - item.body = sanitizeContent(item.body); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "push-to-pull-request-branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch); - item.message = sanitizeContent(item.message); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push-to-pull-request-branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create-pull-request-review-comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create-pull-request-review-comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create-discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category); - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - break; - case "missing-tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool); - item.reason = sanitizeContent(item.reason); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives); - } - break; - case "upload-asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "create-code-scanning-alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); - continue; + const typeCount = parsedItems.filter(existing => existing.type === itemType).length; + const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); + if (typeCount >= maxAllowed) { + errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); + continue; } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; + core.info(`Line ${i + 1}: type '${itemType}'`); + switch (itemType) { + case "create-issue": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-comment": + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); + continue; + } + const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); + if (!issueNumValidation.isValid) { + if (issueNumValidation.error) + errors.push(issueNumValidation.error); + continue; + } + item.body = sanitizeContent(item.body); + break; + case "create-pull-request": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); + continue; + } + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + item.branch = sanitizeContent(item.branch); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-labels": + if (!item.labels || !Array.isArray(item.labels)) { + errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); + continue; + } + if (item.labels.some((label) => typeof label !== "string")) { + errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); + continue; + } + const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); + if (!labelsIssueNumValidation.isValid) { + if (labelsIssueNumValidation.error) + errors.push(labelsIssueNumValidation.error); + continue; + } + item.labels = item.labels.map((label) => sanitizeContent(label)); + break; + case "update-issue": + const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; + if (!hasValidField) { + errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); + continue; + } + if (item.status !== undefined) { + if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { + errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); + continue; + } + } + if (item.title !== undefined) { + if (typeof item.title !== "string") { + errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); + continue; + } + item.title = sanitizeContent(item.title); + } + if (item.body !== undefined) { + if (typeof item.body !== "string") { + errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); + continue; + } + item.body = sanitizeContent(item.body); + } + const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); + if (!updateIssueNumValidation.isValid) { + if (updateIssueNumValidation.error) + errors.push(updateIssueNumValidation.error); + continue; + } + break; + case "push-to-pull-request-branch": + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); + continue; + } + item.branch = sanitizeContent(item.branch); + item.message = sanitizeContent(item.message); + const pushPRNumValidation = validateIssueOrPRNumber(item.pull_request_number, "push-to-pull-request-branch 'pull_request_number'", i + 1); + if (!pushPRNumValidation.isValid) { + if (pushPRNumValidation.error) + errors.push(pushPRNumValidation.error); + continue; + } + break; + case "create-pull-request-review-comment": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); + continue; + } + const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); + if (!lineValidation.isValid) { + if (lineValidation.error) + errors.push(lineValidation.error); + continue; + } + const lineNumber = lineValidation.normalizedValue; + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); + continue; + } + item.body = sanitizeContent(item.body); + const startLineValidation = validateOptionalPositiveInteger(item.start_line, "create-pull-request-review-comment 'start_line'", i + 1); + if (!startLineValidation.isValid) { + if (startLineValidation.error) + errors.push(startLineValidation.error); + continue; + } + if (startLineValidation.normalizedValue !== undefined && + lineNumber !== undefined && + startLineValidation.normalizedValue > lineNumber) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); + continue; + } + if (item.side !== undefined) { + if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); + continue; + } + } + break; + case "create-discussion": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); + continue; + } + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); + continue; + } + item.category = sanitizeContent(item.category); + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + break; + case "missing-tool": + if (!item.tool || typeof item.tool !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); + continue; + } + if (!item.reason || typeof item.reason !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); + continue; + } + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); + continue; + } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "upload-asset": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); + continue; + } + break; + case "create-code-scanning-alert": + if (!item.file || typeof item.file !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); + continue; + } + const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); + if (!alertLineValidation.isValid) { + if (alertLineValidation.error) { + errors.push(alertLineValidation.error); + } + continue; + } + if (!item.severity || typeof item.severity !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); + continue; + } + const allowedSeverities = ["error", "warning", "info", "note"]; + if (!allowedSeverities.includes(item.severity.toLowerCase())) { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}`); + continue; + } + const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); + if (!columnValidation.isValid) { + if (columnValidation.error) + errors.push(columnValidation.error); + continue; + } + if (item.ruleIdSuffix !== undefined) { + if (typeof item.ruleIdSuffix !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); + continue; + } + if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores`); + continue; + } + } + item.severity = item.severity.toLowerCase(); + item.file = sanitizeContent(item.file); + item.severity = sanitizeContent(item.severity); + item.message = sanitizeContent(item.message); + if (item.ruleIdSuffix) { + item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); + } + break; + default: + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); + continue; + } + Object.assign(item, validation.normalizedItem); + } + break; } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file); - item.severity = sanitizeContent(item.severity); - item.message = sanitizeContent(item.message); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); - continue; - } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; + core.info(`Line ${i + 1}: Valid ${itemType} item`); + parsedItems.push(item); + } + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); + } + } + if (errors.length > 0) { + core.warning("Validation errors found:"); + errors.forEach(error => core.warning(` - ${error}`)); + if (parsedItems.length === 0) { + core.setFailed(errors.map(e => ` - ${e}`).join("\n")); + return; + } + } + for (const itemType of Object.keys(expectedOutputTypes)) { + const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); + if (minRequired > 0) { + const actualCount = parsedItems.filter(item => item.type === itemType).length; + if (actualCount < minRequired) { + errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); } - Object.assign(item, validation.normalizedItem); - } - break; - } - core.info(`Line ${i + 1}: Valid ${itemType} item`); - parsedItems.push(item); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); + } + } + core.info(`Successfully parsed ${parsedItems.length} valid output items`); + const validatedOutput = { + items: parsedItems, + errors: errors, + }; + const agentOutputFile = "/tmp/agent_output.json"; + const validatedOutputJson = JSON.stringify(validatedOutput); + try { + fs.mkdirSync("/tmp", { recursive: true }); + fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); + core.info(`Stored validated output to: ${agentOutputFile}`); + core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); } - } - if (errors.length > 0) { - core.warning("Validation errors found:"); - errors.forEach(error => core.warning(` - ${error}`)); - if (parsedItems.length === 0) { - core.setFailed(errors.map(e => ` - ${e}`).join("\n")); - return; + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.error(`Failed to write agent output file: ${errorMsg}`); + } + core.setOutput("output", JSON.stringify(validatedOutput)); + core.setOutput("raw_output", outputContent); + const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); + core.info(`output_types: ${outputTypes.join(", ")}`); + core.setOutput("output_types", outputTypes.join(",")); + try { + await core.summary + .addRaw("## Processed Output\n\n") + .addRaw("```json\n") + .addRaw(JSON.stringify(validatedOutput)) + .addRaw("\n```\n") + .write(); + core.info("Successfully wrote processed output to step summary"); } - } - for (const itemType of Object.keys(expectedOutputTypes)) { - const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); - if (minRequired > 0) { - const actualCount = parsedItems.filter(item => item.type === itemType).length; - if (actualCount < minRequired) { - errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); - } + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.warning(`Failed to write to step summary: ${errorMsg}`); } - } - core.info(`Successfully parsed ${parsedItems.length} valid output items`); - const validatedOutput = { - items: parsedItems, - errors: errors, - }; - const agentOutputFile = "/tmp/agent_output.json"; - const validatedOutputJson = JSON.stringify(validatedOutput); - try { - fs.mkdirSync("/tmp", { recursive: true }); - fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); - core.info(`Stored validated output to: ${agentOutputFile}`); - core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.error(`Failed to write agent output file: ${errorMsg}`); - } - core.setOutput("output", JSON.stringify(validatedOutput)); - core.setOutput("raw_output", outputContent); - const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); - core.info(`output_types: ${outputTypes.join(", ")}`); - core.setOutput("output_types", outputTypes.join(",")); - try { - await core.summary - .addRaw("## Processed Output\n\n") - .addRaw("```json\n") - .addRaw(JSON.stringify(validatedOutput)) - .addRaw("\n```\n") - .write(); - core.info("Successfully wrote processed output to step summary"); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.warning(`Failed to write to step summary: ${errorMsg}`); - } } await main(); diff --git a/pkg/workflow/js/create_discussion.js b/pkg/workflow/js/create_discussion.js index 26779de007b..519ae0ee5f8 100644 --- a/pkg/workflow/js/create_discussion.js +++ b/pkg/workflow/js/create_discussion.js @@ -1,54 +1,55 @@ async function main() { - const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; - if (!outputContent) { - core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); - return; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return; - } - core.debug(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.warning("No valid items found in agent output"); - return; - } - const createDiscussionItems = validatedOutput.items.filter(item => item.type === "create-discussion"); - if (createDiscussionItems.length === 0) { - core.warning("No create-discussion items found in agent output"); - return; - } - core.debug(`Found ${createDiscussionItems.length} create-discussion item(s)`); - if (process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Create Discussions Preview\n\n"; - summaryContent += "The following discussions would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createDiscussionItems.length; i++) { - const item = createDiscussionItems[i]; - summaryContent += `### Discussion ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; - } - if (item.category_id) { - summaryContent += `**Category ID:** ${item.category_id}\n\n`; - } - summaryContent += "---\n\n"; + const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; + if (!outputContent) { + core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); + return; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return; + } + core.debug(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } + catch (error) { + core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); + return; } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Discussion creation preview written to step summary"); - return; - } - let discussionCategories = []; - let repositoryId = undefined; - try { - const repositoryQuery = ` + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.warning("No valid items found in agent output"); + return; + } + const createDiscussionItems = validatedOutput.items.filter(item => item.type === "create-discussion"); + if (createDiscussionItems.length === 0) { + core.warning("No create-discussion items found in agent output"); + return; + } + core.debug(`Found ${createDiscussionItems.length} create-discussion item(s)`); + if (process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true") { + let summaryContent = "## 🎭 Staged Mode: Create Discussions Preview\n\n"; + summaryContent += "The following discussions would be created if staged mode was disabled:\n\n"; + for (let i = 0; i < createDiscussionItems.length; i++) { + const item = createDiscussionItems[i]; + summaryContent += `### Discussion ${i + 1}\n`; + summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; + if (item.body) { + summaryContent += `**Body:**\n${item.body}\n\n`; + } + if (item.category_id) { + summaryContent += `**Category ID:** ${item.category_id}\n\n`; + } + summaryContent += "---\n\n"; + } + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Discussion creation preview written to step summary"); + return; + } + let discussionCategories = []; + let repositoryId = undefined; + try { + const repositoryQuery = ` query($owner: String!, $repo: String!) { repository(owner: $owner, name: $repo) { id @@ -63,68 +64,66 @@ async function main() { } } `; - const queryResult = await github.graphql(repositoryQuery, { - owner: context.repo.owner, - repo: context.repo.repo, - }); - if (!queryResult || !queryResult.repository) throw new Error("Failed to fetch repository information via GraphQL"); - repositoryId = queryResult.repository.id; - discussionCategories = queryResult.repository.discussionCategories.nodes || []; - core.info(`Available categories: ${JSON.stringify(discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if ( - errorMessage.includes("Not Found") || - errorMessage.includes("not found") || - errorMessage.includes("Could not resolve to a Repository") - ) { - core.info("⚠ Cannot create discussions: Discussions are not enabled for this repository"); - core.info("Consider enabling discussions in repository settings if you want to create discussions automatically"); - return; + const queryResult = await github.graphql(repositoryQuery, { + owner: context.repo.owner, + repo: context.repo.repo, + }); + if (!queryResult || !queryResult.repository) + throw new Error("Failed to fetch repository information via GraphQL"); + repositoryId = queryResult.repository.id; + discussionCategories = queryResult.repository.discussionCategories.nodes || []; + core.info(`Available categories: ${JSON.stringify(discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`); } - core.error(`Failed to get discussion categories: ${errorMessage}`); - throw error; - } - let categoryId = process.env.GITHUB_AW_DISCUSSION_CATEGORY_ID; - if (!categoryId && discussionCategories.length > 0) { - categoryId = discussionCategories[0].id; - core.info(`No category-id specified, using default category: ${discussionCategories[0].name} (${categoryId})`); - } - if (!categoryId) { - core.error("No discussion category available and none specified in configuration"); - throw new Error("Discussion category is required but not available"); - } - if (!repositoryId) { - core.error("Repository ID is required for creating discussions"); - throw new Error("Repository ID is required but not available"); - } - const createdDiscussions = []; - for (let i = 0; i < createDiscussionItems.length; i++) { - const createDiscussionItem = createDiscussionItems[i]; - core.info( - `Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body.length}` - ); - let title = createDiscussionItem.title ? createDiscussionItem.title.trim() : ""; - let bodyLines = createDiscussionItem.body.split("\n"); - if (!title) { - title = createDiscussionItem.body || "Agent Output"; + catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage.includes("Not Found") || + errorMessage.includes("not found") || + errorMessage.includes("Could not resolve to a Repository")) { + core.info("⚠ Cannot create discussions: Discussions are not enabled for this repository"); + core.info("Consider enabling discussions in repository settings if you want to create discussions automatically"); + return; + } + core.error(`Failed to get discussion categories: ${errorMessage}`); + throw error; } - const titlePrefix = process.env.GITHUB_AW_DISCUSSION_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; + let categoryId = process.env.GITHUB_AW_DISCUSSION_CATEGORY_ID; + if (!categoryId && discussionCategories.length > 0) { + categoryId = discussionCategories[0].id; + core.info(`No category-id specified, using default category: ${discussionCategories[0].name} (${categoryId})`); } - const workflowName = process.env.GITHUB_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const runUrl = context.payload.repository - ? `${context.payload.repository.html_url}/actions/runs/${runId}` - : `https://github.com/actions/runs/${runId}`; - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating discussion with title: ${title}`); - core.info(`Category ID: ${categoryId}`); - core.info(`Body length: ${body.length}`); - try { - const createDiscussionMutation = ` + if (!categoryId) { + core.error("No discussion category available and none specified in configuration"); + throw new Error("Discussion category is required but not available"); + } + if (!repositoryId) { + core.error("Repository ID is required for creating discussions"); + throw new Error("Repository ID is required but not available"); + } + const createdDiscussions = []; + for (let i = 0; i < createDiscussionItems.length; i++) { + const createDiscussionItem = createDiscussionItems[i]; + core.info(`Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body.length}`); + let title = createDiscussionItem.title ? createDiscussionItem.title.trim() : ""; + let bodyLines = createDiscussionItem.body.split("\n"); + if (!title) { + title = createDiscussionItem.body || "Agent Output"; + } + const titlePrefix = process.env.GITHUB_AW_DISCUSSION_TITLE_PREFIX; + if (titlePrefix && !title.startsWith(titlePrefix)) { + title = titlePrefix + title; + } + const workflowName = process.env.GITHUB_AW_WORKFLOW_NAME || "Workflow"; + const runId = context.runId; + const runUrl = context.payload.repository + ? `${context.payload.repository.html_url}/actions/runs/${runId}` + : `https://github.com/actions/runs/${runId}`; + bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); + const body = bodyLines.join("\n").trim(); + core.info(`Creating discussion with title: ${title}`); + core.info(`Category ID: ${categoryId}`); + core.info(`Body length: ${body.length}`); + try { + const createDiscussionMutation = ` mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) { createDiscussion(input: { repositoryId: $repositoryId, @@ -141,35 +140,36 @@ async function main() { } } `; - const mutationResult = await github.graphql(createDiscussionMutation, { - repositoryId: repositoryId, - categoryId: categoryId, - title: title, - body: body, - }); - const discussion = mutationResult.createDiscussion.discussion; - if (!discussion) { - core.error("Failed to create discussion: No discussion data returned"); - continue; - } - core.info("Created discussion #" + discussion.number + ": " + discussion.url); - createdDiscussions.push(discussion); - if (i === createDiscussionItems.length - 1) { - core.setOutput("discussion_number", discussion.number); - core.setOutput("discussion_url", discussion.url); - } - } catch (error) { - core.error(`✗ Failed to create discussion "${title}": ${error instanceof Error ? error.message : String(error)}`); - throw error; + const mutationResult = await github.graphql(createDiscussionMutation, { + repositoryId: repositoryId, + categoryId: categoryId, + title: title, + body: body, + }); + const discussion = mutationResult.createDiscussion.discussion; + if (!discussion) { + core.error("Failed to create discussion: No discussion data returned"); + continue; + } + core.info("Created discussion #" + discussion.number + ": " + discussion.url); + createdDiscussions.push(discussion); + if (i === createDiscussionItems.length - 1) { + core.setOutput("discussion_number", discussion.number); + core.setOutput("discussion_url", discussion.url); + } + } + catch (error) { + core.error(`✗ Failed to create discussion "${title}": ${error instanceof Error ? error.message : String(error)}`); + throw error; + } } - } - if (createdDiscussions.length > 0) { - let summaryContent = "\n\n## GitHub Discussions\n"; - for (const discussion of createdDiscussions) { - summaryContent += `- Discussion #${discussion.number}: [${discussion.title}](${discussion.url})\n`; + if (createdDiscussions.length > 0) { + let summaryContent = "\n\n## GitHub Discussions\n"; + for (const discussion of createdDiscussions) { + summaryContent += `- Discussion #${discussion.number}: [${discussion.title}](${discussion.url})\n`; + } + await core.summary.addRaw(summaryContent).write(); } - await core.summary.addRaw(summaryContent).write(); - } - core.info(`Successfully created ${createdDiscussions.length} discussion(s)`); + core.info(`Successfully created ${createdDiscussions.length} discussion(s)`); } await main(); diff --git a/pkg/workflow/js/create_issue.js b/pkg/workflow/js/create_issue.js index f806c3caf49..ee05670e553 100644 --- a/pkg/workflow/js/create_issue.js +++ b/pkg/workflow/js/create_issue.js @@ -1,161 +1,159 @@ function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace( - /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, - (_m, p1, p2) => `${p1}\`@${p2}\`` - ); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); + if (!content || typeof content !== "string") { + return ""; + } + let sanitized = content.trim(); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); + sanitized = sanitized.replace(/[<>&'"]/g, ""); + return sanitized.trim(); } async function main() { - const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; - const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; - if (!outputContent) { - core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); - return; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - return; - } - const createIssueItems = validatedOutput.items.filter(item => item.type === "create-issue"); - if (createIssueItems.length === 0) { - core.info("No create-issue items found in agent output"); - return; - } - core.info(`Found ${createIssueItems.length} create-issue item(s)`); - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Issues Preview\n\n"; - summaryContent += "The following issues would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createIssueItems.length; i++) { - const item = createIssueItems[i]; - summaryContent += `### Issue ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; - } - if (item.labels && item.labels.length > 0) { - summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; - } - summaryContent += "---\n\n"; + const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; + const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; + if (!outputContent) { + core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); + return; } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Issue creation preview written to step summary"); - return; - } - const parentIssueNumber = context.payload?.issue?.number; - const labelsEnv = process.env.GITHUB_AW_ISSUE_LABELS; - let envLabels = labelsEnv - ? labelsEnv - .split(",") - .map(label => label.trim()) - .filter(label => label) - : []; - const createdIssues = []; - for (let i = 0; i < createIssueItems.length; i++) { - const createIssueItem = createIssueItems[i]; - core.info( - `Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}` - ); - let labels = [...envLabels]; - if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { - labels = [...labels, ...createIssueItem.labels]; + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return; } - labels = labels - .filter(label => label != null && label !== false && label !== 0) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - let title = createIssueItem.title ? createIssueItem.title.trim() : ""; - let bodyLines = createIssueItem.body.split("\n"); - if (!title) { - title = createIssueItem.body || "Agent Output"; + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); } - const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; + catch (error) { + core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); + return; } - if (parentIssueNumber) { - core.info("Detected issue context, parent issue #" + parentIssueNumber); - bodyLines.push(`Related to #${parentIssueNumber}`); + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + return; } - const workflowName = process.env.GITHUB_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const runUrl = context.payload.repository - ? `${context.payload.repository.html_url}/actions/runs/${runId}` - : `https://github.com/actions/runs/${runId}`; - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating issue with title: ${title}`); - core.info(`Labels: ${labels}`); - core.info(`Body length: ${body.length}`); - try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: body, - labels: labels, - }); - core.info("Created issue #" + issue.number + ": " + issue.html_url); - createdIssues.push(issue); - if (parentIssueNumber) { + const createIssueItems = validatedOutput.items.filter(item => item.type === "create-issue"); + if (createIssueItems.length === 0) { + core.info("No create-issue items found in agent output"); + return; + } + core.info(`Found ${createIssueItems.length} create-issue item(s)`); + if (isStaged) { + let summaryContent = "## 🎭 Staged Mode: Create Issues Preview\n\n"; + summaryContent += "The following issues would be created if staged mode was disabled:\n\n"; + for (let i = 0; i < createIssueItems.length; i++) { + const item = createIssueItems[i]; + summaryContent += `### Issue ${i + 1}\n`; + summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; + if (item.body) { + summaryContent += `**Body:**\n${item.body}\n\n`; + } + if (item.labels && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; + } + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Issue creation preview written to step summary"); + return; + } + const parentIssueNumber = context.payload?.issue?.number; + const labelsEnv = process.env.GITHUB_AW_ISSUE_LABELS; + let envLabels = labelsEnv + ? labelsEnv + .split(",") + .map((label) => label.trim()) + .filter((label) => label) + : []; + const createdIssues = []; + for (let i = 0; i < createIssueItems.length; i++) { + const createIssueItem = createIssueItems[i]; + core.info(`Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}`); + let labels = [...envLabels]; + if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { + labels = [...labels, ...createIssueItem.labels]; + } + labels = labels + .filter(label => label != null && label !== false && label !== 0) + .map(label => String(label).trim()) + .filter(label => label) + .map(label => sanitizeLabelContent(label)) + .filter(label => label) + .map(label => (label.length > 64 ? label.substring(0, 64) : label)) + .filter((label, index, arr) => arr.indexOf(label) === index); + let title = createIssueItem.title ? createIssueItem.title.trim() : ""; + let bodyLines = createIssueItem.body.split("\n"); + if (!title) { + title = createIssueItem.body || "Agent Output"; + } + const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; + if (titlePrefix && !title.startsWith(titlePrefix)) { + title = titlePrefix + title; + } + if (parentIssueNumber) { + core.info("Detected issue context, parent issue #" + parentIssueNumber); + bodyLines.push(`Related to #${parentIssueNumber}`); + } + const workflowName = process.env.GITHUB_AW_WORKFLOW_NAME || "Workflow"; + const runId = context.runId; + const runUrl = context.payload.repository + ? `${context.payload.repository.html_url}/actions/runs/${runId}` + : `https://github.com/actions/runs/${runId}`; + bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); + const body = bodyLines.join("\n").trim(); + core.info(`Creating issue with title: ${title}`); + core.info(`Labels: ${labels}`); + core.info(`Body length: ${body.length}`); try { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: parentIssueNumber, - body: `Created related issue: #${issue.number}`, - }); - core.info("Added comment to parent issue #" + parentIssueNumber); - } catch (error) { - core.info(`Warning: Could not add comment to parent issue: ${error instanceof Error ? error.message : String(error)}`); + const { data: issue } = await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: title, + body: body, + labels: labels, + }); + core.info("Created issue #" + issue.number + ": " + issue.html_url); + createdIssues.push(issue); + if (parentIssueNumber) { + try { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: parentIssueNumber, + body: `Created related issue: #${issue.number}`, + }); + core.info("Added comment to parent issue #" + parentIssueNumber); + } + catch (error) { + core.info(`Warning: Could not add comment to parent issue: ${error instanceof Error ? error.message : String(error)}`); + } + } + if (i === createIssueItems.length - 1) { + core.setOutput("issue_number", issue.number); + core.setOutput("issue_url", issue.html_url); + } + } + catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage.includes("Issues has been disabled in this repository")) { + core.info(`⚠ Cannot create issue "${title}": Issues are disabled for this repository`); + core.info("Consider enabling issues in repository settings if you want to create issues automatically"); + continue; + } + core.error(`✗ Failed to create issue "${title}": ${errorMessage}`); + throw error; } - } - if (i === createIssueItems.length - 1) { - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Issues has been disabled in this repository")) { - core.info(`⚠ Cannot create issue "${title}": Issues are disabled for this repository`); - core.info("Consider enabling issues in repository settings if you want to create issues automatically"); - continue; - } - core.error(`✗ Failed to create issue "${title}": ${errorMessage}`); - throw error; } - } - if (createdIssues.length > 0) { - let summaryContent = "\n\n## GitHub Issues\n"; - for (const issue of createdIssues) { - summaryContent += `- Issue #${issue.number}: [${issue.title}](${issue.html_url})\n`; + if (createdIssues.length > 0) { + let summaryContent = "\n\n## GitHub Issues\n"; + for (const issue of createdIssues) { + summaryContent += `- Issue #${issue.number}: [${issue.title}](${issue.html_url})\n`; + } + await core.summary.addRaw(summaryContent).write(); } - await core.summary.addRaw(summaryContent).write(); - } - core.info(`Successfully created ${createdIssues.length} issue(s)`); + core.info(`Successfully created ${createdIssues.length} issue(s)`); } (async () => { - await main(); + await main(); })(); From c93db4dcf79a891e4fc8843277bfe14f2631051a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Oct 2025 15:39:04 +0000 Subject: [PATCH 5/8] Apply concurrency configuration to detection job - Add GenerateJobConcurrencyConfig call in buildThreatDetectionJob - Detection job now uses same concurrency pattern as agent job - Maintains consistency across all agentic jobs (agent and detection) Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/artifacts-summary.lock.yml | 2 ++ .github/workflows/brave.lock.yml | 2 ++ .github/workflows/changeset-generator.lock.yml | 2 ++ .github/workflows/ci-doctor.lock.yml | 2 ++ .github/workflows/dev.lock.yml | 2 ++ .github/workflows/duplicate-code-detector.lock.yml | 2 ++ .github/workflows/go-pattern-detector.lock.yml | 2 ++ .github/workflows/issue-classifier.lock.yml | 2 ++ .github/workflows/pdf-summary.lock.yml | 2 ++ .github/workflows/poem-bot.lock.yml | 2 ++ .github/workflows/scout.lock.yml | 2 ++ .github/workflows/technical-doc-writer.lock.yml | 2 ++ .github/workflows/tidy.lock.yml | 2 ++ pkg/workflow/threat_detection.go | 4 ++++ 14 files changed, 30 insertions(+) diff --git a/.github/workflows/artifacts-summary.lock.yml b/.github/workflows/artifacts-summary.lock.yml index 76a34943a92..2712c65e8de 100644 --- a/.github/workflows/artifacts-summary.lock.yml +++ b/.github/workflows/artifacts-summary.lock.yml @@ -2546,6 +2546,8 @@ jobs: needs: agent runs-on: ubuntu-latest permissions: read-all + concurrency: + group: "gh-aw-copilot" timeout-minutes: 10 steps: - name: Download agent output artifact diff --git a/.github/workflows/brave.lock.yml b/.github/workflows/brave.lock.yml index d820846498a..5594063dfc7 100644 --- a/.github/workflows/brave.lock.yml +++ b/.github/workflows/brave.lock.yml @@ -2945,6 +2945,8 @@ jobs: needs: agent runs-on: ubuntu-latest permissions: read-all + concurrency: + group: "gh-aw-copilot" timeout-minutes: 10 steps: - name: Download agent output artifact diff --git a/.github/workflows/changeset-generator.lock.yml b/.github/workflows/changeset-generator.lock.yml index 872d0f602f3..14c40c0c007 100644 --- a/.github/workflows/changeset-generator.lock.yml +++ b/.github/workflows/changeset-generator.lock.yml @@ -2807,6 +2807,8 @@ jobs: needs: agent runs-on: ubuntu-latest permissions: read-all + concurrency: + group: "gh-aw-claude" timeout-minutes: 10 steps: - name: Download agent output artifact diff --git a/.github/workflows/ci-doctor.lock.yml b/.github/workflows/ci-doctor.lock.yml index eb9dafa9811..0a737e46644 100644 --- a/.github/workflows/ci-doctor.lock.yml +++ b/.github/workflows/ci-doctor.lock.yml @@ -2640,6 +2640,8 @@ jobs: needs: agent runs-on: ubuntu-latest permissions: read-all + concurrency: + group: "gh-aw-copilot" timeout-minutes: 10 steps: - name: Download agent output artifact diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml index 60cc08ba9ba..4f950aebf2c 100644 --- a/.github/workflows/dev.lock.yml +++ b/.github/workflows/dev.lock.yml @@ -2485,6 +2485,8 @@ jobs: needs: agent runs-on: ubuntu-latest permissions: read-all + concurrency: + group: "gh-aw-claude" timeout-minutes: 10 steps: - name: Download agent output artifact diff --git a/.github/workflows/duplicate-code-detector.lock.yml b/.github/workflows/duplicate-code-detector.lock.yml index 3b7a28b2daa..2bc0830cd50 100644 --- a/.github/workflows/duplicate-code-detector.lock.yml +++ b/.github/workflows/duplicate-code-detector.lock.yml @@ -2723,6 +2723,8 @@ jobs: needs: agent runs-on: ubuntu-latest permissions: read-all + concurrency: + group: "gh-aw-copilot" timeout-minutes: 10 steps: - name: Download agent output artifact diff --git a/.github/workflows/go-pattern-detector.lock.yml b/.github/workflows/go-pattern-detector.lock.yml index a20825efe12..6de6afd91a9 100644 --- a/.github/workflows/go-pattern-detector.lock.yml +++ b/.github/workflows/go-pattern-detector.lock.yml @@ -2599,6 +2599,8 @@ jobs: needs: agent runs-on: ubuntu-latest permissions: read-all + concurrency: + group: "gh-aw-claude" timeout-minutes: 10 steps: - name: Download agent output artifact diff --git a/.github/workflows/issue-classifier.lock.yml b/.github/workflows/issue-classifier.lock.yml index b70f7eebe6b..5c99ff0adae 100644 --- a/.github/workflows/issue-classifier.lock.yml +++ b/.github/workflows/issue-classifier.lock.yml @@ -2076,6 +2076,8 @@ jobs: needs: agent runs-on: ubuntu-latest permissions: read-all + concurrency: + group: "gh-aw-custom" timeout-minutes: 10 steps: - name: Download agent output artifact diff --git a/.github/workflows/pdf-summary.lock.yml b/.github/workflows/pdf-summary.lock.yml index 5c88ce1d3d6..a218af3479b 100644 --- a/.github/workflows/pdf-summary.lock.yml +++ b/.github/workflows/pdf-summary.lock.yml @@ -3053,6 +3053,8 @@ jobs: needs: agent runs-on: ubuntu-latest permissions: read-all + concurrency: + group: "gh-aw-copilot" timeout-minutes: 10 steps: - name: Download agent output artifact diff --git a/.github/workflows/poem-bot.lock.yml b/.github/workflows/poem-bot.lock.yml index 704c4d90cc9..24c3eafc4e9 100644 --- a/.github/workflows/poem-bot.lock.yml +++ b/.github/workflows/poem-bot.lock.yml @@ -3123,6 +3123,8 @@ jobs: needs: agent runs-on: ubuntu-latest permissions: read-all + concurrency: + group: "gh-aw-copilot" timeout-minutes: 10 steps: - name: Download agent output artifact diff --git a/.github/workflows/scout.lock.yml b/.github/workflows/scout.lock.yml index bac0c0537e0..565146afa22 100644 --- a/.github/workflows/scout.lock.yml +++ b/.github/workflows/scout.lock.yml @@ -3044,6 +3044,8 @@ jobs: needs: agent runs-on: ubuntu-latest permissions: read-all + concurrency: + group: "gh-aw-copilot" timeout-minutes: 10 steps: - name: Download agent output artifact diff --git a/.github/workflows/technical-doc-writer.lock.yml b/.github/workflows/technical-doc-writer.lock.yml index 589cfb479b8..e19626bcc47 100644 --- a/.github/workflows/technical-doc-writer.lock.yml +++ b/.github/workflows/technical-doc-writer.lock.yml @@ -2789,6 +2789,8 @@ jobs: needs: agent runs-on: ubuntu-latest permissions: read-all + concurrency: + group: "gh-aw-claude" timeout-minutes: 10 steps: - name: Download agent output artifact diff --git a/.github/workflows/tidy.lock.yml b/.github/workflows/tidy.lock.yml index 5366e5cc9d0..5507c28ceca 100644 --- a/.github/workflows/tidy.lock.yml +++ b/.github/workflows/tidy.lock.yml @@ -2888,6 +2888,8 @@ jobs: needs: agent runs-on: ubuntu-latest permissions: read-all + concurrency: + group: "gh-aw-copilot" timeout-minutes: 10 steps: - name: Download agent output artifact diff --git a/pkg/workflow/threat_detection.go b/pkg/workflow/threat_detection.go index 60c98434ca7..9ad0116d7e8 100644 --- a/pkg/workflow/threat_detection.go +++ b/pkg/workflow/threat_detection.go @@ -87,11 +87,15 @@ func (c *Compiler) buildThreatDetectionJob(data *WorkflowData, mainJobName strin // Build steps using a more structured approach steps := c.buildThreatDetectionSteps(data, mainJobName) + // Generate agent concurrency configuration (same as main agent job) + agentConcurrency := GenerateJobConcurrencyConfig(data) + job := &Job{ Name: constants.DetectionJobName, If: "", RunsOn: "runs-on: ubuntu-latest", Permissions: "permissions: read-all", + Concurrency: c.indentYAMLLines(agentConcurrency, " "), TimeoutMinutes: 10, Steps: steps, Needs: []string{mainJobName}, From bacda9f820da38d46ddfd4d02a7262b3fda92e63 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Oct 2025 16:21:22 +0000 Subject: [PATCH 6/8] Add hasDefaultConcurrency flag to control default concurrency per engine - Add HasDefaultConcurrency() method to CodingAgentEngine interface - Add hasDefaultConcurrency field to BaseEngine struct - Copilot engine has default concurrency enabled (true) - Claude, Codex, and Custom engines do NOT have default concurrency (false) - Update GenerateJobConcurrencyConfig to check engine flag before applying default - Update tests to reflect new behavior per engine - Engines without default concurrency return empty string (no job concurrency) Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../workflows/changeset-generator.lock.yml | 4 - .github/workflows/dev.lock.yml | 4 - .../workflows/go-pattern-detector.lock.yml | 4 - .github/workflows/issue-classifier.lock.yml | 4 - .../workflows/notion-issue-summary.lock.yml | 1416 ++++++++--------- .../workflows/technical-doc-writer.lock.yml | 4 - pkg/workflow/agentic_engine.go | 10 + pkg/workflow/claude_engine.go | 1 + pkg/workflow/codex_engine.go | 1 + pkg/workflow/concurrency.go | 19 +- pkg/workflow/concurrency_test.go | 16 +- pkg/workflow/copilot_engine.go | 1 + pkg/workflow/custom_engine.go | 1 + .../engine_concurrency_integration_test.go | 145 +- 14 files changed, 837 insertions(+), 793 deletions(-) diff --git a/.github/workflows/changeset-generator.lock.yml b/.github/workflows/changeset-generator.lock.yml index d2313ba60a9..c6b2d7b31ee 100644 --- a/.github/workflows/changeset-generator.lock.yml +++ b/.github/workflows/changeset-generator.lock.yml @@ -271,8 +271,6 @@ jobs: permissions: contents: read pull-requests: read - concurrency: - group: "gh-aw-claude" env: GITHUB_AW_SAFE_OUTPUTS: /tmp/safe-outputs/outputs.jsonl GITHUB_AW_SAFE_OUTPUTS_CONFIG: "{\"missing-tool\":{},\"push-to-pull-request-branch\":{}}" @@ -2807,8 +2805,6 @@ jobs: needs: agent runs-on: ubuntu-latest permissions: read-all - concurrency: - group: "gh-aw-claude" timeout-minutes: 10 steps: - name: Download agent output artifact diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml index a90e16badf2..f8fcbe93e9c 100644 --- a/.github/workflows/dev.lock.yml +++ b/.github/workflows/dev.lock.yml @@ -130,8 +130,6 @@ jobs: needs: activation runs-on: ubuntu-latest permissions: read-all - concurrency: - group: "gh-aw-claude" env: GITHUB_AW_SAFE_OUTPUTS: /tmp/safe-outputs/outputs.jsonl GITHUB_AW_SAFE_OUTPUTS_CONFIG: "{\"create-issue\":{\"max\":1},\"missing-tool\":{}}" @@ -2485,8 +2483,6 @@ jobs: needs: agent runs-on: ubuntu-latest permissions: read-all - concurrency: - group: "gh-aw-claude" timeout-minutes: 10 steps: - name: Download agent output artifact diff --git a/.github/workflows/go-pattern-detector.lock.yml b/.github/workflows/go-pattern-detector.lock.yml index a434a813a99..4888ccfc553 100644 --- a/.github/workflows/go-pattern-detector.lock.yml +++ b/.github/workflows/go-pattern-detector.lock.yml @@ -126,8 +126,6 @@ jobs: permissions: actions: read contents: read - concurrency: - group: "gh-aw-claude" env: GITHUB_AW_SAFE_OUTPUTS: /tmp/safe-outputs/outputs.jsonl GITHUB_AW_SAFE_OUTPUTS_CONFIG: "{\"create-issue\":{\"max\":1},\"missing-tool\":{}}" @@ -2599,8 +2597,6 @@ jobs: needs: agent runs-on: ubuntu-latest permissions: read-all - concurrency: - group: "gh-aw-claude" timeout-minutes: 10 steps: - name: Download agent output artifact diff --git a/.github/workflows/issue-classifier.lock.yml b/.github/workflows/issue-classifier.lock.yml index 63d907e9b48..769d16a0fd7 100644 --- a/.github/workflows/issue-classifier.lock.yml +++ b/.github/workflows/issue-classifier.lock.yml @@ -425,8 +425,6 @@ jobs: actions: read contents: read models: read - concurrency: - group: "gh-aw-custom" env: GITHUB_AW_SAFE_OUTPUTS: /tmp/safe-outputs/outputs.jsonl GITHUB_AW_SAFE_OUTPUTS_CONFIG: "{\"add-labels\":{\"allowed\":[\"bug\",\"feature\",\"enhancement\",\"documentation\"],\"max\":1},\"missing-tool\":{}}" @@ -2076,8 +2074,6 @@ jobs: needs: agent runs-on: ubuntu-latest permissions: read-all - concurrency: - group: "gh-aw-custom" timeout-minutes: 10 steps: - name: Download agent output artifact diff --git a/.github/workflows/notion-issue-summary.lock.yml b/.github/workflows/notion-issue-summary.lock.yml index b1d6d1272b6..2e807675dc9 100644 --- a/.github/workflows/notion-issue-summary.lock.yml +++ b/.github/workflows/notion-issue-summary.lock.yml @@ -1384,742 +1384,742 @@ jobs: with: script: | async function main() { - const fs = require("fs"); - function sanitizeContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; - const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; - const allowedDomains = allowedDomainsEnv - ? allowedDomainsEnv - .split(",") - .map(d => d.trim()) - .filter(d => d) - : defaultAllowedDomains; - let sanitized = content; - sanitized = neutralizeMentions(sanitized); - sanitized = removeXmlComments(sanitized); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitizeUrlProtocols(sanitized); - sanitized = sanitizeUrlDomains(sanitized); - const maxLength = 524288; - if (sanitized.length > maxLength) { - sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; + const fs = require("fs"); + function sanitizeContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; + const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; + const allowedDomains = allowedDomainsEnv + ? allowedDomainsEnv + .split(",") + .map(d => d.trim()) + .filter(d => d) + : defaultAllowedDomains; + let sanitized = content; + sanitized = neutralizeMentions(sanitized); + sanitized = removeXmlComments(sanitized); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitizeUrlProtocols(sanitized); + sanitized = sanitizeUrlDomains(sanitized); + const maxLength = 524288; + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; + } + const lines = sanitized.split("\n"); + const maxLines = 65000; + if (lines.length > maxLines) { + sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; + } + sanitized = neutralizeBotTriggers(sanitized); + return sanitized.trim(); + function sanitizeUrlDomains(s) { + return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { + const urlAfterProtocol = match.slice(8); + const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); + const isAllowed = allowedDomains.some(allowedDomain => { + const normalizedAllowed = allowedDomain.toLowerCase(); + return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); + }); + return isAllowed ? match : "(redacted)"; + }); + } + function sanitizeUrlProtocols(s) { + return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { + return protocol.toLowerCase() === "https" ? match : "(redacted)"; + }); + } + function neutralizeMentions(s) { + return s.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); + } + function removeXmlComments(s) { + return s.replace(//g, "").replace(//g, ""); + } + function neutralizeBotTriggers(s) { + return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); + } } - const lines = sanitized.split("\n"); - const maxLines = 65000; - if (lines.length > maxLines) { - sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + switch (itemType) { + case "create-issue": + return 1; + case "add-comment": + return 1; + case "create-pull-request": + return 1; + case "create-pull-request-review-comment": + return 1; + case "add-labels": + return 5; + case "update-issue": + return 1; + case "push-to-pull-request-branch": + return 1; + case "create-discussion": + return 1; + case "missing-tool": + return 1000; + case "create-code-scanning-alert": + return 1000; + case "upload-asset": + return 10; + default: + return 1; + } } - sanitized = neutralizeBotTriggers(sanitized); - return sanitized.trim(); - function sanitizeUrlDomains(s) { - return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { - const urlAfterProtocol = match.slice(8); - const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); - const isAllowed = allowedDomains.some(allowedDomain => { - const normalizedAllowed = allowedDomain.toLowerCase(); - return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; + } + return 0; + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); }); - return isAllowed ? match : "(redacted)"; - }); - } - function sanitizeUrlProtocols(s) { - return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { - return protocol.toLowerCase() === "https" ? match : "(redacted)"; - }); - } - function neutralizeMentions(s) { - return s.replace( - /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, - (_m, p1, p2) => `${p1}\`@${p2}\`` - ); - } - function removeXmlComments(s) { - return s.replace(//g, "").replace(//g, ""); - } - function neutralizeBotTriggers(s) { - return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); - } - } - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create-issue": - return 1; - case "add-comment": - return 1; - case "create-pull-request": - return 1; - case "create-pull-request-review-comment": - return 1; - case "add-labels": - return 5; - case "update-issue": - return 1; - case "push-to-pull-request-branch": - return 1; - case "create-discussion": - return 1; - case "missing-tool": - return 1000; - case "create-code-scanning-alert": - return 1000; - case "upload-asset": - return 10; - default: - return 1; - } - } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; - } - return 0; - } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; - } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } + else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } + else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; } - return { isValid: true, normalizedValue: parsed }; - } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { + if (inputSchema.required && (value === undefined || value === null)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (value === undefined || value === null) { + return { + isValid: true, + normalizedValue: inputSchema.default || undefined, + }; + } + const inputType = inputSchema.type || "string"; + let normalizedValue = value; + switch (inputType) { + case "string": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string`, + }; + } + normalizedValue = sanitizeContent(value); + break; + case "boolean": + if (typeof value !== "boolean") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a boolean`, + }; + } + break; + case "number": + if (typeof value !== "number") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number`, + }; + } + break; + case "choice": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, + }; + } + if (inputSchema.options && !inputSchema.options.includes(value)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, + }; + } + normalizedValue = sanitizeContent(value); + break; + default: + if (typeof value === "string") { + normalizedValue = sanitizeContent(value); + } + break; + } return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, + isValid: true, + normalizedValue, }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { + function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { + const errors = []; + const normalizedItem = { ...item }; + if (!jobConfig.inputs) { + return { + isValid: true, + errors: [], + normalizedItem: item, + }; + } + for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { + const fieldValue = item[fieldName]; + const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); + if (!validation.isValid && validation.error) { + errors.push(validation.error); + } + else if (validation.normalizedValue !== undefined) { + normalizedItem[fieldName] = validation.normalizedValue; + } + } return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, + isValid: errors.length === 0, + errors, + normalizedItem, }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; } - return { isValid: true, normalizedValue: parsed }; - } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; + function parseJsonWithRepair(jsonStr) { + try { + return JSON.parse(jsonStr); + } + catch (originalError) { + try { + const repairedJson = repairJson(jsonStr); + return JSON.parse(repairedJson); + } + catch (repairError) { + core.info(`invalid input json: ${jsonStr}`); + const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); + const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); + throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); + } + } } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; + const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; + const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; + if (!outputFile) { + core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); + core.setOutput("output", ""); + return; } - return { isValid: true }; - } - function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { - if (inputSchema.required && (value === undefined || value === null)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; + if (!fs.existsSync(outputFile)) { + core.info(`Output file does not exist: ${outputFile}`); + core.setOutput("output", ""); + return; } - if (value === undefined || value === null) { - return { - isValid: true, - normalizedValue: inputSchema.default || undefined, - }; + const outputContent = fs.readFileSync(outputFile, "utf8"); + if (outputContent.trim() === "") { + core.info("Output file is empty"); } - const inputType = inputSchema.type || "string"; - let normalizedValue = value; - switch (inputType) { - case "string": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string`, - }; - } - normalizedValue = sanitizeContent(value); - break; - case "boolean": - if (typeof value !== "boolean") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a boolean`, - }; - } - break; - case "number": - if (typeof value !== "number") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number`, - }; - } - break; - case "choice": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, - }; - } - if (inputSchema.options && !inputSchema.options.includes(value)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, - }; + core.info(`Raw output content length: ${outputContent.length}`); + let expectedOutputTypes = {}; + if (safeOutputsConfig) { + try { + expectedOutputTypes = JSON.parse(safeOutputsConfig); + core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); } - normalizedValue = sanitizeContent(value); - break; - default: - if (typeof value === "string") { - normalizedValue = sanitizeContent(value); + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); } - break; } - return { - isValid: true, - normalizedValue, - }; - } - function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { + const lines = outputContent.trim().split("\n"); + const parsedItems = []; const errors = []; - const normalizedItem = { ...item }; - if (!jobConfig.inputs) { - return { - isValid: true, - errors: [], - normalizedItem: item, - }; - } - for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { - const fieldValue = item[fieldName]; - const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); - if (!validation.isValid && validation.error) { - errors.push(validation.error); - } else if (validation.normalizedValue !== undefined) { - normalizedItem[fieldName] = validation.normalizedValue; - } - } - return { - isValid: errors.length === 0, - errors, - normalizedItem, - }; - } - function parseJsonWithRepair(jsonStr) { - try { - return JSON.parse(jsonStr); - } catch (originalError) { - try { - const repairedJson = repairJson(jsonStr); - return JSON.parse(repairedJson); - } catch (repairError) { - core.info(`invalid input json: ${jsonStr}`); - const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); - const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); - throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); - } - } - } - const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; - const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; - if (!outputFile) { - core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); - core.setOutput("output", ""); - return; - } - if (!fs.existsSync(outputFile)) { - core.info(`Output file does not exist: ${outputFile}`); - core.setOutput("output", ""); - return; - } - const outputContent = fs.readFileSync(outputFile, "utf8"); - if (outputContent.trim() === "") { - core.info("Output file is empty"); - } - core.info(`Raw output content length: ${outputContent.length}`); - let expectedOutputTypes = {}; - if (safeOutputsConfig) { - try { - expectedOutputTypes = JSON.parse(safeOutputsConfig); - core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); - } - } - const lines = outputContent.trim().split("\n"); - const parsedItems = []; - const errors = []; - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); - if (line === "") continue; - try { - const item = parseJsonWithRepair(line); - if (item === undefined) { - errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); - continue; - } - if (!item.type) { - errors.push(`Line ${i + 1}: Missing required 'type' field`); - continue; - } - const itemType = item.type; - if (!expectedOutputTypes[itemType]) { - errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); - continue; - } - const typeCount = parsedItems.filter(existing => existing.type === itemType).length; - const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); - if (typeCount >= maxAllowed) { - errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); - continue; - } - core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create-issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); - if (!issueNumValidation.isValid) { - if (issueNumValidation.error) errors.push(issueNumValidation.error); - continue; - } - item.body = sanitizeContent(item.body); - break; - case "create-pull-request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - item.branch = sanitizeContent(item.branch); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some(label => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); - if (!labelsIssueNumValidation.isValid) { - if (labelsIssueNumValidation.error) errors.push(labelsIssueNumValidation.error); - continue; - } - item.labels = item.labels.map(label => sanitizeContent(label)); - break; - case "update-issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "push-to-pull-request-branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch); - item.message = sanitizeContent(item.message); - const pushPRNumValidation = validateIssueOrPRNumber( - item.pull_request_number, - "push-to-pull-request-branch 'pull_request_number'", - i + 1 - ); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create-pull-request-review-comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line === "") continue; - } - item.body = sanitizeContent(item.body); - const startLineValidation = validateOptionalPositiveInteger( - item.start_line, - "create-pull-request-review-comment 'start_line'", - i + 1 - ); - if (!startLineValidation.isValid) { - if (startLineValidation.error) errors.push(startLineValidation.error); - continue; - } - if ( - startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber - ) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; + try { + const item = parseJsonWithRepair(line); + if (item === undefined) { + errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); + continue; } - } - break; - case "create-discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; + if (!item.type) { + errors.push(`Line ${i + 1}: Missing required 'type' field`); + continue; } - item.category = sanitizeContent(item.category); - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - break; - case "missing-tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool); - item.reason = sanitizeContent(item.reason); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); - continue; + const itemType = item.type; + if (!expectedOutputTypes[itemType]) { + errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); + continue; } - item.alternatives = sanitizeContent(item.alternatives); - } - break; - case "upload-asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "create-code-scanning-alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); + const typeCount = parsedItems.filter(existing => existing.type === itemType).length; + const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); + if (typeCount >= maxAllowed) { + errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); + continue; } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push( - `Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` - ); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push( - `Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` - ); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file); - item.severity = sanitizeContent(item.severity); - item.message = sanitizeContent(item.message); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); - continue; - } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; + core.info(`Line ${i + 1}: type '${itemType}'`); + switch (itemType) { + case "create-issue": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-comment": + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); + continue; + } + const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); + if (!issueNumValidation.isValid) { + if (issueNumValidation.error) + errors.push(issueNumValidation.error); + continue; + } + item.body = sanitizeContent(item.body); + break; + case "create-pull-request": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); + continue; + } + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + item.branch = sanitizeContent(item.branch); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-labels": + if (!item.labels || !Array.isArray(item.labels)) { + errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); + continue; + } + if (item.labels.some((label) => typeof label !== "string")) { + errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); + continue; + } + const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); + if (!labelsIssueNumValidation.isValid) { + if (labelsIssueNumValidation.error) + errors.push(labelsIssueNumValidation.error); + continue; + } + item.labels = item.labels.map((label) => sanitizeContent(label)); + break; + case "update-issue": + const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; + if (!hasValidField) { + errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); + continue; + } + if (item.status !== undefined) { + if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { + errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); + continue; + } + } + if (item.title !== undefined) { + if (typeof item.title !== "string") { + errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); + continue; + } + item.title = sanitizeContent(item.title); + } + if (item.body !== undefined) { + if (typeof item.body !== "string") { + errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); + continue; + } + item.body = sanitizeContent(item.body); + } + const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); + if (!updateIssueNumValidation.isValid) { + if (updateIssueNumValidation.error) + errors.push(updateIssueNumValidation.error); + continue; + } + break; + case "push-to-pull-request-branch": + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); + continue; + } + item.branch = sanitizeContent(item.branch); + item.message = sanitizeContent(item.message); + const pushPRNumValidation = validateIssueOrPRNumber(item.pull_request_number, "push-to-pull-request-branch 'pull_request_number'", i + 1); + if (!pushPRNumValidation.isValid) { + if (pushPRNumValidation.error) + errors.push(pushPRNumValidation.error); + continue; + } + break; + case "create-pull-request-review-comment": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); + continue; + } + const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); + if (!lineValidation.isValid) { + if (lineValidation.error) + errors.push(lineValidation.error); + continue; + } + const lineNumber = lineValidation.normalizedValue; + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); + continue; + } + item.body = sanitizeContent(item.body); + const startLineValidation = validateOptionalPositiveInteger(item.start_line, "create-pull-request-review-comment 'start_line'", i + 1); + if (!startLineValidation.isValid) { + if (startLineValidation.error) + errors.push(startLineValidation.error); + continue; + } + if (startLineValidation.normalizedValue !== undefined && + lineNumber !== undefined && + startLineValidation.normalizedValue > lineNumber) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); + continue; + } + if (item.side !== undefined) { + if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); + continue; + } + } + break; + case "create-discussion": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); + continue; + } + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); + continue; + } + item.category = sanitizeContent(item.category); + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + break; + case "missing-tool": + if (!item.tool || typeof item.tool !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); + continue; + } + if (!item.reason || typeof item.reason !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); + continue; + } + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); + continue; + } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "upload-asset": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); + continue; + } + break; + case "create-code-scanning-alert": + if (!item.file || typeof item.file !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); + continue; + } + const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); + if (!alertLineValidation.isValid) { + if (alertLineValidation.error) { + errors.push(alertLineValidation.error); + } + continue; + } + if (!item.severity || typeof item.severity !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); + continue; + } + const allowedSeverities = ["error", "warning", "info", "note"]; + if (!allowedSeverities.includes(item.severity.toLowerCase())) { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}`); + continue; + } + const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); + if (!columnValidation.isValid) { + if (columnValidation.error) + errors.push(columnValidation.error); + continue; + } + if (item.ruleIdSuffix !== undefined) { + if (typeof item.ruleIdSuffix !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); + continue; + } + if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores`); + continue; + } + } + item.severity = item.severity.toLowerCase(); + item.file = sanitizeContent(item.file); + item.severity = sanitizeContent(item.severity); + item.message = sanitizeContent(item.message); + if (item.ruleIdSuffix) { + item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); + } + break; + default: + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); + continue; + } + Object.assign(item, validation.normalizedItem); + } + break; } - Object.assign(item, validation.normalizedItem); - } - break; - } - core.info(`Line ${i + 1}: Valid ${itemType} item`); - parsedItems.push(item); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); + core.info(`Line ${i + 1}: Valid ${itemType} item`); + parsedItems.push(item); + } + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); + } } - } - if (errors.length > 0) { - core.warning("Validation errors found:"); - errors.forEach(error => core.warning(` - ${error}`)); - if (parsedItems.length === 0) { - core.setFailed(errors.map(e => ` - ${e}`).join("\n")); - return; + if (errors.length > 0) { + core.warning("Validation errors found:"); + errors.forEach(error => core.warning(` - ${error}`)); + if (parsedItems.length === 0) { + core.setFailed(errors.map(e => ` - ${e}`).join("\n")); + return; + } } - } - for (const itemType of Object.keys(expectedOutputTypes)) { - const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); - if (minRequired > 0) { - const actualCount = parsedItems.filter(item => item.type === itemType).length; - if (actualCount < minRequired) { - errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); - } + for (const itemType of Object.keys(expectedOutputTypes)) { + const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); + if (minRequired > 0) { + const actualCount = parsedItems.filter(item => item.type === itemType).length; + if (actualCount < minRequired) { + errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); + } + } + } + core.info(`Successfully parsed ${parsedItems.length} valid output items`); + const validatedOutput = { + items: parsedItems, + errors: errors, + }; + const agentOutputFile = "/tmp/agent_output.json"; + const validatedOutputJson = JSON.stringify(validatedOutput); + try { + fs.mkdirSync("/tmp", { recursive: true }); + fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); + core.info(`Stored validated output to: ${agentOutputFile}`); + core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); + } + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.error(`Failed to write agent output file: ${errorMsg}`); + } + core.setOutput("output", JSON.stringify(validatedOutput)); + core.setOutput("raw_output", outputContent); + const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); + core.info(`output_types: ${outputTypes.join(", ")}`); + core.setOutput("output_types", outputTypes.join(",")); + try { + await core.summary + .addRaw("## Processed Output\n\n") + .addRaw("```json\n") + .addRaw(JSON.stringify(validatedOutput)) + .addRaw("\n```\n") + .write(); + core.info("Successfully wrote processed output to step summary"); + } + catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.warning(`Failed to write to step summary: ${errorMsg}`); } - } - core.info(`Successfully parsed ${parsedItems.length} valid output items`); - const validatedOutput = { - items: parsedItems, - errors: errors, - }; - const agentOutputFile = "/tmp/agent_output.json"; - const validatedOutputJson = JSON.stringify(validatedOutput); - try { - fs.mkdirSync("/tmp", { recursive: true }); - fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); - core.info(`Stored validated output to: ${agentOutputFile}`); - core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.error(`Failed to write agent output file: ${errorMsg}`); - } - core.setOutput("output", JSON.stringify(validatedOutput)); - core.setOutput("raw_output", outputContent); - const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); - core.info(`output_types: ${outputTypes.join(", ")}`); - core.setOutput("output_types", outputTypes.join(",")); - try { - await core.summary - .addRaw("## Processed Output\n\n") - .addRaw("```json\n") - .addRaw(JSON.stringify(validatedOutput)) - .addRaw("\n```\n") - .write(); - core.info("Successfully wrote processed output to step summary"); - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.warning(`Failed to write to step summary: ${errorMsg}`); - } } await main(); - name: Upload sanitized agent output diff --git a/.github/workflows/technical-doc-writer.lock.yml b/.github/workflows/technical-doc-writer.lock.yml index 0e91355b30c..98fcb0a8454 100644 --- a/.github/workflows/technical-doc-writer.lock.yml +++ b/.github/workflows/technical-doc-writer.lock.yml @@ -120,8 +120,6 @@ jobs: needs: activation runs-on: ubuntu-latest permissions: read-all - concurrency: - group: "gh-aw-claude" env: GITHUB_AW_SAFE_OUTPUTS: /tmp/safe-outputs/outputs.jsonl GITHUB_AW_SAFE_OUTPUTS_CONFIG: "{\"add-comment\":{\"max\":1},\"create-pull-request\":{},\"missing-tool\":{},\"upload-asset\":{}}" @@ -2789,8 +2787,6 @@ jobs: needs: agent runs-on: ubuntu-latest permissions: read-all - concurrency: - group: "gh-aw-claude" timeout-minutes: 10 steps: - name: Download agent output artifact diff --git a/pkg/workflow/agentic_engine.go b/pkg/workflow/agentic_engine.go index 3985cc37d07..e3a3f76898f 100644 --- a/pkg/workflow/agentic_engine.go +++ b/pkg/workflow/agentic_engine.go @@ -66,6 +66,10 @@ type CodingAgentEngine interface { // GetVersionCommand returns the command to get the version of the agent (e.g., "copilot --version") // Returns empty string if the engine does not support version reporting GetVersionCommand() string + + // HasDefaultConcurrency returns true if this engine should have default concurrency mode enabled + // Default concurrency mode applies gh-aw-{engine-id} pattern when no custom concurrency is configured + HasDefaultConcurrency() bool } // ErrorPattern represents a regex pattern for extracting error information from logs @@ -93,6 +97,7 @@ type BaseEngine struct { supportsMaxTurns bool supportsWebFetch bool supportsWebSearch bool + hasDefaultConcurrency bool } func (e *BaseEngine) GetID() string { @@ -146,6 +151,11 @@ func (e *BaseEngine) GetVersionCommand() string { return "" } +// HasDefaultConcurrency returns the configured value for default concurrency mode +func (e *BaseEngine) HasDefaultConcurrency() bool { + return e.hasDefaultConcurrency +} + // EngineRegistry manages available agentic engines type EngineRegistry struct { engines map[string]CodingAgentEngine diff --git a/pkg/workflow/claude_engine.go b/pkg/workflow/claude_engine.go index f90827fcc0b..51ccc3d5bb4 100644 --- a/pkg/workflow/claude_engine.go +++ b/pkg/workflow/claude_engine.go @@ -30,6 +30,7 @@ func NewClaudeEngine() *ClaudeEngine { supportsMaxTurns: true, // Claude supports max-turns feature supportsWebFetch: true, // Claude has built-in WebFetch support supportsWebSearch: true, // Claude has built-in WebSearch support + hasDefaultConcurrency: false, // Claude does NOT have default concurrency enabled }, } } diff --git a/pkg/workflow/codex_engine.go b/pkg/workflow/codex_engine.go index 26e3b374977..75f984072ea 100644 --- a/pkg/workflow/codex_engine.go +++ b/pkg/workflow/codex_engine.go @@ -51,6 +51,7 @@ func NewCodexEngine() *CodexEngine { supportsMaxTurns: false, // Codex does not support max-turns feature supportsWebFetch: false, // Codex does not have built-in web-fetch support supportsWebSearch: true, // Codex has built-in web-search support + hasDefaultConcurrency: false, // Codex does NOT have default concurrency enabled }, } } diff --git a/pkg/workflow/concurrency.go b/pkg/workflow/concurrency.go index 5aa9e6eac13..d16d1420760 100644 --- a/pkg/workflow/concurrency.go +++ b/pkg/workflow/concurrency.go @@ -36,6 +36,21 @@ func GenerateJobConcurrencyConfig(workflowData *WorkflowData) string { return workflowData.EngineConfig.Concurrency } + // Check if the engine has default concurrency enabled + engineID := "" + if workflowData.EngineConfig != nil && workflowData.EngineConfig.ID != "" { + engineID = workflowData.EngineConfig.ID + } + + // Get the engine to check if default concurrency should be applied + registry := GetGlobalEngineRegistry() + engine, err := registry.GetEngine(engineID) + + // If engine not found or doesn't have default concurrency, return empty string (no concurrency) + if err != nil || !engine.HasDefaultConcurrency() { + return "" + } + // Default behavior: single job per engine across all workflows // Pattern: gh-aw-{engine-id} var keys []string @@ -44,9 +59,7 @@ func GenerateJobConcurrencyConfig(workflowData *WorkflowData) string { keys = append(keys, "gh-aw") // Use engine ID for isolation between different engines - if workflowData.EngineConfig != nil && workflowData.EngineConfig.ID != "" { - keys = append(keys, workflowData.EngineConfig.ID) - } + keys = append(keys, engineID) groupValue := strings.Join(keys, "-") diff --git a/pkg/workflow/concurrency_test.go b/pkg/workflow/concurrency_test.go index 69490fe9cc5..56863424a85 100644 --- a/pkg/workflow/concurrency_test.go +++ b/pkg/workflow/concurrency_test.go @@ -322,16 +322,15 @@ func TestGenerateJobConcurrencyConfig(t *testing.T) { }, expected: `concurrency: group: "gh-aw-copilot"`, - description: "Should use default pattern gh-aw-{engine-id} without slot distribution", + description: "Copilot should use default pattern gh-aw-{engine-id} (has default concurrency enabled)", }, { - name: "Default concurrency with claude engine", + name: "No default concurrency with claude engine", workflowData: &WorkflowData{ EngineConfig: &EngineConfig{ID: "claude"}, }, - expected: `concurrency: - group: "gh-aw-claude"`, - description: "Should use default pattern gh-aw-{engine-id} for claude", + expected: "", + description: "Claude should NOT have default concurrency (returns empty string)", }, { name: "Custom concurrency string (simple group)", @@ -362,13 +361,12 @@ func TestGenerateJobConcurrencyConfig(t *testing.T) { description: "Should preserve cancel-in-progress when specified", }, { - name: "Different engine ID should be included in default concurrency group", + name: "No default concurrency with codex engine", workflowData: &WorkflowData{ EngineConfig: &EngineConfig{ID: "codex"}, }, - expected: `concurrency: - group: "gh-aw-codex"`, - description: "Different engine IDs should be included in concurrency group for isolation", + expected: "", + description: "Codex should NOT have default concurrency (returns empty string)", }, } diff --git a/pkg/workflow/copilot_engine.go b/pkg/workflow/copilot_engine.go index e6f2c1e6e9e..5b8cb11d186 100644 --- a/pkg/workflow/copilot_engine.go +++ b/pkg/workflow/copilot_engine.go @@ -29,6 +29,7 @@ func NewCopilotEngine() *CopilotEngine { supportsMaxTurns: false, // Copilot CLI does not support max-turns feature yet supportsWebFetch: false, // Copilot CLI does not have built-in web-fetch support supportsWebSearch: false, // Copilot CLI does not have built-in web-search support + hasDefaultConcurrency: true, // Copilot HAS default concurrency enabled }, } } diff --git a/pkg/workflow/custom_engine.go b/pkg/workflow/custom_engine.go index 30e75f9c8a4..1f38e1f9d02 100644 --- a/pkg/workflow/custom_engine.go +++ b/pkg/workflow/custom_engine.go @@ -23,6 +23,7 @@ func NewCustomEngine() *CustomEngine { supportsMaxTurns: true, // Custom engine supports max-turns for consistency supportsWebFetch: false, // Custom engine does not have built-in web-fetch support supportsWebSearch: false, // Custom engine does not have built-in web-search support + hasDefaultConcurrency: false, // Custom engine does NOT have default concurrency enabled }, } } diff --git a/pkg/workflow/engine_concurrency_integration_test.go b/pkg/workflow/engine_concurrency_integration_test.go index a3427177353..7ccf6782d1e 100644 --- a/pkg/workflow/engine_concurrency_integration_test.go +++ b/pkg/workflow/engine_concurrency_integration_test.go @@ -8,15 +8,33 @@ import ( ) func TestEngineConcurrencyIntegration(t *testing.T) { -tests := []struct { -name string -markdown string -expectedInJob string -description string -}{ -{ -name: "Default concurrency (no engine.concurrency specified)", -markdown: `--- + tests := []struct { + name string + markdown string + expectedInJob string + notExpectedInJob string + description string + }{ + { + name: "Copilot engine has default concurrency", + markdown: `--- +on: push +engine: + id: copilot +tools: + github: + allowed: [list_issues] +--- + +# Test workflow +Test content`, + expectedInJob: `concurrency: + group: "gh-aw-copilot"`, + description: "Copilot should use default pattern gh-aw-{engine-id}", + }, + { + name: "Claude engine does NOT have default concurrency", + markdown: `--- on: push engine: id: claude @@ -27,13 +45,12 @@ tools: # Test workflow Test content`, -expectedInJob: `concurrency: - group: "gh-aw-claude"`, -description: "Should use default pattern gh-aw-{engine-id}", -}, -{ -name: "Custom concurrency with string format", -markdown: `--- + notExpectedInJob: `concurrency:`, + description: "Claude should NOT have default concurrency in agent job", + }, + { + name: "Custom concurrency with string format", + markdown: `--- on: push engine: id: claude @@ -45,13 +62,13 @@ tools: # Test workflow Test content`, -expectedInJob: `concurrency: + expectedInJob: `concurrency: group: "custom-${{ github.ref }}"`, -description: "Should use custom concurrency group from string format", -}, -{ -name: "Custom concurrency with object format", -markdown: `--- + description: "Should use custom concurrency group from string format", + }, + { + name: "Custom concurrency with object format", + markdown: `--- on: push engine: id: claude @@ -65,41 +82,63 @@ tools: # Test workflow Test content`, -expectedInJob: `concurrency: + expectedInJob: `concurrency: group: "my-group-${{ github.workflow }}" cancel-in-progress: true`, -description: "Should use custom concurrency with cancel-in-progress", -}, -} + description: "Should use custom concurrency with cancel-in-progress", + }, + } -for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Create temporary directory and file - tmpDir := t.TempDir() - workflowPath := filepath.Join(tmpDir, "test-workflow.md") - if err := os.WriteFile(workflowPath, []byte(tt.markdown), 0644); err != nil { - t.Fatalf("Failed to write test workflow: %v", err) - } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create temporary directory and file + tmpDir := t.TempDir() + workflowPath := filepath.Join(tmpDir, "test-workflow.md") + if err := os.WriteFile(workflowPath, []byte(tt.markdown), 0644); err != nil { + t.Fatalf("Failed to write test workflow: %v", err) + } - // Compile workflow - compiler := NewCompiler(false, "", "test") - err := compiler.CompileWorkflow(workflowPath) - if err != nil { - t.Fatalf("Failed to compile workflow: %v", err) - } + // Compile workflow + compiler := NewCompiler(false, "", "test") + err := compiler.CompileWorkflow(workflowPath) + if err != nil { + t.Fatalf("Failed to compile workflow: %v", err) + } - // Read the generated lock file - lockFile := filepath.Join(tmpDir, "test-workflow.lock.yml") - lockContent, err := os.ReadFile(lockFile) - if err != nil { - t.Fatalf("Failed to read generated lock file: %v", err) - } + // Read the generated lock file + lockFile := filepath.Join(tmpDir, "test-workflow.lock.yml") + lockContent, err := os.ReadFile(lockFile) + if err != nil { + t.Fatalf("Failed to read generated lock file: %v", err) + } - // Check if expected concurrency is in the job section - if !strings.Contains(string(lockContent), tt.expectedInJob) { - t.Errorf("Compiled workflow doesn't contain expected concurrency\nExpected to find:\n%s\n\nFull output:\n%s", - tt.expectedInJob, string(lockContent)) - } - }) -} + // Check if expected concurrency is in the job section + if tt.expectedInJob != "" && !strings.Contains(string(lockContent), tt.expectedInJob) { + t.Errorf("Compiled workflow doesn't contain expected concurrency\nExpected to find:\n%s\n\nFull output:\n%s", + tt.expectedInJob, string(lockContent)) + } + + // Check that notExpectedInJob is NOT in the agent job section + if tt.notExpectedInJob != "" { + // Extract agent job section + agentJobStart := strings.Index(string(lockContent), "agent:") + if agentJobStart == -1 { + t.Fatalf("Could not find agent job in compiled workflow") + } + // Find the next job (or end of file) + nextJobStart := strings.Index(string(lockContent)[agentJobStart+10:], "\n ") + agentJobSection := "" + if nextJobStart == -1 { + agentJobSection = string(lockContent)[agentJobStart:] + } else { + agentJobSection = string(lockContent)[agentJobStart : agentJobStart+10+nextJobStart] + } + + if strings.Contains(agentJobSection, tt.notExpectedInJob) { + t.Errorf("Compiled workflow contains unexpected content in agent job\nDid not expect to find:\n%s\n\nAgent job section:\n%s", + tt.notExpectedInJob, agentJobSection) + } + } + }) + } } From 24fbb38b4fcdc1281a62a41ae87654eb3dba6378 Mon Sep 17 00:00:00 2001 From: Peli de Halleux Date: Tue, 7 Oct 2025 16:27:59 +0000 Subject: [PATCH 7/8] Refactor discussion and issue creation scripts for improved readability and error handling - Simplified the parsing and validation of agent output in create_discussion.js and create_issue.js. - Enhanced error messages for better debugging and user feedback. - Streamlined the process of creating discussions and issues, ensuring consistent handling of labels and titles. - Added checks for repository settings to ensure discussions and issues can be created. - Improved summary output for staged mode to provide clearer previews of discussions and issues to be created. --- .github/workflows/artifacts-summary.lock.yml | 1714 +++++++------- .github/workflows/brave.lock.yml | 1418 +++++------ .../workflows/changeset-generator.lock.yml | 1418 +++++------ .github/workflows/ci-doctor.lock.yml | 1712 +++++++------- .github/workflows/dev.lock.yml | 1714 +++++++------- .../duplicate-code-detector.lock.yml | 1714 +++++++------- .../workflows/go-pattern-detector.lock.yml | 1714 +++++++------- .github/workflows/issue-classifier.lock.yml | 1767 +++++++------- .../workflows/notion-issue-summary.lock.yml | 1416 +++++------ .github/workflows/pdf-summary.lock.yml | 1418 +++++------ .github/workflows/poem-bot.lock.yml | 2069 ++++++++--------- .github/workflows/scout.lock.yml | 1418 +++++------ .../workflows/technical-doc-writer.lock.yml | 1416 +++++------ .github/workflows/tidy.lock.yml | 1416 +++++------ pkg/workflow/js/add_labels.js | 371 ++- pkg/workflow/js/collect_ndjson_output.js | 1414 +++++------ pkg/workflow/js/create_discussion.js | 272 +-- pkg/workflow/js/create_issue.js | 290 +-- 18 files changed, 12338 insertions(+), 12333 deletions(-) diff --git a/.github/workflows/artifacts-summary.lock.yml b/.github/workflows/artifacts-summary.lock.yml index 5186ea7744e..a787a5ec98d 100644 --- a/.github/workflows/artifacts-summary.lock.yml +++ b/.github/workflows/artifacts-summary.lock.yml @@ -1116,742 +1116,742 @@ jobs: with: script: | async function main() { - const fs = require("fs"); - function sanitizeContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; - const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; - const allowedDomains = allowedDomainsEnv - ? allowedDomainsEnv - .split(",") - .map(d => d.trim()) - .filter(d => d) - : defaultAllowedDomains; - let sanitized = content; - sanitized = neutralizeMentions(sanitized); - sanitized = removeXmlComments(sanitized); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitizeUrlProtocols(sanitized); - sanitized = sanitizeUrlDomains(sanitized); - const maxLength = 524288; - if (sanitized.length > maxLength) { - sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; - } - const lines = sanitized.split("\n"); - const maxLines = 65000; - if (lines.length > maxLines) { - sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; - } - sanitized = neutralizeBotTriggers(sanitized); - return sanitized.trim(); - function sanitizeUrlDomains(s) { - return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { - const urlAfterProtocol = match.slice(8); - const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); - const isAllowed = allowedDomains.some(allowedDomain => { - const normalizedAllowed = allowedDomain.toLowerCase(); - return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); - }); - return isAllowed ? match : "(redacted)"; - }); - } - function sanitizeUrlProtocols(s) { - return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { - return protocol.toLowerCase() === "https" ? match : "(redacted)"; - }); - } - function neutralizeMentions(s) { - return s.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - } - function removeXmlComments(s) { - return s.replace(//g, "").replace(//g, ""); - } - function neutralizeBotTriggers(s) { - return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); - } + const fs = require("fs"); + function sanitizeContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; + const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; + const allowedDomains = allowedDomainsEnv + ? allowedDomainsEnv + .split(",") + .map(d => d.trim()) + .filter(d => d) + : defaultAllowedDomains; + let sanitized = content; + sanitized = neutralizeMentions(sanitized); + sanitized = removeXmlComments(sanitized); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitizeUrlProtocols(sanitized); + sanitized = sanitizeUrlDomains(sanitized); + const maxLength = 524288; + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; + } + const lines = sanitized.split("\n"); + const maxLines = 65000; + if (lines.length > maxLines) { + sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; + } + sanitized = neutralizeBotTriggers(sanitized); + return sanitized.trim(); + function sanitizeUrlDomains(s) { + return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { + const urlAfterProtocol = match.slice(8); + const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); + const isAllowed = allowedDomains.some(allowedDomain => { + const normalizedAllowed = allowedDomain.toLowerCase(); + return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); + }); + return isAllowed ? match : "(redacted)"; + }); } - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create-issue": - return 1; - case "add-comment": - return 1; - case "create-pull-request": - return 1; - case "create-pull-request-review-comment": - return 1; - case "add-labels": - return 5; - case "update-issue": - return 1; - case "push-to-pull-request-branch": - return 1; - case "create-discussion": - return 1; - case "missing-tool": - return 1000; - case "create-code-scanning-alert": - return 1000; - case "upload-asset": - return 10; - default: - return 1; - } + function sanitizeUrlProtocols(s) { + return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { + return protocol.toLowerCase() === "https" ? match : "(redacted)"; + }); } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; - } - return 0; - } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } - else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } - else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; - } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; - } - return { isValid: true, normalizedValue: parsed }; + function neutralizeMentions(s) { + return s.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; - } - return { isValid: true, normalizedValue: parsed }; + function removeXmlComments(s) { + return s.replace(//g, "").replace(//g, ""); } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - return { isValid: true }; - } - function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { - if (inputSchema.required && (value === undefined || value === null)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; - } - if (value === undefined || value === null) { - return { - isValid: true, - normalizedValue: inputSchema.default || undefined, - }; - } - const inputType = inputSchema.type || "string"; - let normalizedValue = value; - switch (inputType) { - case "string": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string`, - }; - } - normalizedValue = sanitizeContent(value); - break; - case "boolean": - if (typeof value !== "boolean") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a boolean`, - }; - } - break; - case "number": - if (typeof value !== "number") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number`, - }; - } - break; - case "choice": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, - }; - } - if (inputSchema.options && !inputSchema.options.includes(value)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, - }; - } - normalizedValue = sanitizeContent(value); - break; - default: - if (typeof value === "string") { - normalizedValue = sanitizeContent(value); - } - break; - } + function neutralizeBotTriggers(s) { + return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); + } + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + switch (itemType) { + case "create-issue": + return 1; + case "add-comment": + return 1; + case "create-pull-request": + return 1; + case "create-pull-request-review-comment": + return 1; + case "add-labels": + return 5; + case "update-issue": + return 1; + case "push-to-pull-request-branch": + return 1; + case "create-discussion": + return 1; + case "missing-tool": + return 1000; + case "create-code-scanning-alert": + return 1000; + case "upload-asset": + return 10; + default: + return 1; + } + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; + } + return 0; + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { return { - isValid: true, - normalizedValue, + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; } - function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { - const errors = []; - const normalizedItem = { ...item }; - if (!jobConfig.inputs) { - return { - isValid: true, - errors: [], - normalizedItem: item, - }; - } - for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { - const fieldValue = item[fieldName]; - const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); - if (!validation.isValid && validation.error) { - errors.push(validation.error); - } - else if (validation.normalizedValue !== undefined) { - normalizedItem[fieldName] = validation.normalizedValue; - } - } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-code-scanning-alert 'line'")) { return { - isValid: errors.length === 0, - errors, - normalizedItem, + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; } - function parseJsonWithRepair(jsonStr) { - try { - return JSON.parse(jsonStr); - } - catch (originalError) { - try { - const repairedJson = repairJson(jsonStr); - return JSON.parse(repairedJson); - } - catch (repairError) { - core.info(`invalid input json: ${jsonStr}`); - const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); - const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); - throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); - } - } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; } - const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; - const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; - if (!outputFile) { - core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); - core.setOutput("output", ""); - return; + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; } - if (!fs.existsSync(outputFile)) { - core.info(`Output file does not exist: ${outputFile}`); - core.setOutput("output", ""); - return; + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; } - const outputContent = fs.readFileSync(outputFile, "utf8"); - if (outputContent.trim() === "") { - core.info("Output file is empty"); + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; } - core.info(`Raw output content length: ${outputContent.length}`); - let expectedOutputTypes = {}; - if (safeOutputsConfig) { - try { - expectedOutputTypes = JSON.parse(safeOutputsConfig); - core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); + return { isValid: true }; + } + function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { + if (inputSchema.required && (value === undefined || value === null)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (value === undefined || value === null) { + return { + isValid: true, + normalizedValue: inputSchema.default || undefined, + }; + } + const inputType = inputSchema.type || "string"; + let normalizedValue = value; + switch (inputType) { + case "string": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string`, + }; } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); + normalizedValue = sanitizeContent(value); + break; + case "boolean": + if (typeof value !== "boolean") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a boolean`, + }; + } + break; + case "number": + if (typeof value !== "number") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number`, + }; + } + break; + case "choice": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, + }; } + if (inputSchema.options && !inputSchema.options.includes(value)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, + }; + } + normalizedValue = sanitizeContent(value); + break; + default: + if (typeof value === "string") { + normalizedValue = sanitizeContent(value); + } + break; } - const lines = outputContent.trim().split("\n"); - const parsedItems = []; + return { + isValid: true, + normalizedValue, + }; + } + function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { const errors = []; - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); - if (line === "") + const normalizedItem = { ...item }; + if (!jobConfig.inputs) { + return { + isValid: true, + errors: [], + normalizedItem: item, + }; + } + for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { + const fieldValue = item[fieldName]; + const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); + if (!validation.isValid && validation.error) { + errors.push(validation.error); + } else if (validation.normalizedValue !== undefined) { + normalizedItem[fieldName] = validation.normalizedValue; + } + } + return { + isValid: errors.length === 0, + errors, + normalizedItem, + }; + } + function parseJsonWithRepair(jsonStr) { + try { + return JSON.parse(jsonStr); + } catch (originalError) { + try { + const repairedJson = repairJson(jsonStr); + return JSON.parse(repairedJson); + } catch (repairError) { + core.info(`invalid input json: ${jsonStr}`); + const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); + const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); + throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); + } + } + } + const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; + const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; + if (!outputFile) { + core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); + core.setOutput("output", ""); + return; + } + if (!fs.existsSync(outputFile)) { + core.info(`Output file does not exist: ${outputFile}`); + core.setOutput("output", ""); + return; + } + const outputContent = fs.readFileSync(outputFile, "utf8"); + if (outputContent.trim() === "") { + core.info("Output file is empty"); + } + core.info(`Raw output content length: ${outputContent.length}`); + let expectedOutputTypes = {}; + if (safeOutputsConfig) { + try { + expectedOutputTypes = JSON.parse(safeOutputsConfig); + core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); + } + } + const lines = outputContent.trim().split("\n"); + const parsedItems = []; + const errors = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line === "") continue; + try { + const item = parseJsonWithRepair(line); + if (item === undefined) { + errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); + continue; + } + if (!item.type) { + errors.push(`Line ${i + 1}: Missing required 'type' field`); + continue; + } + const itemType = item.type; + if (!expectedOutputTypes[itemType]) { + errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); + continue; + } + const typeCount = parsedItems.filter(existing => existing.type === itemType).length; + const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); + if (typeCount >= maxAllowed) { + errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); + continue; + } + core.info(`Line ${i + 1}: type '${itemType}'`); + switch (itemType) { + case "create-issue": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); continue; - try { - const item = parseJsonWithRepair(line); - if (item === undefined) { - errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); - continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-comment": + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); + continue; + } + const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); + if (!issueNumValidation.isValid) { + if (issueNumValidation.error) errors.push(issueNumValidation.error); + continue; + } + item.body = sanitizeContent(item.body); + break; + case "create-pull-request": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); + continue; + } + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + item.branch = sanitizeContent(item.branch); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-labels": + if (!item.labels || !Array.isArray(item.labels)) { + errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); + continue; + } + if (item.labels.some(label => typeof label !== "string")) { + errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); + continue; + } + const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); + if (!labelsIssueNumValidation.isValid) { + if (labelsIssueNumValidation.error) errors.push(labelsIssueNumValidation.error); + continue; + } + item.labels = item.labels.map(label => sanitizeContent(label)); + break; + case "update-issue": + const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; + if (!hasValidField) { + errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); + continue; + } + if (item.status !== undefined) { + if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { + errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); + continue; } - if (!item.type) { - errors.push(`Line ${i + 1}: Missing required 'type' field`); - continue; + } + if (item.title !== undefined) { + if (typeof item.title !== "string") { + errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); + continue; } - const itemType = item.type; - if (!expectedOutputTypes[itemType]) { - errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); - continue; + item.title = sanitizeContent(item.title); + } + if (item.body !== undefined) { + if (typeof item.body !== "string") { + errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); + continue; } - const typeCount = parsedItems.filter(existing => existing.type === itemType).length; - const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); - if (typeCount >= maxAllowed) { - errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); - continue; + item.body = sanitizeContent(item.body); + } + const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); + if (!updateIssueNumValidation.isValid) { + if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); + continue; + } + break; + case "push-to-pull-request-branch": + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); + continue; + } + item.branch = sanitizeContent(item.branch); + item.message = sanitizeContent(item.message); + const pushPRNumValidation = validateIssueOrPRNumber( + item.pull_request_number, + "push-to-pull-request-branch 'pull_request_number'", + i + 1 + ); + if (!pushPRNumValidation.isValid) { + if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); + continue; + } + break; + case "create-pull-request-review-comment": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); + continue; + } + const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); + if (!lineValidation.isValid) { + if (lineValidation.error) errors.push(lineValidation.error); + continue; + } + const lineNumber = lineValidation.normalizedValue; + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); + continue; + } + item.body = sanitizeContent(item.body); + const startLineValidation = validateOptionalPositiveInteger( + item.start_line, + "create-pull-request-review-comment 'start_line'", + i + 1 + ); + if (!startLineValidation.isValid) { + if (startLineValidation.error) errors.push(startLineValidation.error); + continue; + } + if ( + startLineValidation.normalizedValue !== undefined && + lineNumber !== undefined && + startLineValidation.normalizedValue > lineNumber + ) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); + continue; + } + if (item.side !== undefined) { + if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); + continue; } - core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create-issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); - if (!issueNumValidation.isValid) { - if (issueNumValidation.error) - errors.push(issueNumValidation.error); - continue; - } - item.body = sanitizeContent(item.body); - break; - case "create-pull-request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - item.branch = sanitizeContent(item.branch); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some((label) => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); - if (!labelsIssueNumValidation.isValid) { - if (labelsIssueNumValidation.error) - errors.push(labelsIssueNumValidation.error); - continue; - } - item.labels = item.labels.map((label) => sanitizeContent(label)); - break; - case "update-issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) - errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "push-to-pull-request-branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch); - item.message = sanitizeContent(item.message); - const pushPRNumValidation = validateIssueOrPRNumber(item.pull_request_number, "push-to-pull-request-branch 'pull_request_number'", i + 1); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) - errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create-pull-request-review-comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) - errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body); - const startLineValidation = validateOptionalPositiveInteger(item.start_line, "create-pull-request-review-comment 'start_line'", i + 1); - if (!startLineValidation.isValid) { - if (startLineValidation.error) - errors.push(startLineValidation.error); - continue; - } - if (startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create-discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category); - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - break; - case "missing-tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool); - item.reason = sanitizeContent(item.reason); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives); - } - break; - case "upload-asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "create-code-scanning-alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}`); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) - errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores`); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file); - item.severity = sanitizeContent(item.severity); - item.message = sanitizeContent(item.message); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); - continue; - } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + } + break; + case "create-discussion": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); + continue; + } + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); + continue; } - core.info(`Line ${i + 1}: Valid ${itemType} item`); - parsedItems.push(item); - } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); - } - } - if (errors.length > 0) { - core.warning("Validation errors found:"); - errors.forEach(error => core.warning(` - ${error}`)); - if (parsedItems.length === 0) { - core.setFailed(errors.map(e => ` - ${e}`).join("\n")); - return; - } - } - for (const itemType of Object.keys(expectedOutputTypes)) { - const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); - if (minRequired > 0) { - const actualCount = parsedItems.filter(item => item.type === itemType).length; - if (actualCount < minRequired) { - errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); + item.category = sanitizeContent(item.category); + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + break; + case "missing-tool": + if (!item.tool || typeof item.tool !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); + continue; + } + if (!item.reason || typeof item.reason !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); + continue; + } + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); + continue; } - } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "upload-asset": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); + continue; + } + break; + case "create-code-scanning-alert": + if (!item.file || typeof item.file !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); + continue; + } + const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); + if (!alertLineValidation.isValid) { + if (alertLineValidation.error) { + errors.push(alertLineValidation.error); + } + continue; + } + if (!item.severity || typeof item.severity !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); + continue; + } + const allowedSeverities = ["error", "warning", "info", "note"]; + if (!allowedSeverities.includes(item.severity.toLowerCase())) { + errors.push( + `Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` + ); + continue; + } + const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); + if (!columnValidation.isValid) { + if (columnValidation.error) errors.push(columnValidation.error); + continue; + } + if (item.ruleIdSuffix !== undefined) { + if (typeof item.ruleIdSuffix !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); + continue; + } + if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { + errors.push( + `Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` + ); + continue; + } + } + item.severity = item.severity.toLowerCase(); + item.file = sanitizeContent(item.file); + item.severity = sanitizeContent(item.severity); + item.message = sanitizeContent(item.message); + if (item.ruleIdSuffix) { + item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); + } + break; + default: + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); + continue; + } + Object.assign(item, validation.normalizedItem); + } + break; + } + core.info(`Line ${i + 1}: Valid ${itemType} item`); + parsedItems.push(item); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); } - core.info(`Successfully parsed ${parsedItems.length} valid output items`); - const validatedOutput = { - items: parsedItems, - errors: errors, - }; - const agentOutputFile = "/tmp/agent_output.json"; - const validatedOutputJson = JSON.stringify(validatedOutput); - try { - fs.mkdirSync("/tmp", { recursive: true }); - fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); - core.info(`Stored validated output to: ${agentOutputFile}`); - core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); - } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.error(`Failed to write agent output file: ${errorMsg}`); - } - core.setOutput("output", JSON.stringify(validatedOutput)); - core.setOutput("raw_output", outputContent); - const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); - core.info(`output_types: ${outputTypes.join(", ")}`); - core.setOutput("output_types", outputTypes.join(",")); - try { - await core.summary - .addRaw("## Processed Output\n\n") - .addRaw("```json\n") - .addRaw(JSON.stringify(validatedOutput)) - .addRaw("\n```\n") - .write(); - core.info("Successfully wrote processed output to step summary"); - } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.warning(`Failed to write to step summary: ${errorMsg}`); + } + if (errors.length > 0) { + core.warning("Validation errors found:"); + errors.forEach(error => core.warning(` - ${error}`)); + if (parsedItems.length === 0) { + core.setFailed(errors.map(e => ` - ${e}`).join("\n")); + return; } + } + for (const itemType of Object.keys(expectedOutputTypes)) { + const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); + if (minRequired > 0) { + const actualCount = parsedItems.filter(item => item.type === itemType).length; + if (actualCount < minRequired) { + errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); + } + } + } + core.info(`Successfully parsed ${parsedItems.length} valid output items`); + const validatedOutput = { + items: parsedItems, + errors: errors, + }; + const agentOutputFile = "/tmp/agent_output.json"; + const validatedOutputJson = JSON.stringify(validatedOutput); + try { + fs.mkdirSync("/tmp", { recursive: true }); + fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); + core.info(`Stored validated output to: ${agentOutputFile}`); + core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.error(`Failed to write agent output file: ${errorMsg}`); + } + core.setOutput("output", JSON.stringify(validatedOutput)); + core.setOutput("raw_output", outputContent); + const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); + core.info(`output_types: ${outputTypes.join(", ")}`); + core.setOutput("output_types", outputTypes.join(",")); + try { + await core.summary + .addRaw("## Processed Output\n\n") + .addRaw("```json\n") + .addRaw(JSON.stringify(validatedOutput)) + .addRaw("\n```\n") + .write(); + core.info("Successfully wrote processed output to step summary"); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.warning(`Failed to write to step summary: ${errorMsg}`); + } } await main(); - name: Upload sanitized agent output @@ -2750,163 +2750,165 @@ jobs: with: script: | function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); + if (!content || typeof content !== "string") { + return ""; + } + let sanitized = content.trim(); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); + sanitized = sanitized.replace(/[<>&'"]/g, ""); + return sanitized.trim(); } async function main() { - const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; - const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; - if (!outputContent) { - core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); - return; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return; + const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; + const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; + if (!outputContent) { + core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); + return; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); + return; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + return; + } + const createIssueItems = validatedOutput.items.filter(item => item.type === "create-issue"); + if (createIssueItems.length === 0) { + core.info("No create-issue items found in agent output"); + return; + } + core.info(`Found ${createIssueItems.length} create-issue item(s)`); + if (isStaged) { + let summaryContent = "## 🎭 Staged Mode: Create Issues Preview\n\n"; + summaryContent += "The following issues would be created if staged mode was disabled:\n\n"; + for (let i = 0; i < createIssueItems.length; i++) { + const item = createIssueItems[i]; + summaryContent += `### Issue ${i + 1}\n`; + summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; + if (item.body) { + summaryContent += `**Body:**\n${item.body}\n\n`; + } + if (item.labels && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Issue creation preview written to step summary"); + return; + } + const parentIssueNumber = context.payload?.issue?.number; + const labelsEnv = process.env.GITHUB_AW_ISSUE_LABELS; + let envLabels = labelsEnv + ? labelsEnv + .split(",") + .map(label => label.trim()) + .filter(label => label) + : []; + const createdIssues = []; + for (let i = 0; i < createIssueItems.length; i++) { + const createIssueItem = createIssueItems[i]; + core.info( + `Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}` + ); + let labels = [...envLabels]; + if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { + labels = [...labels, ...createIssueItem.labels]; + } + labels = labels + .filter(label => label != null && label !== false && label !== 0) + .map(label => String(label).trim()) + .filter(label => label) + .map(label => sanitizeLabelContent(label)) + .filter(label => label) + .map(label => (label.length > 64 ? label.substring(0, 64) : label)) + .filter((label, index, arr) => arr.indexOf(label) === index); + let title = createIssueItem.title ? createIssueItem.title.trim() : ""; + let bodyLines = createIssueItem.body.split("\n"); + if (!title) { + title = createIssueItem.body || "Agent Output"; + } + const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; + if (titlePrefix && !title.startsWith(titlePrefix)) { + title = titlePrefix + title; + } + if (parentIssueNumber) { + core.info("Detected issue context, parent issue #" + parentIssueNumber); + bodyLines.push(`Related to #${parentIssueNumber}`); + } + const workflowName = process.env.GITHUB_AW_WORKFLOW_NAME || "Workflow"; + const runId = context.runId; + const runUrl = context.payload.repository + ? `${context.payload.repository.html_url}/actions/runs/${runId}` + : `https://github.com/actions/runs/${runId}`; + bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); + const body = bodyLines.join("\n").trim(); + core.info(`Creating issue with title: ${title}`); + core.info(`Labels: ${labels}`); + core.info(`Body length: ${body.length}`); try { - validatedOutput = JSON.parse(outputContent); - } - catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - return; - } - const createIssueItems = validatedOutput.items.filter(item => item.type === "create-issue"); - if (createIssueItems.length === 0) { - core.info("No create-issue items found in agent output"); - return; - } - core.info(`Found ${createIssueItems.length} create-issue item(s)`); - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Issues Preview\n\n"; - summaryContent += "The following issues would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createIssueItems.length; i++) { - const item = createIssueItems[i]; - summaryContent += `### Issue ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; - } - if (item.labels && item.labels.length > 0) { - summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; - } - summaryContent += "---\n\n"; - } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Issue creation preview written to step summary"); - return; - } - const parentIssueNumber = context.payload?.issue?.number; - const labelsEnv = process.env.GITHUB_AW_ISSUE_LABELS; - let envLabels = labelsEnv - ? labelsEnv - .split(",") - .map((label) => label.trim()) - .filter((label) => label) - : []; - const createdIssues = []; - for (let i = 0; i < createIssueItems.length; i++) { - const createIssueItem = createIssueItems[i]; - core.info(`Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}`); - let labels = [...envLabels]; - if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { - labels = [...labels, ...createIssueItem.labels]; - } - labels = labels - .filter(label => label != null && label !== false && label !== 0) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - let title = createIssueItem.title ? createIssueItem.title.trim() : ""; - let bodyLines = createIssueItem.body.split("\n"); - if (!title) { - title = createIssueItem.body || "Agent Output"; - } - const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - if (parentIssueNumber) { - core.info("Detected issue context, parent issue #" + parentIssueNumber); - bodyLines.push(`Related to #${parentIssueNumber}`); - } - const workflowName = process.env.GITHUB_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const runUrl = context.payload.repository - ? `${context.payload.repository.html_url}/actions/runs/${runId}` - : `https://github.com/actions/runs/${runId}`; - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating issue with title: ${title}`); - core.info(`Labels: ${labels}`); - core.info(`Body length: ${body.length}`); + const { data: issue } = await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: title, + body: body, + labels: labels, + }); + core.info("Created issue #" + issue.number + ": " + issue.html_url); + createdIssues.push(issue); + if (parentIssueNumber) { try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: body, - labels: labels, - }); - core.info("Created issue #" + issue.number + ": " + issue.html_url); - createdIssues.push(issue); - if (parentIssueNumber) { - try { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: parentIssueNumber, - body: `Created related issue: #${issue.number}`, - }); - core.info("Added comment to parent issue #" + parentIssueNumber); - } - catch (error) { - core.info(`Warning: Could not add comment to parent issue: ${error instanceof Error ? error.message : String(error)}`); - } - } - if (i === createIssueItems.length - 1) { - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - } - } - catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Issues has been disabled in this repository")) { - core.info(`⚠ Cannot create issue "${title}": Issues are disabled for this repository`); - core.info("Consider enabling issues in repository settings if you want to create issues automatically"); - continue; - } - core.error(`✗ Failed to create issue "${title}": ${errorMessage}`); - throw error; + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: parentIssueNumber, + body: `Created related issue: #${issue.number}`, + }); + core.info("Added comment to parent issue #" + parentIssueNumber); + } catch (error) { + core.info(`Warning: Could not add comment to parent issue: ${error instanceof Error ? error.message : String(error)}`); } + } + if (i === createIssueItems.length - 1) { + core.setOutput("issue_number", issue.number); + core.setOutput("issue_url", issue.html_url); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage.includes("Issues has been disabled in this repository")) { + core.info(`⚠ Cannot create issue "${title}": Issues are disabled for this repository`); + core.info("Consider enabling issues in repository settings if you want to create issues automatically"); + continue; + } + core.error(`✗ Failed to create issue "${title}": ${errorMessage}`); + throw error; } - if (createdIssues.length > 0) { - let summaryContent = "\n\n## GitHub Issues\n"; - for (const issue of createdIssues) { - summaryContent += `- Issue #${issue.number}: [${issue.title}](${issue.html_url})\n`; - } - await core.summary.addRaw(summaryContent).write(); + } + if (createdIssues.length > 0) { + let summaryContent = "\n\n## GitHub Issues\n"; + for (const issue of createdIssues) { + summaryContent += `- Issue #${issue.number}: [${issue.title}](${issue.html_url})\n`; } - core.info(`Successfully created ${createdIssues.length} issue(s)`); + await core.summary.addRaw(summaryContent).write(); + } + core.info(`Successfully created ${createdIssues.length} issue(s)`); } (async () => { - await main(); + await main(); })(); missing_tool: diff --git a/.github/workflows/brave.lock.yml b/.github/workflows/brave.lock.yml index 02d3bbc544d..4b1b1da7397 100644 --- a/.github/workflows/brave.lock.yml +++ b/.github/workflows/brave.lock.yml @@ -1515,742 +1515,742 @@ jobs: with: script: | async function main() { - const fs = require("fs"); - function sanitizeContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; - const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; - const allowedDomains = allowedDomainsEnv - ? allowedDomainsEnv - .split(",") - .map(d => d.trim()) - .filter(d => d) - : defaultAllowedDomains; - let sanitized = content; - sanitized = neutralizeMentions(sanitized); - sanitized = removeXmlComments(sanitized); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitizeUrlProtocols(sanitized); - sanitized = sanitizeUrlDomains(sanitized); - const maxLength = 524288; - if (sanitized.length > maxLength) { - sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; - } - const lines = sanitized.split("\n"); - const maxLines = 65000; - if (lines.length > maxLines) { - sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; - } - sanitized = neutralizeBotTriggers(sanitized); - return sanitized.trim(); - function sanitizeUrlDomains(s) { - return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { - const urlAfterProtocol = match.slice(8); - const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); - const isAllowed = allowedDomains.some(allowedDomain => { - const normalizedAllowed = allowedDomain.toLowerCase(); - return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); - }); - return isAllowed ? match : "(redacted)"; - }); - } - function sanitizeUrlProtocols(s) { - return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { - return protocol.toLowerCase() === "https" ? match : "(redacted)"; - }); - } - function neutralizeMentions(s) { - return s.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - } - function removeXmlComments(s) { - return s.replace(//g, "").replace(//g, ""); - } - function neutralizeBotTriggers(s) { - return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); - } + const fs = require("fs"); + function sanitizeContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; + const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; + const allowedDomains = allowedDomainsEnv + ? allowedDomainsEnv + .split(",") + .map(d => d.trim()) + .filter(d => d) + : defaultAllowedDomains; + let sanitized = content; + sanitized = neutralizeMentions(sanitized); + sanitized = removeXmlComments(sanitized); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitizeUrlProtocols(sanitized); + sanitized = sanitizeUrlDomains(sanitized); + const maxLength = 524288; + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; + } + const lines = sanitized.split("\n"); + const maxLines = 65000; + if (lines.length > maxLines) { + sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; + } + sanitized = neutralizeBotTriggers(sanitized); + return sanitized.trim(); + function sanitizeUrlDomains(s) { + return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { + const urlAfterProtocol = match.slice(8); + const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); + const isAllowed = allowedDomains.some(allowedDomain => { + const normalizedAllowed = allowedDomain.toLowerCase(); + return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); + }); + return isAllowed ? match : "(redacted)"; + }); } - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create-issue": - return 1; - case "add-comment": - return 1; - case "create-pull-request": - return 1; - case "create-pull-request-review-comment": - return 1; - case "add-labels": - return 5; - case "update-issue": - return 1; - case "push-to-pull-request-branch": - return 1; - case "create-discussion": - return 1; - case "missing-tool": - return 1000; - case "create-code-scanning-alert": - return 1000; - case "upload-asset": - return 10; - default: - return 1; - } + function sanitizeUrlProtocols(s) { + return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { + return protocol.toLowerCase() === "https" ? match : "(redacted)"; + }); } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; - } - return 0; - } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } - else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } - else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; - } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; - } - return { isValid: true, normalizedValue: parsed }; + function neutralizeMentions(s) { + return s.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); + } + function removeXmlComments(s) { + return s.replace(//g, "").replace(//g, ""); + } + function neutralizeBotTriggers(s) { + return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); + } + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + switch (itemType) { + case "create-issue": + return 1; + case "add-comment": + return 1; + case "create-pull-request": + return 1; + case "create-pull-request-review-comment": + return 1; + case "add-labels": + return 5; + case "update-issue": + return 1; + case "push-to-pull-request-branch": + return 1; + case "create-discussion": + return 1; + case "missing-tool": + return 1000; + case "create-code-scanning-alert": + return 1000; + case "upload-asset": + return 10; + default: + return 1; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; - } - return { isValid: true, normalizedValue: parsed }; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - return { isValid: true }; - } - function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { - if (inputSchema.required && (value === undefined || value === null)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; - } - if (value === undefined || value === null) { - return { - isValid: true, - normalizedValue: inputSchema.default || undefined, - }; - } - const inputType = inputSchema.type || "string"; - let normalizedValue = value; - switch (inputType) { - case "string": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string`, - }; - } - normalizedValue = sanitizeContent(value); - break; - case "boolean": - if (typeof value !== "boolean") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a boolean`, - }; - } - break; - case "number": - if (typeof value !== "number") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number`, - }; - } - break; - case "choice": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, - }; - } - if (inputSchema.options && !inputSchema.options.includes(value)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, - }; - } - normalizedValue = sanitizeContent(value); - break; - default: - if (typeof value === "string") { - normalizedValue = sanitizeContent(value); - } - break; - } + return 0; + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { return { - isValid: true, - normalizedValue, + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; } - function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { - const errors = []; - const normalizedItem = { ...item }; - if (!jobConfig.inputs) { - return { - isValid: true, - errors: [], - normalizedItem: item, - }; - } - for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { - const fieldValue = item[fieldName]; - const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); - if (!validation.isValid && validation.error) { - errors.push(validation.error); - } - else if (validation.normalizedValue !== undefined) { - normalizedItem[fieldName] = validation.normalizedValue; - } - } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-code-scanning-alert 'line'")) { return { - isValid: errors.length === 0, - errors, - normalizedItem, + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; } - function parseJsonWithRepair(jsonStr) { - try { - return JSON.parse(jsonStr); - } - catch (originalError) { - try { - const repairedJson = repairJson(jsonStr); - return JSON.parse(repairedJson); - } - catch (repairError) { - core.info(`invalid input json: ${jsonStr}`); - const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); - const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); - throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); - } - } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; } - const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; - const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; - if (!outputFile) { - core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); - core.setOutput("output", ""); - return; + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; } - if (!fs.existsSync(outputFile)) { - core.info(`Output file does not exist: ${outputFile}`); - core.setOutput("output", ""); - return; + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; } - const outputContent = fs.readFileSync(outputFile, "utf8"); - if (outputContent.trim() === "") { - core.info("Output file is empty"); + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { + if (inputSchema.required && (value === undefined || value === null)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; } - core.info(`Raw output content length: ${outputContent.length}`); - let expectedOutputTypes = {}; - if (safeOutputsConfig) { - try { - expectedOutputTypes = JSON.parse(safeOutputsConfig); - core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); + if (value === undefined || value === null) { + return { + isValid: true, + normalizedValue: inputSchema.default || undefined, + }; + } + const inputType = inputSchema.type || "string"; + let normalizedValue = value; + switch (inputType) { + case "string": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string`, + }; + } + normalizedValue = sanitizeContent(value); + break; + case "boolean": + if (typeof value !== "boolean") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a boolean`, + }; } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); + break; + case "number": + if (typeof value !== "number") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number`, + }; } + break; + case "choice": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, + }; + } + if (inputSchema.options && !inputSchema.options.includes(value)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, + }; + } + normalizedValue = sanitizeContent(value); + break; + default: + if (typeof value === "string") { + normalizedValue = sanitizeContent(value); + } + break; } - const lines = outputContent.trim().split("\n"); - const parsedItems = []; + return { + isValid: true, + normalizedValue, + }; + } + function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { const errors = []; - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); - if (line === "") + const normalizedItem = { ...item }; + if (!jobConfig.inputs) { + return { + isValid: true, + errors: [], + normalizedItem: item, + }; + } + for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { + const fieldValue = item[fieldName]; + const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); + if (!validation.isValid && validation.error) { + errors.push(validation.error); + } else if (validation.normalizedValue !== undefined) { + normalizedItem[fieldName] = validation.normalizedValue; + } + } + return { + isValid: errors.length === 0, + errors, + normalizedItem, + }; + } + function parseJsonWithRepair(jsonStr) { + try { + return JSON.parse(jsonStr); + } catch (originalError) { + try { + const repairedJson = repairJson(jsonStr); + return JSON.parse(repairedJson); + } catch (repairError) { + core.info(`invalid input json: ${jsonStr}`); + const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); + const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); + throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); + } + } + } + const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; + const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; + if (!outputFile) { + core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); + core.setOutput("output", ""); + return; + } + if (!fs.existsSync(outputFile)) { + core.info(`Output file does not exist: ${outputFile}`); + core.setOutput("output", ""); + return; + } + const outputContent = fs.readFileSync(outputFile, "utf8"); + if (outputContent.trim() === "") { + core.info("Output file is empty"); + } + core.info(`Raw output content length: ${outputContent.length}`); + let expectedOutputTypes = {}; + if (safeOutputsConfig) { + try { + expectedOutputTypes = JSON.parse(safeOutputsConfig); + core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); + } + } + const lines = outputContent.trim().split("\n"); + const parsedItems = []; + const errors = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line === "") continue; + try { + const item = parseJsonWithRepair(line); + if (item === undefined) { + errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); + continue; + } + if (!item.type) { + errors.push(`Line ${i + 1}: Missing required 'type' field`); + continue; + } + const itemType = item.type; + if (!expectedOutputTypes[itemType]) { + errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); + continue; + } + const typeCount = parsedItems.filter(existing => existing.type === itemType).length; + const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); + if (typeCount >= maxAllowed) { + errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); + continue; + } + core.info(`Line ${i + 1}: type '${itemType}'`); + switch (itemType) { + case "create-issue": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); continue; - try { - const item = parseJsonWithRepair(line); - if (item === undefined) { - errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); - continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-comment": + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); + continue; + } + const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); + if (!issueNumValidation.isValid) { + if (issueNumValidation.error) errors.push(issueNumValidation.error); + continue; + } + item.body = sanitizeContent(item.body); + break; + case "create-pull-request": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); + continue; + } + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + item.branch = sanitizeContent(item.branch); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-labels": + if (!item.labels || !Array.isArray(item.labels)) { + errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); + continue; + } + if (item.labels.some(label => typeof label !== "string")) { + errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); + continue; + } + const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); + if (!labelsIssueNumValidation.isValid) { + if (labelsIssueNumValidation.error) errors.push(labelsIssueNumValidation.error); + continue; + } + item.labels = item.labels.map(label => sanitizeContent(label)); + break; + case "update-issue": + const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; + if (!hasValidField) { + errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); + continue; + } + if (item.status !== undefined) { + if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { + errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); + continue; } - if (!item.type) { - errors.push(`Line ${i + 1}: Missing required 'type' field`); - continue; + } + if (item.title !== undefined) { + if (typeof item.title !== "string") { + errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); + continue; } - const itemType = item.type; - if (!expectedOutputTypes[itemType]) { - errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); - continue; + item.title = sanitizeContent(item.title); + } + if (item.body !== undefined) { + if (typeof item.body !== "string") { + errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); + continue; } - const typeCount = parsedItems.filter(existing => existing.type === itemType).length; - const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); - if (typeCount >= maxAllowed) { - errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); - continue; + item.body = sanitizeContent(item.body); + } + const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); + if (!updateIssueNumValidation.isValid) { + if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); + continue; + } + break; + case "push-to-pull-request-branch": + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); + continue; + } + item.branch = sanitizeContent(item.branch); + item.message = sanitizeContent(item.message); + const pushPRNumValidation = validateIssueOrPRNumber( + item.pull_request_number, + "push-to-pull-request-branch 'pull_request_number'", + i + 1 + ); + if (!pushPRNumValidation.isValid) { + if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); + continue; + } + break; + case "create-pull-request-review-comment": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); + continue; + } + const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); + if (!lineValidation.isValid) { + if (lineValidation.error) errors.push(lineValidation.error); + continue; + } + const lineNumber = lineValidation.normalizedValue; + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); + continue; + } + item.body = sanitizeContent(item.body); + const startLineValidation = validateOptionalPositiveInteger( + item.start_line, + "create-pull-request-review-comment 'start_line'", + i + 1 + ); + if (!startLineValidation.isValid) { + if (startLineValidation.error) errors.push(startLineValidation.error); + continue; + } + if ( + startLineValidation.normalizedValue !== undefined && + lineNumber !== undefined && + startLineValidation.normalizedValue > lineNumber + ) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); + continue; + } + if (item.side !== undefined) { + if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); + continue; } - core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create-issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); - if (!issueNumValidation.isValid) { - if (issueNumValidation.error) - errors.push(issueNumValidation.error); - continue; - } - item.body = sanitizeContent(item.body); - break; - case "create-pull-request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - item.branch = sanitizeContent(item.branch); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some((label) => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); - if (!labelsIssueNumValidation.isValid) { - if (labelsIssueNumValidation.error) - errors.push(labelsIssueNumValidation.error); - continue; - } - item.labels = item.labels.map((label) => sanitizeContent(label)); - break; - case "update-issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) - errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "push-to-pull-request-branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch); - item.message = sanitizeContent(item.message); - const pushPRNumValidation = validateIssueOrPRNumber(item.pull_request_number, "push-to-pull-request-branch 'pull_request_number'", i + 1); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) - errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create-pull-request-review-comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) - errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body); - const startLineValidation = validateOptionalPositiveInteger(item.start_line, "create-pull-request-review-comment 'start_line'", i + 1); - if (!startLineValidation.isValid) { - if (startLineValidation.error) - errors.push(startLineValidation.error); - continue; - } - if (startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create-discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category); - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - break; - case "missing-tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool); - item.reason = sanitizeContent(item.reason); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives); - } - break; - case "upload-asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "create-code-scanning-alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}`); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) - errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores`); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file); - item.severity = sanitizeContent(item.severity); - item.message = sanitizeContent(item.message); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); - continue; - } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + } + break; + case "create-discussion": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); + continue; + } + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); + continue; } - core.info(`Line ${i + 1}: Valid ${itemType} item`); - parsedItems.push(item); - } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); - } - } - if (errors.length > 0) { - core.warning("Validation errors found:"); - errors.forEach(error => core.warning(` - ${error}`)); - if (parsedItems.length === 0) { - core.setFailed(errors.map(e => ` - ${e}`).join("\n")); - return; - } - } - for (const itemType of Object.keys(expectedOutputTypes)) { - const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); - if (minRequired > 0) { - const actualCount = parsedItems.filter(item => item.type === itemType).length; - if (actualCount < minRequired) { - errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); + item.category = sanitizeContent(item.category); + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + break; + case "missing-tool": + if (!item.tool || typeof item.tool !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); + continue; + } + if (!item.reason || typeof item.reason !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); + continue; + } + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); + continue; } - } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "upload-asset": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); + continue; + } + break; + case "create-code-scanning-alert": + if (!item.file || typeof item.file !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); + continue; + } + const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); + if (!alertLineValidation.isValid) { + if (alertLineValidation.error) { + errors.push(alertLineValidation.error); + } + continue; + } + if (!item.severity || typeof item.severity !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); + continue; + } + const allowedSeverities = ["error", "warning", "info", "note"]; + if (!allowedSeverities.includes(item.severity.toLowerCase())) { + errors.push( + `Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` + ); + continue; + } + const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); + if (!columnValidation.isValid) { + if (columnValidation.error) errors.push(columnValidation.error); + continue; + } + if (item.ruleIdSuffix !== undefined) { + if (typeof item.ruleIdSuffix !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); + continue; + } + if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { + errors.push( + `Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` + ); + continue; + } + } + item.severity = item.severity.toLowerCase(); + item.file = sanitizeContent(item.file); + item.severity = sanitizeContent(item.severity); + item.message = sanitizeContent(item.message); + if (item.ruleIdSuffix) { + item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); + } + break; + default: + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); + continue; + } + Object.assign(item, validation.normalizedItem); + } + break; + } + core.info(`Line ${i + 1}: Valid ${itemType} item`); + parsedItems.push(item); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); } - core.info(`Successfully parsed ${parsedItems.length} valid output items`); - const validatedOutput = { - items: parsedItems, - errors: errors, - }; - const agentOutputFile = "/tmp/agent_output.json"; - const validatedOutputJson = JSON.stringify(validatedOutput); - try { - fs.mkdirSync("/tmp", { recursive: true }); - fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); - core.info(`Stored validated output to: ${agentOutputFile}`); - core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); - } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.error(`Failed to write agent output file: ${errorMsg}`); - } - core.setOutput("output", JSON.stringify(validatedOutput)); - core.setOutput("raw_output", outputContent); - const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); - core.info(`output_types: ${outputTypes.join(", ")}`); - core.setOutput("output_types", outputTypes.join(",")); - try { - await core.summary - .addRaw("## Processed Output\n\n") - .addRaw("```json\n") - .addRaw(JSON.stringify(validatedOutput)) - .addRaw("\n```\n") - .write(); - core.info("Successfully wrote processed output to step summary"); - } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.warning(`Failed to write to step summary: ${errorMsg}`); + } + if (errors.length > 0) { + core.warning("Validation errors found:"); + errors.forEach(error => core.warning(` - ${error}`)); + if (parsedItems.length === 0) { + core.setFailed(errors.map(e => ` - ${e}`).join("\n")); + return; } + } + for (const itemType of Object.keys(expectedOutputTypes)) { + const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); + if (minRequired > 0) { + const actualCount = parsedItems.filter(item => item.type === itemType).length; + if (actualCount < minRequired) { + errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); + } + } + } + core.info(`Successfully parsed ${parsedItems.length} valid output items`); + const validatedOutput = { + items: parsedItems, + errors: errors, + }; + const agentOutputFile = "/tmp/agent_output.json"; + const validatedOutputJson = JSON.stringify(validatedOutput); + try { + fs.mkdirSync("/tmp", { recursive: true }); + fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); + core.info(`Stored validated output to: ${agentOutputFile}`); + core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.error(`Failed to write agent output file: ${errorMsg}`); + } + core.setOutput("output", JSON.stringify(validatedOutput)); + core.setOutput("raw_output", outputContent); + const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); + core.info(`output_types: ${outputTypes.join(", ")}`); + core.setOutput("output_types", outputTypes.join(",")); + try { + await core.summary + .addRaw("## Processed Output\n\n") + .addRaw("```json\n") + .addRaw(JSON.stringify(validatedOutput)) + .addRaw("\n```\n") + .write(); + core.info("Successfully wrote processed output to step summary"); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.warning(`Failed to write to step summary: ${errorMsg}`); + } } await main(); - name: Upload sanitized agent output diff --git a/.github/workflows/changeset-generator.lock.yml b/.github/workflows/changeset-generator.lock.yml index c6b2d7b31ee..3432352de42 100644 --- a/.github/workflows/changeset-generator.lock.yml +++ b/.github/workflows/changeset-generator.lock.yml @@ -1458,742 +1458,742 @@ jobs: with: script: | async function main() { - const fs = require("fs"); - function sanitizeContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; - const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; - const allowedDomains = allowedDomainsEnv - ? allowedDomainsEnv - .split(",") - .map(d => d.trim()) - .filter(d => d) - : defaultAllowedDomains; - let sanitized = content; - sanitized = neutralizeMentions(sanitized); - sanitized = removeXmlComments(sanitized); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitizeUrlProtocols(sanitized); - sanitized = sanitizeUrlDomains(sanitized); - const maxLength = 524288; - if (sanitized.length > maxLength) { - sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; - } - const lines = sanitized.split("\n"); - const maxLines = 65000; - if (lines.length > maxLines) { - sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; - } - sanitized = neutralizeBotTriggers(sanitized); - return sanitized.trim(); - function sanitizeUrlDomains(s) { - return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { - const urlAfterProtocol = match.slice(8); - const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); - const isAllowed = allowedDomains.some(allowedDomain => { - const normalizedAllowed = allowedDomain.toLowerCase(); - return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); - }); - return isAllowed ? match : "(redacted)"; - }); - } - function sanitizeUrlProtocols(s) { - return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { - return protocol.toLowerCase() === "https" ? match : "(redacted)"; - }); - } - function neutralizeMentions(s) { - return s.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - } - function removeXmlComments(s) { - return s.replace(//g, "").replace(//g, ""); - } - function neutralizeBotTriggers(s) { - return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); - } + const fs = require("fs"); + function sanitizeContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; + const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; + const allowedDomains = allowedDomainsEnv + ? allowedDomainsEnv + .split(",") + .map(d => d.trim()) + .filter(d => d) + : defaultAllowedDomains; + let sanitized = content; + sanitized = neutralizeMentions(sanitized); + sanitized = removeXmlComments(sanitized); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitizeUrlProtocols(sanitized); + sanitized = sanitizeUrlDomains(sanitized); + const maxLength = 524288; + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; + } + const lines = sanitized.split("\n"); + const maxLines = 65000; + if (lines.length > maxLines) { + sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; + } + sanitized = neutralizeBotTriggers(sanitized); + return sanitized.trim(); + function sanitizeUrlDomains(s) { + return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { + const urlAfterProtocol = match.slice(8); + const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); + const isAllowed = allowedDomains.some(allowedDomain => { + const normalizedAllowed = allowedDomain.toLowerCase(); + return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); + }); + return isAllowed ? match : "(redacted)"; + }); } - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create-issue": - return 1; - case "add-comment": - return 1; - case "create-pull-request": - return 1; - case "create-pull-request-review-comment": - return 1; - case "add-labels": - return 5; - case "update-issue": - return 1; - case "push-to-pull-request-branch": - return 1; - case "create-discussion": - return 1; - case "missing-tool": - return 1000; - case "create-code-scanning-alert": - return 1000; - case "upload-asset": - return 10; - default: - return 1; - } + function sanitizeUrlProtocols(s) { + return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { + return protocol.toLowerCase() === "https" ? match : "(redacted)"; + }); } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; - } - return 0; - } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } - else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } - else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; - } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; - } - return { isValid: true, normalizedValue: parsed }; + function neutralizeMentions(s) { + return s.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; - } - return { isValid: true, normalizedValue: parsed }; + function removeXmlComments(s) { + return s.replace(//g, "").replace(//g, ""); + } + function neutralizeBotTriggers(s) { + return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); + } + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + switch (itemType) { + case "create-issue": + return 1; + case "add-comment": + return 1; + case "create-pull-request": + return 1; + case "create-pull-request-review-comment": + return 1; + case "add-labels": + return 5; + case "update-issue": + return 1; + case "push-to-pull-request-branch": + return 1; + case "create-discussion": + return 1; + case "missing-tool": + return 1000; + case "create-code-scanning-alert": + return 1000; + case "upload-asset": + return 10; + default: + return 1; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - return { isValid: true }; - } - function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { - if (inputSchema.required && (value === undefined || value === null)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; - } - if (value === undefined || value === null) { - return { - isValid: true, - normalizedValue: inputSchema.default || undefined, - }; - } - const inputType = inputSchema.type || "string"; - let normalizedValue = value; - switch (inputType) { - case "string": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string`, - }; - } - normalizedValue = sanitizeContent(value); - break; - case "boolean": - if (typeof value !== "boolean") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a boolean`, - }; - } - break; - case "number": - if (typeof value !== "number") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number`, - }; - } - break; - case "choice": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, - }; - } - if (inputSchema.options && !inputSchema.options.includes(value)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, - }; - } - normalizedValue = sanitizeContent(value); - break; - default: - if (typeof value === "string") { - normalizedValue = sanitizeContent(value); - } - break; - } + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; + } + return 0; + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { return { - isValid: true, - normalizedValue, + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; } - function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { - const errors = []; - const normalizedItem = { ...item }; - if (!jobConfig.inputs) { - return { - isValid: true, - errors: [], - normalizedItem: item, - }; - } - for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { - const fieldValue = item[fieldName]; - const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); - if (!validation.isValid && validation.error) { - errors.push(validation.error); - } - else if (validation.normalizedValue !== undefined) { - normalizedItem[fieldName] = validation.normalizedValue; - } - } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { return { - isValid: errors.length === 0, - errors, - normalizedItem, + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; } - function parseJsonWithRepair(jsonStr) { - try { - return JSON.parse(jsonStr); - } - catch (originalError) { - try { - const repairedJson = repairJson(jsonStr); - return JSON.parse(repairedJson); - } - catch (repairError) { - core.info(`invalid input json: ${jsonStr}`); - const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); - const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); - throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); - } - } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; } - const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; - const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; - if (!outputFile) { - core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); - core.setOutput("output", ""); - return; + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; } - if (!fs.existsSync(outputFile)) { - core.info(`Output file does not exist: ${outputFile}`); - core.setOutput("output", ""); - return; + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; } - const outputContent = fs.readFileSync(outputFile, "utf8"); - if (outputContent.trim() === "") { - core.info("Output file is empty"); + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; } - core.info(`Raw output content length: ${outputContent.length}`); - let expectedOutputTypes = {}; - if (safeOutputsConfig) { - try { - expectedOutputTypes = JSON.parse(safeOutputsConfig); - core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { + if (inputSchema.required && (value === undefined || value === null)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (value === undefined || value === null) { + return { + isValid: true, + normalizedValue: inputSchema.default || undefined, + }; + } + const inputType = inputSchema.type || "string"; + let normalizedValue = value; + switch (inputType) { + case "string": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string`, + }; + } + normalizedValue = sanitizeContent(value); + break; + case "boolean": + if (typeof value !== "boolean") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a boolean`, + }; + } + break; + case "number": + if (typeof value !== "number") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number`, + }; + } + break; + case "choice": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, + }; } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); + if (inputSchema.options && !inputSchema.options.includes(value)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, + }; } + normalizedValue = sanitizeContent(value); + break; + default: + if (typeof value === "string") { + normalizedValue = sanitizeContent(value); + } + break; } - const lines = outputContent.trim().split("\n"); - const parsedItems = []; + return { + isValid: true, + normalizedValue, + }; + } + function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { const errors = []; - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); - if (line === "") + const normalizedItem = { ...item }; + if (!jobConfig.inputs) { + return { + isValid: true, + errors: [], + normalizedItem: item, + }; + } + for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { + const fieldValue = item[fieldName]; + const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); + if (!validation.isValid && validation.error) { + errors.push(validation.error); + } else if (validation.normalizedValue !== undefined) { + normalizedItem[fieldName] = validation.normalizedValue; + } + } + return { + isValid: errors.length === 0, + errors, + normalizedItem, + }; + } + function parseJsonWithRepair(jsonStr) { + try { + return JSON.parse(jsonStr); + } catch (originalError) { + try { + const repairedJson = repairJson(jsonStr); + return JSON.parse(repairedJson); + } catch (repairError) { + core.info(`invalid input json: ${jsonStr}`); + const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); + const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); + throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); + } + } + } + const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; + const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; + if (!outputFile) { + core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); + core.setOutput("output", ""); + return; + } + if (!fs.existsSync(outputFile)) { + core.info(`Output file does not exist: ${outputFile}`); + core.setOutput("output", ""); + return; + } + const outputContent = fs.readFileSync(outputFile, "utf8"); + if (outputContent.trim() === "") { + core.info("Output file is empty"); + } + core.info(`Raw output content length: ${outputContent.length}`); + let expectedOutputTypes = {}; + if (safeOutputsConfig) { + try { + expectedOutputTypes = JSON.parse(safeOutputsConfig); + core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); + } + } + const lines = outputContent.trim().split("\n"); + const parsedItems = []; + const errors = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line === "") continue; + try { + const item = parseJsonWithRepair(line); + if (item === undefined) { + errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); + continue; + } + if (!item.type) { + errors.push(`Line ${i + 1}: Missing required 'type' field`); + continue; + } + const itemType = item.type; + if (!expectedOutputTypes[itemType]) { + errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); + continue; + } + const typeCount = parsedItems.filter(existing => existing.type === itemType).length; + const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); + if (typeCount >= maxAllowed) { + errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); + continue; + } + core.info(`Line ${i + 1}: type '${itemType}'`); + switch (itemType) { + case "create-issue": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); continue; - try { - const item = parseJsonWithRepair(line); - if (item === undefined) { - errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); - continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-comment": + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); + continue; + } + const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); + if (!issueNumValidation.isValid) { + if (issueNumValidation.error) errors.push(issueNumValidation.error); + continue; + } + item.body = sanitizeContent(item.body); + break; + case "create-pull-request": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); + continue; + } + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + item.branch = sanitizeContent(item.branch); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-labels": + if (!item.labels || !Array.isArray(item.labels)) { + errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); + continue; + } + if (item.labels.some(label => typeof label !== "string")) { + errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); + continue; + } + const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); + if (!labelsIssueNumValidation.isValid) { + if (labelsIssueNumValidation.error) errors.push(labelsIssueNumValidation.error); + continue; + } + item.labels = item.labels.map(label => sanitizeContent(label)); + break; + case "update-issue": + const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; + if (!hasValidField) { + errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); + continue; + } + if (item.status !== undefined) { + if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { + errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); + continue; } - if (!item.type) { - errors.push(`Line ${i + 1}: Missing required 'type' field`); - continue; + } + if (item.title !== undefined) { + if (typeof item.title !== "string") { + errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); + continue; } - const itemType = item.type; - if (!expectedOutputTypes[itemType]) { - errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); - continue; + item.title = sanitizeContent(item.title); + } + if (item.body !== undefined) { + if (typeof item.body !== "string") { + errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); + continue; } - const typeCount = parsedItems.filter(existing => existing.type === itemType).length; - const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); - if (typeCount >= maxAllowed) { - errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); - continue; + item.body = sanitizeContent(item.body); + } + const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); + if (!updateIssueNumValidation.isValid) { + if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); + continue; + } + break; + case "push-to-pull-request-branch": + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); + continue; + } + item.branch = sanitizeContent(item.branch); + item.message = sanitizeContent(item.message); + const pushPRNumValidation = validateIssueOrPRNumber( + item.pull_request_number, + "push-to-pull-request-branch 'pull_request_number'", + i + 1 + ); + if (!pushPRNumValidation.isValid) { + if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); + continue; + } + break; + case "create-pull-request-review-comment": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); + continue; + } + const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); + if (!lineValidation.isValid) { + if (lineValidation.error) errors.push(lineValidation.error); + continue; + } + const lineNumber = lineValidation.normalizedValue; + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); + continue; + } + item.body = sanitizeContent(item.body); + const startLineValidation = validateOptionalPositiveInteger( + item.start_line, + "create-pull-request-review-comment 'start_line'", + i + 1 + ); + if (!startLineValidation.isValid) { + if (startLineValidation.error) errors.push(startLineValidation.error); + continue; + } + if ( + startLineValidation.normalizedValue !== undefined && + lineNumber !== undefined && + startLineValidation.normalizedValue > lineNumber + ) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); + continue; + } + if (item.side !== undefined) { + if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); + continue; } - core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create-issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); - if (!issueNumValidation.isValid) { - if (issueNumValidation.error) - errors.push(issueNumValidation.error); - continue; - } - item.body = sanitizeContent(item.body); - break; - case "create-pull-request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - item.branch = sanitizeContent(item.branch); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some((label) => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); - if (!labelsIssueNumValidation.isValid) { - if (labelsIssueNumValidation.error) - errors.push(labelsIssueNumValidation.error); - continue; - } - item.labels = item.labels.map((label) => sanitizeContent(label)); - break; - case "update-issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) - errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "push-to-pull-request-branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch); - item.message = sanitizeContent(item.message); - const pushPRNumValidation = validateIssueOrPRNumber(item.pull_request_number, "push-to-pull-request-branch 'pull_request_number'", i + 1); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) - errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create-pull-request-review-comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) - errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body); - const startLineValidation = validateOptionalPositiveInteger(item.start_line, "create-pull-request-review-comment 'start_line'", i + 1); - if (!startLineValidation.isValid) { - if (startLineValidation.error) - errors.push(startLineValidation.error); - continue; - } - if (startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create-discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category); - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - break; - case "missing-tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool); - item.reason = sanitizeContent(item.reason); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives); - } - break; - case "upload-asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "create-code-scanning-alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}`); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) - errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores`); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file); - item.severity = sanitizeContent(item.severity); - item.message = sanitizeContent(item.message); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); - continue; - } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + } + break; + case "create-discussion": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); + continue; + } + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); + continue; } - core.info(`Line ${i + 1}: Valid ${itemType} item`); - parsedItems.push(item); - } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); - } - } - if (errors.length > 0) { - core.warning("Validation errors found:"); - errors.forEach(error => core.warning(` - ${error}`)); - if (parsedItems.length === 0) { - core.setFailed(errors.map(e => ` - ${e}`).join("\n")); - return; - } - } - for (const itemType of Object.keys(expectedOutputTypes)) { - const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); - if (minRequired > 0) { - const actualCount = parsedItems.filter(item => item.type === itemType).length; - if (actualCount < minRequired) { - errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); + item.category = sanitizeContent(item.category); + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + break; + case "missing-tool": + if (!item.tool || typeof item.tool !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); + continue; + } + if (!item.reason || typeof item.reason !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); + continue; + } + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); + continue; } - } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "upload-asset": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); + continue; + } + break; + case "create-code-scanning-alert": + if (!item.file || typeof item.file !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); + continue; + } + const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); + if (!alertLineValidation.isValid) { + if (alertLineValidation.error) { + errors.push(alertLineValidation.error); + } + continue; + } + if (!item.severity || typeof item.severity !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); + continue; + } + const allowedSeverities = ["error", "warning", "info", "note"]; + if (!allowedSeverities.includes(item.severity.toLowerCase())) { + errors.push( + `Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` + ); + continue; + } + const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); + if (!columnValidation.isValid) { + if (columnValidation.error) errors.push(columnValidation.error); + continue; + } + if (item.ruleIdSuffix !== undefined) { + if (typeof item.ruleIdSuffix !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); + continue; + } + if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { + errors.push( + `Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` + ); + continue; + } + } + item.severity = item.severity.toLowerCase(); + item.file = sanitizeContent(item.file); + item.severity = sanitizeContent(item.severity); + item.message = sanitizeContent(item.message); + if (item.ruleIdSuffix) { + item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); + } + break; + default: + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); + continue; + } + Object.assign(item, validation.normalizedItem); + } + break; + } + core.info(`Line ${i + 1}: Valid ${itemType} item`); + parsedItems.push(item); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); } - core.info(`Successfully parsed ${parsedItems.length} valid output items`); - const validatedOutput = { - items: parsedItems, - errors: errors, - }; - const agentOutputFile = "/tmp/agent_output.json"; - const validatedOutputJson = JSON.stringify(validatedOutput); - try { - fs.mkdirSync("/tmp", { recursive: true }); - fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); - core.info(`Stored validated output to: ${agentOutputFile}`); - core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); - } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.error(`Failed to write agent output file: ${errorMsg}`); - } - core.setOutput("output", JSON.stringify(validatedOutput)); - core.setOutput("raw_output", outputContent); - const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); - core.info(`output_types: ${outputTypes.join(", ")}`); - core.setOutput("output_types", outputTypes.join(",")); - try { - await core.summary - .addRaw("## Processed Output\n\n") - .addRaw("```json\n") - .addRaw(JSON.stringify(validatedOutput)) - .addRaw("\n```\n") - .write(); - core.info("Successfully wrote processed output to step summary"); - } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.warning(`Failed to write to step summary: ${errorMsg}`); + } + if (errors.length > 0) { + core.warning("Validation errors found:"); + errors.forEach(error => core.warning(` - ${error}`)); + if (parsedItems.length === 0) { + core.setFailed(errors.map(e => ` - ${e}`).join("\n")); + return; + } + } + for (const itemType of Object.keys(expectedOutputTypes)) { + const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); + if (minRequired > 0) { + const actualCount = parsedItems.filter(item => item.type === itemType).length; + if (actualCount < minRequired) { + errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); + } } + } + core.info(`Successfully parsed ${parsedItems.length} valid output items`); + const validatedOutput = { + items: parsedItems, + errors: errors, + }; + const agentOutputFile = "/tmp/agent_output.json"; + const validatedOutputJson = JSON.stringify(validatedOutput); + try { + fs.mkdirSync("/tmp", { recursive: true }); + fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); + core.info(`Stored validated output to: ${agentOutputFile}`); + core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.error(`Failed to write agent output file: ${errorMsg}`); + } + core.setOutput("output", JSON.stringify(validatedOutput)); + core.setOutput("raw_output", outputContent); + const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); + core.info(`output_types: ${outputTypes.join(", ")}`); + core.setOutput("output_types", outputTypes.join(",")); + try { + await core.summary + .addRaw("## Processed Output\n\n") + .addRaw("```json\n") + .addRaw(JSON.stringify(validatedOutput)) + .addRaw("\n```\n") + .write(); + core.info("Successfully wrote processed output to step summary"); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.warning(`Failed to write to step summary: ${errorMsg}`); + } } await main(); - name: Upload sanitized agent output diff --git a/.github/workflows/ci-doctor.lock.yml b/.github/workflows/ci-doctor.lock.yml index c0582a30ece..6fd642433a4 100644 --- a/.github/workflows/ci-doctor.lock.yml +++ b/.github/workflows/ci-doctor.lock.yml @@ -1210,742 +1210,742 @@ jobs: with: script: | async function main() { - const fs = require("fs"); - function sanitizeContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; - const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; - const allowedDomains = allowedDomainsEnv - ? allowedDomainsEnv - .split(",") - .map(d => d.trim()) - .filter(d => d) - : defaultAllowedDomains; - let sanitized = content; - sanitized = neutralizeMentions(sanitized); - sanitized = removeXmlComments(sanitized); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitizeUrlProtocols(sanitized); - sanitized = sanitizeUrlDomains(sanitized); - const maxLength = 524288; - if (sanitized.length > maxLength) { - sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; - } - const lines = sanitized.split("\n"); - const maxLines = 65000; - if (lines.length > maxLines) { - sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; - } - sanitized = neutralizeBotTriggers(sanitized); - return sanitized.trim(); - function sanitizeUrlDomains(s) { - return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { - const urlAfterProtocol = match.slice(8); - const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); - const isAllowed = allowedDomains.some(allowedDomain => { - const normalizedAllowed = allowedDomain.toLowerCase(); - return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); - }); - return isAllowed ? match : "(redacted)"; - }); - } - function sanitizeUrlProtocols(s) { - return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { - return protocol.toLowerCase() === "https" ? match : "(redacted)"; - }); - } - function neutralizeMentions(s) { - return s.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - } - function removeXmlComments(s) { - return s.replace(//g, "").replace(//g, ""); - } - function neutralizeBotTriggers(s) { - return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); - } + const fs = require("fs"); + function sanitizeContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; + const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; + const allowedDomains = allowedDomainsEnv + ? allowedDomainsEnv + .split(",") + .map(d => d.trim()) + .filter(d => d) + : defaultAllowedDomains; + let sanitized = content; + sanitized = neutralizeMentions(sanitized); + sanitized = removeXmlComments(sanitized); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitizeUrlProtocols(sanitized); + sanitized = sanitizeUrlDomains(sanitized); + const maxLength = 524288; + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; + } + const lines = sanitized.split("\n"); + const maxLines = 65000; + if (lines.length > maxLines) { + sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; + } + sanitized = neutralizeBotTriggers(sanitized); + return sanitized.trim(); + function sanitizeUrlDomains(s) { + return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { + const urlAfterProtocol = match.slice(8); + const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); + const isAllowed = allowedDomains.some(allowedDomain => { + const normalizedAllowed = allowedDomain.toLowerCase(); + return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); + }); + return isAllowed ? match : "(redacted)"; + }); } - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create-issue": - return 1; - case "add-comment": - return 1; - case "create-pull-request": - return 1; - case "create-pull-request-review-comment": - return 1; - case "add-labels": - return 5; - case "update-issue": - return 1; - case "push-to-pull-request-branch": - return 1; - case "create-discussion": - return 1; - case "missing-tool": - return 1000; - case "create-code-scanning-alert": - return 1000; - case "upload-asset": - return 10; - default: - return 1; - } + function sanitizeUrlProtocols(s) { + return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { + return protocol.toLowerCase() === "https" ? match : "(redacted)"; + }); } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; - } - return 0; - } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } - else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } - else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; - } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; - } - return { isValid: true, normalizedValue: parsed }; + function neutralizeMentions(s) { + return s.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); + } + function removeXmlComments(s) { + return s.replace(//g, "").replace(//g, ""); + } + function neutralizeBotTriggers(s) { + return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); + } + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + switch (itemType) { + case "create-issue": + return 1; + case "add-comment": + return 1; + case "create-pull-request": + return 1; + case "create-pull-request-review-comment": + return 1; + case "add-labels": + return 5; + case "update-issue": + return 1; + case "push-to-pull-request-branch": + return 1; + case "create-discussion": + return 1; + case "missing-tool": + return 1000; + case "create-code-scanning-alert": + return 1000; + case "upload-asset": + return 10; + default: + return 1; + } + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; + } + return 0; + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; - } - return { isValid: true, normalizedValue: parsed }; + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - return { isValid: true }; - } - function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { - if (inputSchema.required && (value === undefined || value === null)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; - } - if (value === undefined || value === null) { - return { - isValid: true, - normalizedValue: inputSchema.default || undefined, - }; - } - const inputType = inputSchema.type || "string"; - let normalizedValue = value; - switch (inputType) { - case "string": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string`, - }; - } - normalizedValue = sanitizeContent(value); - break; - case "boolean": - if (typeof value !== "boolean") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a boolean`, - }; - } - break; - case "number": - if (typeof value !== "number") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number`, - }; - } - break; - case "choice": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, - }; - } - if (inputSchema.options && !inputSchema.options.includes(value)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, - }; - } - normalizedValue = sanitizeContent(value); - break; - default: - if (typeof value === "string") { - normalizedValue = sanitizeContent(value); - } - break; - } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { return { - isValid: true, - normalizedValue, + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; } - function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { - const errors = []; - const normalizedItem = { ...item }; - if (!jobConfig.inputs) { - return { - isValid: true, - errors: [], - normalizedItem: item, - }; - } - for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { - const fieldValue = item[fieldName]; - const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); - if (!validation.isValid && validation.error) { - errors.push(validation.error); - } - else if (validation.normalizedValue !== undefined) { - normalizedItem[fieldName] = validation.normalizedValue; - } - } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { return { - isValid: errors.length === 0, - errors, - normalizedItem, + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; } - function parseJsonWithRepair(jsonStr) { - try { - return JSON.parse(jsonStr); - } - catch (originalError) { - try { - const repairedJson = repairJson(jsonStr); - return JSON.parse(repairedJson); - } - catch (repairError) { - core.info(`invalid input json: ${jsonStr}`); - const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); - const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); - throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); - } - } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; } - const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; - const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; - if (!outputFile) { - core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); - core.setOutput("output", ""); - return; + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; } - if (!fs.existsSync(outputFile)) { - core.info(`Output file does not exist: ${outputFile}`); - core.setOutput("output", ""); - return; + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { + if (inputSchema.required && (value === undefined || value === null)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; } - const outputContent = fs.readFileSync(outputFile, "utf8"); - if (outputContent.trim() === "") { - core.info("Output file is empty"); + if (value === undefined || value === null) { + return { + isValid: true, + normalizedValue: inputSchema.default || undefined, + }; } - core.info(`Raw output content length: ${outputContent.length}`); - let expectedOutputTypes = {}; - if (safeOutputsConfig) { - try { - expectedOutputTypes = JSON.parse(safeOutputsConfig); - core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); + const inputType = inputSchema.type || "string"; + let normalizedValue = value; + switch (inputType) { + case "string": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string`, + }; + } + normalizedValue = sanitizeContent(value); + break; + case "boolean": + if (typeof value !== "boolean") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a boolean`, + }; + } + break; + case "number": + if (typeof value !== "number") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number`, + }; } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); + break; + case "choice": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, + }; + } + if (inputSchema.options && !inputSchema.options.includes(value)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, + }; + } + normalizedValue = sanitizeContent(value); + break; + default: + if (typeof value === "string") { + normalizedValue = sanitizeContent(value); } + break; } - const lines = outputContent.trim().split("\n"); - const parsedItems = []; + return { + isValid: true, + normalizedValue, + }; + } + function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { const errors = []; - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); - if (line === "") + const normalizedItem = { ...item }; + if (!jobConfig.inputs) { + return { + isValid: true, + errors: [], + normalizedItem: item, + }; + } + for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { + const fieldValue = item[fieldName]; + const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); + if (!validation.isValid && validation.error) { + errors.push(validation.error); + } else if (validation.normalizedValue !== undefined) { + normalizedItem[fieldName] = validation.normalizedValue; + } + } + return { + isValid: errors.length === 0, + errors, + normalizedItem, + }; + } + function parseJsonWithRepair(jsonStr) { + try { + return JSON.parse(jsonStr); + } catch (originalError) { + try { + const repairedJson = repairJson(jsonStr); + return JSON.parse(repairedJson); + } catch (repairError) { + core.info(`invalid input json: ${jsonStr}`); + const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); + const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); + throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); + } + } + } + const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; + const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; + if (!outputFile) { + core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); + core.setOutput("output", ""); + return; + } + if (!fs.existsSync(outputFile)) { + core.info(`Output file does not exist: ${outputFile}`); + core.setOutput("output", ""); + return; + } + const outputContent = fs.readFileSync(outputFile, "utf8"); + if (outputContent.trim() === "") { + core.info("Output file is empty"); + } + core.info(`Raw output content length: ${outputContent.length}`); + let expectedOutputTypes = {}; + if (safeOutputsConfig) { + try { + expectedOutputTypes = JSON.parse(safeOutputsConfig); + core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); + } + } + const lines = outputContent.trim().split("\n"); + const parsedItems = []; + const errors = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line === "") continue; + try { + const item = parseJsonWithRepair(line); + if (item === undefined) { + errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); + continue; + } + if (!item.type) { + errors.push(`Line ${i + 1}: Missing required 'type' field`); + continue; + } + const itemType = item.type; + if (!expectedOutputTypes[itemType]) { + errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); + continue; + } + const typeCount = parsedItems.filter(existing => existing.type === itemType).length; + const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); + if (typeCount >= maxAllowed) { + errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); + continue; + } + core.info(`Line ${i + 1}: type '${itemType}'`); + switch (itemType) { + case "create-issue": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); continue; - try { - const item = parseJsonWithRepair(line); - if (item === undefined) { - errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); - continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-comment": + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); + continue; + } + const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); + if (!issueNumValidation.isValid) { + if (issueNumValidation.error) errors.push(issueNumValidation.error); + continue; + } + item.body = sanitizeContent(item.body); + break; + case "create-pull-request": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); + continue; + } + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + item.branch = sanitizeContent(item.branch); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-labels": + if (!item.labels || !Array.isArray(item.labels)) { + errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); + continue; + } + if (item.labels.some(label => typeof label !== "string")) { + errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); + continue; + } + const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); + if (!labelsIssueNumValidation.isValid) { + if (labelsIssueNumValidation.error) errors.push(labelsIssueNumValidation.error); + continue; + } + item.labels = item.labels.map(label => sanitizeContent(label)); + break; + case "update-issue": + const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; + if (!hasValidField) { + errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); + continue; + } + if (item.status !== undefined) { + if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { + errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); + continue; } - if (!item.type) { - errors.push(`Line ${i + 1}: Missing required 'type' field`); - continue; + } + if (item.title !== undefined) { + if (typeof item.title !== "string") { + errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); + continue; } - const itemType = item.type; - if (!expectedOutputTypes[itemType]) { - errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); - continue; + item.title = sanitizeContent(item.title); + } + if (item.body !== undefined) { + if (typeof item.body !== "string") { + errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); + continue; } - const typeCount = parsedItems.filter(existing => existing.type === itemType).length; - const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); - if (typeCount >= maxAllowed) { - errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); - continue; + item.body = sanitizeContent(item.body); + } + const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); + if (!updateIssueNumValidation.isValid) { + if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); + continue; + } + break; + case "push-to-pull-request-branch": + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); + continue; + } + item.branch = sanitizeContent(item.branch); + item.message = sanitizeContent(item.message); + const pushPRNumValidation = validateIssueOrPRNumber( + item.pull_request_number, + "push-to-pull-request-branch 'pull_request_number'", + i + 1 + ); + if (!pushPRNumValidation.isValid) { + if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); + continue; + } + break; + case "create-pull-request-review-comment": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); + continue; + } + const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); + if (!lineValidation.isValid) { + if (lineValidation.error) errors.push(lineValidation.error); + continue; + } + const lineNumber = lineValidation.normalizedValue; + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); + continue; + } + item.body = sanitizeContent(item.body); + const startLineValidation = validateOptionalPositiveInteger( + item.start_line, + "create-pull-request-review-comment 'start_line'", + i + 1 + ); + if (!startLineValidation.isValid) { + if (startLineValidation.error) errors.push(startLineValidation.error); + continue; + } + if ( + startLineValidation.normalizedValue !== undefined && + lineNumber !== undefined && + startLineValidation.normalizedValue > lineNumber + ) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); + continue; + } + if (item.side !== undefined) { + if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); + continue; } - core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create-issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); - if (!issueNumValidation.isValid) { - if (issueNumValidation.error) - errors.push(issueNumValidation.error); - continue; - } - item.body = sanitizeContent(item.body); - break; - case "create-pull-request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - item.branch = sanitizeContent(item.branch); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some((label) => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); - if (!labelsIssueNumValidation.isValid) { - if (labelsIssueNumValidation.error) - errors.push(labelsIssueNumValidation.error); - continue; - } - item.labels = item.labels.map((label) => sanitizeContent(label)); - break; - case "update-issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) - errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "push-to-pull-request-branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch); - item.message = sanitizeContent(item.message); - const pushPRNumValidation = validateIssueOrPRNumber(item.pull_request_number, "push-to-pull-request-branch 'pull_request_number'", i + 1); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) - errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create-pull-request-review-comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) - errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body); - const startLineValidation = validateOptionalPositiveInteger(item.start_line, "create-pull-request-review-comment 'start_line'", i + 1); - if (!startLineValidation.isValid) { - if (startLineValidation.error) - errors.push(startLineValidation.error); - continue; - } - if (startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create-discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category); - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - break; - case "missing-tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool); - item.reason = sanitizeContent(item.reason); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives); - } - break; - case "upload-asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "create-code-scanning-alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}`); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) - errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores`); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file); - item.severity = sanitizeContent(item.severity); - item.message = sanitizeContent(item.message); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); - continue; - } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + } + break; + case "create-discussion": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); + continue; + } + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); + continue; } - core.info(`Line ${i + 1}: Valid ${itemType} item`); - parsedItems.push(item); - } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); - } - } - if (errors.length > 0) { - core.warning("Validation errors found:"); - errors.forEach(error => core.warning(` - ${error}`)); - if (parsedItems.length === 0) { - core.setFailed(errors.map(e => ` - ${e}`).join("\n")); - return; - } - } - for (const itemType of Object.keys(expectedOutputTypes)) { - const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); - if (minRequired > 0) { - const actualCount = parsedItems.filter(item => item.type === itemType).length; - if (actualCount < minRequired) { - errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); + item.category = sanitizeContent(item.category); + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + break; + case "missing-tool": + if (!item.tool || typeof item.tool !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); + continue; + } + if (!item.reason || typeof item.reason !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); + continue; + } + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); + continue; } - } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "upload-asset": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); + continue; + } + break; + case "create-code-scanning-alert": + if (!item.file || typeof item.file !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); + continue; + } + const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); + if (!alertLineValidation.isValid) { + if (alertLineValidation.error) { + errors.push(alertLineValidation.error); + } + continue; + } + if (!item.severity || typeof item.severity !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); + continue; + } + const allowedSeverities = ["error", "warning", "info", "note"]; + if (!allowedSeverities.includes(item.severity.toLowerCase())) { + errors.push( + `Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` + ); + continue; + } + const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); + if (!columnValidation.isValid) { + if (columnValidation.error) errors.push(columnValidation.error); + continue; + } + if (item.ruleIdSuffix !== undefined) { + if (typeof item.ruleIdSuffix !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); + continue; + } + if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { + errors.push( + `Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` + ); + continue; + } + } + item.severity = item.severity.toLowerCase(); + item.file = sanitizeContent(item.file); + item.severity = sanitizeContent(item.severity); + item.message = sanitizeContent(item.message); + if (item.ruleIdSuffix) { + item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); + } + break; + default: + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); + continue; + } + Object.assign(item, validation.normalizedItem); + } + break; + } + core.info(`Line ${i + 1}: Valid ${itemType} item`); + parsedItems.push(item); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); } - core.info(`Successfully parsed ${parsedItems.length} valid output items`); - const validatedOutput = { - items: parsedItems, - errors: errors, - }; - const agentOutputFile = "/tmp/agent_output.json"; - const validatedOutputJson = JSON.stringify(validatedOutput); - try { - fs.mkdirSync("/tmp", { recursive: true }); - fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); - core.info(`Stored validated output to: ${agentOutputFile}`); - core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); - } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.error(`Failed to write agent output file: ${errorMsg}`); - } - core.setOutput("output", JSON.stringify(validatedOutput)); - core.setOutput("raw_output", outputContent); - const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); - core.info(`output_types: ${outputTypes.join(", ")}`); - core.setOutput("output_types", outputTypes.join(",")); - try { - await core.summary - .addRaw("## Processed Output\n\n") - .addRaw("```json\n") - .addRaw(JSON.stringify(validatedOutput)) - .addRaw("\n```\n") - .write(); - core.info("Successfully wrote processed output to step summary"); - } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.warning(`Failed to write to step summary: ${errorMsg}`); + } + if (errors.length > 0) { + core.warning("Validation errors found:"); + errors.forEach(error => core.warning(` - ${error}`)); + if (parsedItems.length === 0) { + core.setFailed(errors.map(e => ` - ${e}`).join("\n")); + return; + } + } + for (const itemType of Object.keys(expectedOutputTypes)) { + const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); + if (minRequired > 0) { + const actualCount = parsedItems.filter(item => item.type === itemType).length; + if (actualCount < minRequired) { + errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); + } } + } + core.info(`Successfully parsed ${parsedItems.length} valid output items`); + const validatedOutput = { + items: parsedItems, + errors: errors, + }; + const agentOutputFile = "/tmp/agent_output.json"; + const validatedOutputJson = JSON.stringify(validatedOutput); + try { + fs.mkdirSync("/tmp", { recursive: true }); + fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); + core.info(`Stored validated output to: ${agentOutputFile}`); + core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.error(`Failed to write agent output file: ${errorMsg}`); + } + core.setOutput("output", JSON.stringify(validatedOutput)); + core.setOutput("raw_output", outputContent); + const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); + core.info(`output_types: ${outputTypes.join(", ")}`); + core.setOutput("output_types", outputTypes.join(",")); + try { + await core.summary + .addRaw("## Processed Output\n\n") + .addRaw("```json\n") + .addRaw(JSON.stringify(validatedOutput)) + .addRaw("\n```\n") + .write(); + core.info("Successfully wrote processed output to step summary"); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.warning(`Failed to write to step summary: ${errorMsg}`); + } } await main(); - name: Upload sanitized agent output @@ -2843,163 +2843,165 @@ jobs: with: script: | function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); + if (!content || typeof content !== "string") { + return ""; + } + let sanitized = content.trim(); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); + sanitized = sanitized.replace(/[<>&'"]/g, ""); + return sanitized.trim(); } async function main() { - const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; - const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; - if (!outputContent) { - core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); - return; + const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; + const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; + if (!outputContent) { + core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); + return; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); + return; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + return; + } + const createIssueItems = validatedOutput.items.filter(item => item.type === "create-issue"); + if (createIssueItems.length === 0) { + core.info("No create-issue items found in agent output"); + return; + } + core.info(`Found ${createIssueItems.length} create-issue item(s)`); + if (isStaged) { + let summaryContent = "## 🎭 Staged Mode: Create Issues Preview\n\n"; + summaryContent += "The following issues would be created if staged mode was disabled:\n\n"; + for (let i = 0; i < createIssueItems.length; i++) { + const item = createIssueItems[i]; + summaryContent += `### Issue ${i + 1}\n`; + summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; + if (item.body) { + summaryContent += `**Body:**\n${item.body}\n\n`; + } + if (item.labels && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return; + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Issue creation preview written to step summary"); + return; + } + const parentIssueNumber = context.payload?.issue?.number; + const labelsEnv = process.env.GITHUB_AW_ISSUE_LABELS; + let envLabels = labelsEnv + ? labelsEnv + .split(",") + .map(label => label.trim()) + .filter(label => label) + : []; + const createdIssues = []; + for (let i = 0; i < createIssueItems.length; i++) { + const createIssueItem = createIssueItems[i]; + core.info( + `Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}` + ); + let labels = [...envLabels]; + if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { + labels = [...labels, ...createIssueItem.labels]; + } + labels = labels + .filter(label => label != null && label !== false && label !== 0) + .map(label => String(label).trim()) + .filter(label => label) + .map(label => sanitizeLabelContent(label)) + .filter(label => label) + .map(label => (label.length > 64 ? label.substring(0, 64) : label)) + .filter((label, index, arr) => arr.indexOf(label) === index); + let title = createIssueItem.title ? createIssueItem.title.trim() : ""; + let bodyLines = createIssueItem.body.split("\n"); + if (!title) { + title = createIssueItem.body || "Agent Output"; + } + const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; + if (titlePrefix && !title.startsWith(titlePrefix)) { + title = titlePrefix + title; + } + if (parentIssueNumber) { + core.info("Detected issue context, parent issue #" + parentIssueNumber); + bodyLines.push(`Related to #${parentIssueNumber}`); } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; + const workflowName = process.env.GITHUB_AW_WORKFLOW_NAME || "Workflow"; + const runId = context.runId; + const runUrl = context.payload.repository + ? `${context.payload.repository.html_url}/actions/runs/${runId}` + : `https://github.com/actions/runs/${runId}`; + bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); + const body = bodyLines.join("\n").trim(); + core.info(`Creating issue with title: ${title}`); + core.info(`Labels: ${labels}`); + core.info(`Body length: ${body.length}`); try { - validatedOutput = JSON.parse(outputContent); - } - catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - return; - } - const createIssueItems = validatedOutput.items.filter(item => item.type === "create-issue"); - if (createIssueItems.length === 0) { - core.info("No create-issue items found in agent output"); - return; - } - core.info(`Found ${createIssueItems.length} create-issue item(s)`); - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Issues Preview\n\n"; - summaryContent += "The following issues would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createIssueItems.length; i++) { - const item = createIssueItems[i]; - summaryContent += `### Issue ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; - } - if (item.labels && item.labels.length > 0) { - summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; - } - summaryContent += "---\n\n"; - } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Issue creation preview written to step summary"); - return; - } - const parentIssueNumber = context.payload?.issue?.number; - const labelsEnv = process.env.GITHUB_AW_ISSUE_LABELS; - let envLabels = labelsEnv - ? labelsEnv - .split(",") - .map((label) => label.trim()) - .filter((label) => label) - : []; - const createdIssues = []; - for (let i = 0; i < createIssueItems.length; i++) { - const createIssueItem = createIssueItems[i]; - core.info(`Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}`); - let labels = [...envLabels]; - if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { - labels = [...labels, ...createIssueItem.labels]; - } - labels = labels - .filter(label => label != null && label !== false && label !== 0) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - let title = createIssueItem.title ? createIssueItem.title.trim() : ""; - let bodyLines = createIssueItem.body.split("\n"); - if (!title) { - title = createIssueItem.body || "Agent Output"; - } - const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - if (parentIssueNumber) { - core.info("Detected issue context, parent issue #" + parentIssueNumber); - bodyLines.push(`Related to #${parentIssueNumber}`); - } - const workflowName = process.env.GITHUB_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const runUrl = context.payload.repository - ? `${context.payload.repository.html_url}/actions/runs/${runId}` - : `https://github.com/actions/runs/${runId}`; - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating issue with title: ${title}`); - core.info(`Labels: ${labels}`); - core.info(`Body length: ${body.length}`); + const { data: issue } = await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: title, + body: body, + labels: labels, + }); + core.info("Created issue #" + issue.number + ": " + issue.html_url); + createdIssues.push(issue); + if (parentIssueNumber) { try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: body, - labels: labels, - }); - core.info("Created issue #" + issue.number + ": " + issue.html_url); - createdIssues.push(issue); - if (parentIssueNumber) { - try { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: parentIssueNumber, - body: `Created related issue: #${issue.number}`, - }); - core.info("Added comment to parent issue #" + parentIssueNumber); - } - catch (error) { - core.info(`Warning: Could not add comment to parent issue: ${error instanceof Error ? error.message : String(error)}`); - } - } - if (i === createIssueItems.length - 1) { - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - } - } - catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Issues has been disabled in this repository")) { - core.info(`⚠ Cannot create issue "${title}": Issues are disabled for this repository`); - core.info("Consider enabling issues in repository settings if you want to create issues automatically"); - continue; - } - core.error(`✗ Failed to create issue "${title}": ${errorMessage}`); - throw error; + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: parentIssueNumber, + body: `Created related issue: #${issue.number}`, + }); + core.info("Added comment to parent issue #" + parentIssueNumber); + } catch (error) { + core.info(`Warning: Could not add comment to parent issue: ${error instanceof Error ? error.message : String(error)}`); } + } + if (i === createIssueItems.length - 1) { + core.setOutput("issue_number", issue.number); + core.setOutput("issue_url", issue.html_url); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage.includes("Issues has been disabled in this repository")) { + core.info(`⚠ Cannot create issue "${title}": Issues are disabled for this repository`); + core.info("Consider enabling issues in repository settings if you want to create issues automatically"); + continue; + } + core.error(`✗ Failed to create issue "${title}": ${errorMessage}`); + throw error; } - if (createdIssues.length > 0) { - let summaryContent = "\n\n## GitHub Issues\n"; - for (const issue of createdIssues) { - summaryContent += `- Issue #${issue.number}: [${issue.title}](${issue.html_url})\n`; - } - await core.summary.addRaw(summaryContent).write(); + } + if (createdIssues.length > 0) { + let summaryContent = "\n\n## GitHub Issues\n"; + for (const issue of createdIssues) { + summaryContent += `- Issue #${issue.number}: [${issue.title}](${issue.html_url})\n`; } - core.info(`Successfully created ${createdIssues.length} issue(s)`); + await core.summary.addRaw(summaryContent).write(); + } + core.info(`Successfully created ${createdIssues.length} issue(s)`); } (async () => { - await main(); + await main(); })(); add_comment: diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml index f8fcbe93e9c..372b187b60a 100644 --- a/.github/workflows/dev.lock.yml +++ b/.github/workflows/dev.lock.yml @@ -1224,742 +1224,742 @@ jobs: with: script: | async function main() { - const fs = require("fs"); - function sanitizeContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; - const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; - const allowedDomains = allowedDomainsEnv - ? allowedDomainsEnv - .split(",") - .map(d => d.trim()) - .filter(d => d) - : defaultAllowedDomains; - let sanitized = content; - sanitized = neutralizeMentions(sanitized); - sanitized = removeXmlComments(sanitized); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitizeUrlProtocols(sanitized); - sanitized = sanitizeUrlDomains(sanitized); - const maxLength = 524288; - if (sanitized.length > maxLength) { - sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; - } - const lines = sanitized.split("\n"); - const maxLines = 65000; - if (lines.length > maxLines) { - sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; - } - sanitized = neutralizeBotTriggers(sanitized); - return sanitized.trim(); - function sanitizeUrlDomains(s) { - return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { - const urlAfterProtocol = match.slice(8); - const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); - const isAllowed = allowedDomains.some(allowedDomain => { - const normalizedAllowed = allowedDomain.toLowerCase(); - return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); - }); - return isAllowed ? match : "(redacted)"; - }); - } - function sanitizeUrlProtocols(s) { - return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { - return protocol.toLowerCase() === "https" ? match : "(redacted)"; - }); - } - function neutralizeMentions(s) { - return s.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - } - function removeXmlComments(s) { - return s.replace(//g, "").replace(//g, ""); - } - function neutralizeBotTriggers(s) { - return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); - } + const fs = require("fs"); + function sanitizeContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; + const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; + const allowedDomains = allowedDomainsEnv + ? allowedDomainsEnv + .split(",") + .map(d => d.trim()) + .filter(d => d) + : defaultAllowedDomains; + let sanitized = content; + sanitized = neutralizeMentions(sanitized); + sanitized = removeXmlComments(sanitized); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitizeUrlProtocols(sanitized); + sanitized = sanitizeUrlDomains(sanitized); + const maxLength = 524288; + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; + } + const lines = sanitized.split("\n"); + const maxLines = 65000; + if (lines.length > maxLines) { + sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; + } + sanitized = neutralizeBotTriggers(sanitized); + return sanitized.trim(); + function sanitizeUrlDomains(s) { + return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { + const urlAfterProtocol = match.slice(8); + const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); + const isAllowed = allowedDomains.some(allowedDomain => { + const normalizedAllowed = allowedDomain.toLowerCase(); + return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); + }); + return isAllowed ? match : "(redacted)"; + }); } - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create-issue": - return 1; - case "add-comment": - return 1; - case "create-pull-request": - return 1; - case "create-pull-request-review-comment": - return 1; - case "add-labels": - return 5; - case "update-issue": - return 1; - case "push-to-pull-request-branch": - return 1; - case "create-discussion": - return 1; - case "missing-tool": - return 1000; - case "create-code-scanning-alert": - return 1000; - case "upload-asset": - return 10; - default: - return 1; - } + function sanitizeUrlProtocols(s) { + return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { + return protocol.toLowerCase() === "https" ? match : "(redacted)"; + }); } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; - } - return 0; - } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } - else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } - else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; - } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; - } - return { isValid: true, normalizedValue: parsed }; + function neutralizeMentions(s) { + return s.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; - } - return { isValid: true, normalizedValue: parsed }; + function removeXmlComments(s) { + return s.replace(//g, "").replace(//g, ""); } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - return { isValid: true }; - } - function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { - if (inputSchema.required && (value === undefined || value === null)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; - } - if (value === undefined || value === null) { - return { - isValid: true, - normalizedValue: inputSchema.default || undefined, - }; - } - const inputType = inputSchema.type || "string"; - let normalizedValue = value; - switch (inputType) { - case "string": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string`, - }; - } - normalizedValue = sanitizeContent(value); - break; - case "boolean": - if (typeof value !== "boolean") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a boolean`, - }; - } - break; - case "number": - if (typeof value !== "number") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number`, - }; - } - break; - case "choice": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, - }; - } - if (inputSchema.options && !inputSchema.options.includes(value)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, - }; - } - normalizedValue = sanitizeContent(value); - break; - default: - if (typeof value === "string") { - normalizedValue = sanitizeContent(value); - } - break; - } + function neutralizeBotTriggers(s) { + return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); + } + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + switch (itemType) { + case "create-issue": + return 1; + case "add-comment": + return 1; + case "create-pull-request": + return 1; + case "create-pull-request-review-comment": + return 1; + case "add-labels": + return 5; + case "update-issue": + return 1; + case "push-to-pull-request-branch": + return 1; + case "create-discussion": + return 1; + case "missing-tool": + return 1000; + case "create-code-scanning-alert": + return 1000; + case "upload-asset": + return 10; + default: + return 1; + } + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; + } + return 0; + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { return { - isValid: true, - normalizedValue, + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; } - function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { - const errors = []; - const normalizedItem = { ...item }; - if (!jobConfig.inputs) { - return { - isValid: true, - errors: [], - normalizedItem: item, - }; - } - for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { - const fieldValue = item[fieldName]; - const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); - if (!validation.isValid && validation.error) { - errors.push(validation.error); - } - else if (validation.normalizedValue !== undefined) { - normalizedItem[fieldName] = validation.normalizedValue; - } - } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-code-scanning-alert 'line'")) { return { - isValid: errors.length === 0, - errors, - normalizedItem, + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; } - function parseJsonWithRepair(jsonStr) { - try { - return JSON.parse(jsonStr); - } - catch (originalError) { - try { - const repairedJson = repairJson(jsonStr); - return JSON.parse(repairedJson); - } - catch (repairError) { - core.info(`invalid input json: ${jsonStr}`); - const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); - const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); - throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); - } - } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; } - const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; - const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; - if (!outputFile) { - core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); - core.setOutput("output", ""); - return; + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; } - if (!fs.existsSync(outputFile)) { - core.info(`Output file does not exist: ${outputFile}`); - core.setOutput("output", ""); - return; + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; } - const outputContent = fs.readFileSync(outputFile, "utf8"); - if (outputContent.trim() === "") { - core.info("Output file is empty"); + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; } - core.info(`Raw output content length: ${outputContent.length}`); - let expectedOutputTypes = {}; - if (safeOutputsConfig) { - try { - expectedOutputTypes = JSON.parse(safeOutputsConfig); - core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { + if (inputSchema.required && (value === undefined || value === null)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (value === undefined || value === null) { + return { + isValid: true, + normalizedValue: inputSchema.default || undefined, + }; + } + const inputType = inputSchema.type || "string"; + let normalizedValue = value; + switch (inputType) { + case "string": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string`, + }; + } + normalizedValue = sanitizeContent(value); + break; + case "boolean": + if (typeof value !== "boolean") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a boolean`, + }; + } + break; + case "number": + if (typeof value !== "number") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number`, + }; + } + break; + case "choice": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, + }; } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); + if (inputSchema.options && !inputSchema.options.includes(value)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, + }; + } + normalizedValue = sanitizeContent(value); + break; + default: + if (typeof value === "string") { + normalizedValue = sanitizeContent(value); } + break; } - const lines = outputContent.trim().split("\n"); - const parsedItems = []; + return { + isValid: true, + normalizedValue, + }; + } + function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { const errors = []; - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); - if (line === "") + const normalizedItem = { ...item }; + if (!jobConfig.inputs) { + return { + isValid: true, + errors: [], + normalizedItem: item, + }; + } + for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { + const fieldValue = item[fieldName]; + const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); + if (!validation.isValid && validation.error) { + errors.push(validation.error); + } else if (validation.normalizedValue !== undefined) { + normalizedItem[fieldName] = validation.normalizedValue; + } + } + return { + isValid: errors.length === 0, + errors, + normalizedItem, + }; + } + function parseJsonWithRepair(jsonStr) { + try { + return JSON.parse(jsonStr); + } catch (originalError) { + try { + const repairedJson = repairJson(jsonStr); + return JSON.parse(repairedJson); + } catch (repairError) { + core.info(`invalid input json: ${jsonStr}`); + const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); + const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); + throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); + } + } + } + const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; + const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; + if (!outputFile) { + core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); + core.setOutput("output", ""); + return; + } + if (!fs.existsSync(outputFile)) { + core.info(`Output file does not exist: ${outputFile}`); + core.setOutput("output", ""); + return; + } + const outputContent = fs.readFileSync(outputFile, "utf8"); + if (outputContent.trim() === "") { + core.info("Output file is empty"); + } + core.info(`Raw output content length: ${outputContent.length}`); + let expectedOutputTypes = {}; + if (safeOutputsConfig) { + try { + expectedOutputTypes = JSON.parse(safeOutputsConfig); + core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); + } + } + const lines = outputContent.trim().split("\n"); + const parsedItems = []; + const errors = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line === "") continue; + try { + const item = parseJsonWithRepair(line); + if (item === undefined) { + errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); + continue; + } + if (!item.type) { + errors.push(`Line ${i + 1}: Missing required 'type' field`); + continue; + } + const itemType = item.type; + if (!expectedOutputTypes[itemType]) { + errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); + continue; + } + const typeCount = parsedItems.filter(existing => existing.type === itemType).length; + const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); + if (typeCount >= maxAllowed) { + errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); + continue; + } + core.info(`Line ${i + 1}: type '${itemType}'`); + switch (itemType) { + case "create-issue": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); continue; - try { - const item = parseJsonWithRepair(line); - if (item === undefined) { - errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); - continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-comment": + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); + continue; + } + const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); + if (!issueNumValidation.isValid) { + if (issueNumValidation.error) errors.push(issueNumValidation.error); + continue; + } + item.body = sanitizeContent(item.body); + break; + case "create-pull-request": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); + continue; + } + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + item.branch = sanitizeContent(item.branch); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-labels": + if (!item.labels || !Array.isArray(item.labels)) { + errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); + continue; + } + if (item.labels.some(label => typeof label !== "string")) { + errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); + continue; + } + const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); + if (!labelsIssueNumValidation.isValid) { + if (labelsIssueNumValidation.error) errors.push(labelsIssueNumValidation.error); + continue; + } + item.labels = item.labels.map(label => sanitizeContent(label)); + break; + case "update-issue": + const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; + if (!hasValidField) { + errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); + continue; + } + if (item.status !== undefined) { + if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { + errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); + continue; } - if (!item.type) { - errors.push(`Line ${i + 1}: Missing required 'type' field`); - continue; + } + if (item.title !== undefined) { + if (typeof item.title !== "string") { + errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); + continue; } - const itemType = item.type; - if (!expectedOutputTypes[itemType]) { - errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); - continue; + item.title = sanitizeContent(item.title); + } + if (item.body !== undefined) { + if (typeof item.body !== "string") { + errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); + continue; } - const typeCount = parsedItems.filter(existing => existing.type === itemType).length; - const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); - if (typeCount >= maxAllowed) { - errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); - continue; + item.body = sanitizeContent(item.body); + } + const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); + if (!updateIssueNumValidation.isValid) { + if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); + continue; + } + break; + case "push-to-pull-request-branch": + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); + continue; + } + item.branch = sanitizeContent(item.branch); + item.message = sanitizeContent(item.message); + const pushPRNumValidation = validateIssueOrPRNumber( + item.pull_request_number, + "push-to-pull-request-branch 'pull_request_number'", + i + 1 + ); + if (!pushPRNumValidation.isValid) { + if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); + continue; + } + break; + case "create-pull-request-review-comment": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); + continue; + } + const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); + if (!lineValidation.isValid) { + if (lineValidation.error) errors.push(lineValidation.error); + continue; + } + const lineNumber = lineValidation.normalizedValue; + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); + continue; + } + item.body = sanitizeContent(item.body); + const startLineValidation = validateOptionalPositiveInteger( + item.start_line, + "create-pull-request-review-comment 'start_line'", + i + 1 + ); + if (!startLineValidation.isValid) { + if (startLineValidation.error) errors.push(startLineValidation.error); + continue; + } + if ( + startLineValidation.normalizedValue !== undefined && + lineNumber !== undefined && + startLineValidation.normalizedValue > lineNumber + ) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); + continue; + } + if (item.side !== undefined) { + if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); + continue; } - core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create-issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); - if (!issueNumValidation.isValid) { - if (issueNumValidation.error) - errors.push(issueNumValidation.error); - continue; - } - item.body = sanitizeContent(item.body); - break; - case "create-pull-request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - item.branch = sanitizeContent(item.branch); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some((label) => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); - if (!labelsIssueNumValidation.isValid) { - if (labelsIssueNumValidation.error) - errors.push(labelsIssueNumValidation.error); - continue; - } - item.labels = item.labels.map((label) => sanitizeContent(label)); - break; - case "update-issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) - errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "push-to-pull-request-branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch); - item.message = sanitizeContent(item.message); - const pushPRNumValidation = validateIssueOrPRNumber(item.pull_request_number, "push-to-pull-request-branch 'pull_request_number'", i + 1); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) - errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create-pull-request-review-comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) - errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body); - const startLineValidation = validateOptionalPositiveInteger(item.start_line, "create-pull-request-review-comment 'start_line'", i + 1); - if (!startLineValidation.isValid) { - if (startLineValidation.error) - errors.push(startLineValidation.error); - continue; - } - if (startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create-discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category); - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - break; - case "missing-tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool); - item.reason = sanitizeContent(item.reason); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives); - } - break; - case "upload-asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "create-code-scanning-alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}`); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) - errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores`); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file); - item.severity = sanitizeContent(item.severity); - item.message = sanitizeContent(item.message); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); - continue; - } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + } + break; + case "create-discussion": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); + continue; + } + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); + continue; } - core.info(`Line ${i + 1}: Valid ${itemType} item`); - parsedItems.push(item); - } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); - } - } - if (errors.length > 0) { - core.warning("Validation errors found:"); - errors.forEach(error => core.warning(` - ${error}`)); - if (parsedItems.length === 0) { - core.setFailed(errors.map(e => ` - ${e}`).join("\n")); - return; - } - } - for (const itemType of Object.keys(expectedOutputTypes)) { - const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); - if (minRequired > 0) { - const actualCount = parsedItems.filter(item => item.type === itemType).length; - if (actualCount < minRequired) { - errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); + item.category = sanitizeContent(item.category); + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + break; + case "missing-tool": + if (!item.tool || typeof item.tool !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); + continue; + } + if (!item.reason || typeof item.reason !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); + continue; + } + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); + continue; } - } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "upload-asset": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); + continue; + } + break; + case "create-code-scanning-alert": + if (!item.file || typeof item.file !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); + continue; + } + const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); + if (!alertLineValidation.isValid) { + if (alertLineValidation.error) { + errors.push(alertLineValidation.error); + } + continue; + } + if (!item.severity || typeof item.severity !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); + continue; + } + const allowedSeverities = ["error", "warning", "info", "note"]; + if (!allowedSeverities.includes(item.severity.toLowerCase())) { + errors.push( + `Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` + ); + continue; + } + const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); + if (!columnValidation.isValid) { + if (columnValidation.error) errors.push(columnValidation.error); + continue; + } + if (item.ruleIdSuffix !== undefined) { + if (typeof item.ruleIdSuffix !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); + continue; + } + if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { + errors.push( + `Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` + ); + continue; + } + } + item.severity = item.severity.toLowerCase(); + item.file = sanitizeContent(item.file); + item.severity = sanitizeContent(item.severity); + item.message = sanitizeContent(item.message); + if (item.ruleIdSuffix) { + item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); + } + break; + default: + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); + continue; + } + Object.assign(item, validation.normalizedItem); + } + break; + } + core.info(`Line ${i + 1}: Valid ${itemType} item`); + parsedItems.push(item); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); } - core.info(`Successfully parsed ${parsedItems.length} valid output items`); - const validatedOutput = { - items: parsedItems, - errors: errors, - }; - const agentOutputFile = "/tmp/agent_output.json"; - const validatedOutputJson = JSON.stringify(validatedOutput); - try { - fs.mkdirSync("/tmp", { recursive: true }); - fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); - core.info(`Stored validated output to: ${agentOutputFile}`); - core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); - } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.error(`Failed to write agent output file: ${errorMsg}`); - } - core.setOutput("output", JSON.stringify(validatedOutput)); - core.setOutput("raw_output", outputContent); - const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); - core.info(`output_types: ${outputTypes.join(", ")}`); - core.setOutput("output_types", outputTypes.join(",")); - try { - await core.summary - .addRaw("## Processed Output\n\n") - .addRaw("```json\n") - .addRaw(JSON.stringify(validatedOutput)) - .addRaw("\n```\n") - .write(); - core.info("Successfully wrote processed output to step summary"); - } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.warning(`Failed to write to step summary: ${errorMsg}`); + } + if (errors.length > 0) { + core.warning("Validation errors found:"); + errors.forEach(error => core.warning(` - ${error}`)); + if (parsedItems.length === 0) { + core.setFailed(errors.map(e => ` - ${e}`).join("\n")); + return; } + } + for (const itemType of Object.keys(expectedOutputTypes)) { + const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); + if (minRequired > 0) { + const actualCount = parsedItems.filter(item => item.type === itemType).length; + if (actualCount < minRequired) { + errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); + } + } + } + core.info(`Successfully parsed ${parsedItems.length} valid output items`); + const validatedOutput = { + items: parsedItems, + errors: errors, + }; + const agentOutputFile = "/tmp/agent_output.json"; + const validatedOutputJson = JSON.stringify(validatedOutput); + try { + fs.mkdirSync("/tmp", { recursive: true }); + fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); + core.info(`Stored validated output to: ${agentOutputFile}`); + core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.error(`Failed to write agent output file: ${errorMsg}`); + } + core.setOutput("output", JSON.stringify(validatedOutput)); + core.setOutput("raw_output", outputContent); + const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); + core.info(`output_types: ${outputTypes.join(", ")}`); + core.setOutput("output_types", outputTypes.join(",")); + try { + await core.summary + .addRaw("## Processed Output\n\n") + .addRaw("```json\n") + .addRaw(JSON.stringify(validatedOutput)) + .addRaw("\n```\n") + .write(); + core.info("Successfully wrote processed output to step summary"); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.warning(`Failed to write to step summary: ${errorMsg}`); + } } await main(); - name: Upload sanitized agent output @@ -2694,163 +2694,165 @@ jobs: with: script: | function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); + if (!content || typeof content !== "string") { + return ""; + } + let sanitized = content.trim(); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); + sanitized = sanitized.replace(/[<>&'"]/g, ""); + return sanitized.trim(); } async function main() { - const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; - const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; - if (!outputContent) { - core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); - return; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return; + const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; + const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; + if (!outputContent) { + core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); + return; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); + return; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + return; + } + const createIssueItems = validatedOutput.items.filter(item => item.type === "create-issue"); + if (createIssueItems.length === 0) { + core.info("No create-issue items found in agent output"); + return; + } + core.info(`Found ${createIssueItems.length} create-issue item(s)`); + if (isStaged) { + let summaryContent = "## 🎭 Staged Mode: Create Issues Preview\n\n"; + summaryContent += "The following issues would be created if staged mode was disabled:\n\n"; + for (let i = 0; i < createIssueItems.length; i++) { + const item = createIssueItems[i]; + summaryContent += `### Issue ${i + 1}\n`; + summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; + if (item.body) { + summaryContent += `**Body:**\n${item.body}\n\n`; + } + if (item.labels && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Issue creation preview written to step summary"); + return; + } + const parentIssueNumber = context.payload?.issue?.number; + const labelsEnv = process.env.GITHUB_AW_ISSUE_LABELS; + let envLabels = labelsEnv + ? labelsEnv + .split(",") + .map(label => label.trim()) + .filter(label => label) + : []; + const createdIssues = []; + for (let i = 0; i < createIssueItems.length; i++) { + const createIssueItem = createIssueItems[i]; + core.info( + `Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}` + ); + let labels = [...envLabels]; + if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { + labels = [...labels, ...createIssueItem.labels]; + } + labels = labels + .filter(label => label != null && label !== false && label !== 0) + .map(label => String(label).trim()) + .filter(label => label) + .map(label => sanitizeLabelContent(label)) + .filter(label => label) + .map(label => (label.length > 64 ? label.substring(0, 64) : label)) + .filter((label, index, arr) => arr.indexOf(label) === index); + let title = createIssueItem.title ? createIssueItem.title.trim() : ""; + let bodyLines = createIssueItem.body.split("\n"); + if (!title) { + title = createIssueItem.body || "Agent Output"; + } + const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; + if (titlePrefix && !title.startsWith(titlePrefix)) { + title = titlePrefix + title; + } + if (parentIssueNumber) { + core.info("Detected issue context, parent issue #" + parentIssueNumber); + bodyLines.push(`Related to #${parentIssueNumber}`); + } + const workflowName = process.env.GITHUB_AW_WORKFLOW_NAME || "Workflow"; + const runId = context.runId; + const runUrl = context.payload.repository + ? `${context.payload.repository.html_url}/actions/runs/${runId}` + : `https://github.com/actions/runs/${runId}`; + bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); + const body = bodyLines.join("\n").trim(); + core.info(`Creating issue with title: ${title}`); + core.info(`Labels: ${labels}`); + core.info(`Body length: ${body.length}`); try { - validatedOutput = JSON.parse(outputContent); - } - catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - return; - } - const createIssueItems = validatedOutput.items.filter(item => item.type === "create-issue"); - if (createIssueItems.length === 0) { - core.info("No create-issue items found in agent output"); - return; - } - core.info(`Found ${createIssueItems.length} create-issue item(s)`); - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Issues Preview\n\n"; - summaryContent += "The following issues would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createIssueItems.length; i++) { - const item = createIssueItems[i]; - summaryContent += `### Issue ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; - } - if (item.labels && item.labels.length > 0) { - summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; - } - summaryContent += "---\n\n"; - } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Issue creation preview written to step summary"); - return; - } - const parentIssueNumber = context.payload?.issue?.number; - const labelsEnv = process.env.GITHUB_AW_ISSUE_LABELS; - let envLabels = labelsEnv - ? labelsEnv - .split(",") - .map((label) => label.trim()) - .filter((label) => label) - : []; - const createdIssues = []; - for (let i = 0; i < createIssueItems.length; i++) { - const createIssueItem = createIssueItems[i]; - core.info(`Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}`); - let labels = [...envLabels]; - if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { - labels = [...labels, ...createIssueItem.labels]; - } - labels = labels - .filter(label => label != null && label !== false && label !== 0) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - let title = createIssueItem.title ? createIssueItem.title.trim() : ""; - let bodyLines = createIssueItem.body.split("\n"); - if (!title) { - title = createIssueItem.body || "Agent Output"; - } - const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - if (parentIssueNumber) { - core.info("Detected issue context, parent issue #" + parentIssueNumber); - bodyLines.push(`Related to #${parentIssueNumber}`); - } - const workflowName = process.env.GITHUB_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const runUrl = context.payload.repository - ? `${context.payload.repository.html_url}/actions/runs/${runId}` - : `https://github.com/actions/runs/${runId}`; - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating issue with title: ${title}`); - core.info(`Labels: ${labels}`); - core.info(`Body length: ${body.length}`); + const { data: issue } = await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: title, + body: body, + labels: labels, + }); + core.info("Created issue #" + issue.number + ": " + issue.html_url); + createdIssues.push(issue); + if (parentIssueNumber) { try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: body, - labels: labels, - }); - core.info("Created issue #" + issue.number + ": " + issue.html_url); - createdIssues.push(issue); - if (parentIssueNumber) { - try { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: parentIssueNumber, - body: `Created related issue: #${issue.number}`, - }); - core.info("Added comment to parent issue #" + parentIssueNumber); - } - catch (error) { - core.info(`Warning: Could not add comment to parent issue: ${error instanceof Error ? error.message : String(error)}`); - } - } - if (i === createIssueItems.length - 1) { - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - } - } - catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Issues has been disabled in this repository")) { - core.info(`⚠ Cannot create issue "${title}": Issues are disabled for this repository`); - core.info("Consider enabling issues in repository settings if you want to create issues automatically"); - continue; - } - core.error(`✗ Failed to create issue "${title}": ${errorMessage}`); - throw error; + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: parentIssueNumber, + body: `Created related issue: #${issue.number}`, + }); + core.info("Added comment to parent issue #" + parentIssueNumber); + } catch (error) { + core.info(`Warning: Could not add comment to parent issue: ${error instanceof Error ? error.message : String(error)}`); } + } + if (i === createIssueItems.length - 1) { + core.setOutput("issue_number", issue.number); + core.setOutput("issue_url", issue.html_url); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage.includes("Issues has been disabled in this repository")) { + core.info(`⚠ Cannot create issue "${title}": Issues are disabled for this repository`); + core.info("Consider enabling issues in repository settings if you want to create issues automatically"); + continue; + } + core.error(`✗ Failed to create issue "${title}": ${errorMessage}`); + throw error; } - if (createdIssues.length > 0) { - let summaryContent = "\n\n## GitHub Issues\n"; - for (const issue of createdIssues) { - summaryContent += `- Issue #${issue.number}: [${issue.title}](${issue.html_url})\n`; - } - await core.summary.addRaw(summaryContent).write(); + } + if (createdIssues.length > 0) { + let summaryContent = "\n\n## GitHub Issues\n"; + for (const issue of createdIssues) { + summaryContent += `- Issue #${issue.number}: [${issue.title}](${issue.html_url})\n`; } - core.info(`Successfully created ${createdIssues.length} issue(s)`); + await core.summary.addRaw(summaryContent).write(); + } + core.info(`Successfully created ${createdIssues.length} issue(s)`); } (async () => { - await main(); + await main(); })(); missing_tool: diff --git a/.github/workflows/duplicate-code-detector.lock.yml b/.github/workflows/duplicate-code-detector.lock.yml index d7af3964109..5bad832fd33 100644 --- a/.github/workflows/duplicate-code-detector.lock.yml +++ b/.github/workflows/duplicate-code-detector.lock.yml @@ -1293,742 +1293,742 @@ jobs: with: script: | async function main() { - const fs = require("fs"); - function sanitizeContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; - const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; - const allowedDomains = allowedDomainsEnv - ? allowedDomainsEnv - .split(",") - .map(d => d.trim()) - .filter(d => d) - : defaultAllowedDomains; - let sanitized = content; - sanitized = neutralizeMentions(sanitized); - sanitized = removeXmlComments(sanitized); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitizeUrlProtocols(sanitized); - sanitized = sanitizeUrlDomains(sanitized); - const maxLength = 524288; - if (sanitized.length > maxLength) { - sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; - } - const lines = sanitized.split("\n"); - const maxLines = 65000; - if (lines.length > maxLines) { - sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; - } - sanitized = neutralizeBotTriggers(sanitized); - return sanitized.trim(); - function sanitizeUrlDomains(s) { - return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { - const urlAfterProtocol = match.slice(8); - const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); - const isAllowed = allowedDomains.some(allowedDomain => { - const normalizedAllowed = allowedDomain.toLowerCase(); - return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); - }); - return isAllowed ? match : "(redacted)"; - }); - } - function sanitizeUrlProtocols(s) { - return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { - return protocol.toLowerCase() === "https" ? match : "(redacted)"; - }); - } - function neutralizeMentions(s) { - return s.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - } - function removeXmlComments(s) { - return s.replace(//g, "").replace(//g, ""); - } - function neutralizeBotTriggers(s) { - return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); - } + const fs = require("fs"); + function sanitizeContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; + const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; + const allowedDomains = allowedDomainsEnv + ? allowedDomainsEnv + .split(",") + .map(d => d.trim()) + .filter(d => d) + : defaultAllowedDomains; + let sanitized = content; + sanitized = neutralizeMentions(sanitized); + sanitized = removeXmlComments(sanitized); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitizeUrlProtocols(sanitized); + sanitized = sanitizeUrlDomains(sanitized); + const maxLength = 524288; + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; + } + const lines = sanitized.split("\n"); + const maxLines = 65000; + if (lines.length > maxLines) { + sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; + } + sanitized = neutralizeBotTriggers(sanitized); + return sanitized.trim(); + function sanitizeUrlDomains(s) { + return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { + const urlAfterProtocol = match.slice(8); + const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); + const isAllowed = allowedDomains.some(allowedDomain => { + const normalizedAllowed = allowedDomain.toLowerCase(); + return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); + }); + return isAllowed ? match : "(redacted)"; + }); } - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create-issue": - return 1; - case "add-comment": - return 1; - case "create-pull-request": - return 1; - case "create-pull-request-review-comment": - return 1; - case "add-labels": - return 5; - case "update-issue": - return 1; - case "push-to-pull-request-branch": - return 1; - case "create-discussion": - return 1; - case "missing-tool": - return 1000; - case "create-code-scanning-alert": - return 1000; - case "upload-asset": - return 10; - default: - return 1; - } + function sanitizeUrlProtocols(s) { + return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { + return protocol.toLowerCase() === "https" ? match : "(redacted)"; + }); } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; - } - return 0; - } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } - else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } - else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; - } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; - } - return { isValid: true, normalizedValue: parsed }; + function neutralizeMentions(s) { + return s.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; - } - return { isValid: true, normalizedValue: parsed }; + function removeXmlComments(s) { + return s.replace(//g, "").replace(//g, ""); } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - return { isValid: true }; - } - function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { - if (inputSchema.required && (value === undefined || value === null)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; - } - if (value === undefined || value === null) { - return { - isValid: true, - normalizedValue: inputSchema.default || undefined, - }; - } - const inputType = inputSchema.type || "string"; - let normalizedValue = value; - switch (inputType) { - case "string": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string`, - }; - } - normalizedValue = sanitizeContent(value); - break; - case "boolean": - if (typeof value !== "boolean") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a boolean`, - }; - } - break; - case "number": - if (typeof value !== "number") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number`, - }; - } - break; - case "choice": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, - }; - } - if (inputSchema.options && !inputSchema.options.includes(value)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, - }; - } - normalizedValue = sanitizeContent(value); - break; - default: - if (typeof value === "string") { - normalizedValue = sanitizeContent(value); - } - break; - } + function neutralizeBotTriggers(s) { + return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); + } + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + switch (itemType) { + case "create-issue": + return 1; + case "add-comment": + return 1; + case "create-pull-request": + return 1; + case "create-pull-request-review-comment": + return 1; + case "add-labels": + return 5; + case "update-issue": + return 1; + case "push-to-pull-request-branch": + return 1; + case "create-discussion": + return 1; + case "missing-tool": + return 1000; + case "create-code-scanning-alert": + return 1000; + case "upload-asset": + return 10; + default: + return 1; + } + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; + } + return 0; + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { return { - isValid: true, - normalizedValue, + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; } - function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { - const errors = []; - const normalizedItem = { ...item }; - if (!jobConfig.inputs) { - return { - isValid: true, - errors: [], - normalizedItem: item, - }; - } - for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { - const fieldValue = item[fieldName]; - const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); - if (!validation.isValid && validation.error) { - errors.push(validation.error); - } - else if (validation.normalizedValue !== undefined) { - normalizedItem[fieldName] = validation.normalizedValue; - } - } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-code-scanning-alert 'line'")) { return { - isValid: errors.length === 0, - errors, - normalizedItem, + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; } - function parseJsonWithRepair(jsonStr) { - try { - return JSON.parse(jsonStr); - } - catch (originalError) { - try { - const repairedJson = repairJson(jsonStr); - return JSON.parse(repairedJson); - } - catch (repairError) { - core.info(`invalid input json: ${jsonStr}`); - const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); - const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); - throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); - } - } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; } - const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; - const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; - if (!outputFile) { - core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); - core.setOutput("output", ""); - return; + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; } - if (!fs.existsSync(outputFile)) { - core.info(`Output file does not exist: ${outputFile}`); - core.setOutput("output", ""); - return; + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; } - const outputContent = fs.readFileSync(outputFile, "utf8"); - if (outputContent.trim() === "") { - core.info("Output file is empty"); + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; } - core.info(`Raw output content length: ${outputContent.length}`); - let expectedOutputTypes = {}; - if (safeOutputsConfig) { - try { - expectedOutputTypes = JSON.parse(safeOutputsConfig); - core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); + return { isValid: true }; + } + function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { + if (inputSchema.required && (value === undefined || value === null)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (value === undefined || value === null) { + return { + isValid: true, + normalizedValue: inputSchema.default || undefined, + }; + } + const inputType = inputSchema.type || "string"; + let normalizedValue = value; + switch (inputType) { + case "string": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string`, + }; } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); + normalizedValue = sanitizeContent(value); + break; + case "boolean": + if (typeof value !== "boolean") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a boolean`, + }; + } + break; + case "number": + if (typeof value !== "number") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number`, + }; + } + break; + case "choice": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, + }; } + if (inputSchema.options && !inputSchema.options.includes(value)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, + }; + } + normalizedValue = sanitizeContent(value); + break; + default: + if (typeof value === "string") { + normalizedValue = sanitizeContent(value); + } + break; } - const lines = outputContent.trim().split("\n"); - const parsedItems = []; + return { + isValid: true, + normalizedValue, + }; + } + function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { const errors = []; - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); - if (line === "") + const normalizedItem = { ...item }; + if (!jobConfig.inputs) { + return { + isValid: true, + errors: [], + normalizedItem: item, + }; + } + for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { + const fieldValue = item[fieldName]; + const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); + if (!validation.isValid && validation.error) { + errors.push(validation.error); + } else if (validation.normalizedValue !== undefined) { + normalizedItem[fieldName] = validation.normalizedValue; + } + } + return { + isValid: errors.length === 0, + errors, + normalizedItem, + }; + } + function parseJsonWithRepair(jsonStr) { + try { + return JSON.parse(jsonStr); + } catch (originalError) { + try { + const repairedJson = repairJson(jsonStr); + return JSON.parse(repairedJson); + } catch (repairError) { + core.info(`invalid input json: ${jsonStr}`); + const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); + const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); + throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); + } + } + } + const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; + const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; + if (!outputFile) { + core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); + core.setOutput("output", ""); + return; + } + if (!fs.existsSync(outputFile)) { + core.info(`Output file does not exist: ${outputFile}`); + core.setOutput("output", ""); + return; + } + const outputContent = fs.readFileSync(outputFile, "utf8"); + if (outputContent.trim() === "") { + core.info("Output file is empty"); + } + core.info(`Raw output content length: ${outputContent.length}`); + let expectedOutputTypes = {}; + if (safeOutputsConfig) { + try { + expectedOutputTypes = JSON.parse(safeOutputsConfig); + core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); + } + } + const lines = outputContent.trim().split("\n"); + const parsedItems = []; + const errors = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line === "") continue; + try { + const item = parseJsonWithRepair(line); + if (item === undefined) { + errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); + continue; + } + if (!item.type) { + errors.push(`Line ${i + 1}: Missing required 'type' field`); + continue; + } + const itemType = item.type; + if (!expectedOutputTypes[itemType]) { + errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); + continue; + } + const typeCount = parsedItems.filter(existing => existing.type === itemType).length; + const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); + if (typeCount >= maxAllowed) { + errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); + continue; + } + core.info(`Line ${i + 1}: type '${itemType}'`); + switch (itemType) { + case "create-issue": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); continue; - try { - const item = parseJsonWithRepair(line); - if (item === undefined) { - errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); - continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-comment": + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); + continue; + } + const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); + if (!issueNumValidation.isValid) { + if (issueNumValidation.error) errors.push(issueNumValidation.error); + continue; + } + item.body = sanitizeContent(item.body); + break; + case "create-pull-request": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); + continue; + } + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + item.branch = sanitizeContent(item.branch); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-labels": + if (!item.labels || !Array.isArray(item.labels)) { + errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); + continue; + } + if (item.labels.some(label => typeof label !== "string")) { + errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); + continue; + } + const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); + if (!labelsIssueNumValidation.isValid) { + if (labelsIssueNumValidation.error) errors.push(labelsIssueNumValidation.error); + continue; + } + item.labels = item.labels.map(label => sanitizeContent(label)); + break; + case "update-issue": + const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; + if (!hasValidField) { + errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); + continue; + } + if (item.status !== undefined) { + if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { + errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); + continue; } - if (!item.type) { - errors.push(`Line ${i + 1}: Missing required 'type' field`); - continue; + } + if (item.title !== undefined) { + if (typeof item.title !== "string") { + errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); + continue; } - const itemType = item.type; - if (!expectedOutputTypes[itemType]) { - errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); - continue; + item.title = sanitizeContent(item.title); + } + if (item.body !== undefined) { + if (typeof item.body !== "string") { + errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); + continue; } - const typeCount = parsedItems.filter(existing => existing.type === itemType).length; - const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); - if (typeCount >= maxAllowed) { - errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); - continue; + item.body = sanitizeContent(item.body); + } + const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); + if (!updateIssueNumValidation.isValid) { + if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); + continue; + } + break; + case "push-to-pull-request-branch": + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); + continue; + } + item.branch = sanitizeContent(item.branch); + item.message = sanitizeContent(item.message); + const pushPRNumValidation = validateIssueOrPRNumber( + item.pull_request_number, + "push-to-pull-request-branch 'pull_request_number'", + i + 1 + ); + if (!pushPRNumValidation.isValid) { + if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); + continue; + } + break; + case "create-pull-request-review-comment": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); + continue; + } + const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); + if (!lineValidation.isValid) { + if (lineValidation.error) errors.push(lineValidation.error); + continue; + } + const lineNumber = lineValidation.normalizedValue; + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); + continue; + } + item.body = sanitizeContent(item.body); + const startLineValidation = validateOptionalPositiveInteger( + item.start_line, + "create-pull-request-review-comment 'start_line'", + i + 1 + ); + if (!startLineValidation.isValid) { + if (startLineValidation.error) errors.push(startLineValidation.error); + continue; + } + if ( + startLineValidation.normalizedValue !== undefined && + lineNumber !== undefined && + startLineValidation.normalizedValue > lineNumber + ) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); + continue; + } + if (item.side !== undefined) { + if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); + continue; } - core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create-issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); - if (!issueNumValidation.isValid) { - if (issueNumValidation.error) - errors.push(issueNumValidation.error); - continue; - } - item.body = sanitizeContent(item.body); - break; - case "create-pull-request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - item.branch = sanitizeContent(item.branch); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some((label) => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); - if (!labelsIssueNumValidation.isValid) { - if (labelsIssueNumValidation.error) - errors.push(labelsIssueNumValidation.error); - continue; - } - item.labels = item.labels.map((label) => sanitizeContent(label)); - break; - case "update-issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) - errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "push-to-pull-request-branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch); - item.message = sanitizeContent(item.message); - const pushPRNumValidation = validateIssueOrPRNumber(item.pull_request_number, "push-to-pull-request-branch 'pull_request_number'", i + 1); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) - errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create-pull-request-review-comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) - errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body); - const startLineValidation = validateOptionalPositiveInteger(item.start_line, "create-pull-request-review-comment 'start_line'", i + 1); - if (!startLineValidation.isValid) { - if (startLineValidation.error) - errors.push(startLineValidation.error); - continue; - } - if (startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create-discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category); - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - break; - case "missing-tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool); - item.reason = sanitizeContent(item.reason); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives); - } - break; - case "upload-asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "create-code-scanning-alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}`); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) - errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores`); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file); - item.severity = sanitizeContent(item.severity); - item.message = sanitizeContent(item.message); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); - continue; - } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + } + break; + case "create-discussion": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); + continue; + } + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); + continue; } - core.info(`Line ${i + 1}: Valid ${itemType} item`); - parsedItems.push(item); - } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); - } - } - if (errors.length > 0) { - core.warning("Validation errors found:"); - errors.forEach(error => core.warning(` - ${error}`)); - if (parsedItems.length === 0) { - core.setFailed(errors.map(e => ` - ${e}`).join("\n")); - return; - } - } - for (const itemType of Object.keys(expectedOutputTypes)) { - const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); - if (minRequired > 0) { - const actualCount = parsedItems.filter(item => item.type === itemType).length; - if (actualCount < minRequired) { - errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); + item.category = sanitizeContent(item.category); + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + break; + case "missing-tool": + if (!item.tool || typeof item.tool !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); + continue; + } + if (!item.reason || typeof item.reason !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); + continue; + } + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); + continue; } - } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "upload-asset": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); + continue; + } + break; + case "create-code-scanning-alert": + if (!item.file || typeof item.file !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); + continue; + } + const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); + if (!alertLineValidation.isValid) { + if (alertLineValidation.error) { + errors.push(alertLineValidation.error); + } + continue; + } + if (!item.severity || typeof item.severity !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); + continue; + } + const allowedSeverities = ["error", "warning", "info", "note"]; + if (!allowedSeverities.includes(item.severity.toLowerCase())) { + errors.push( + `Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` + ); + continue; + } + const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); + if (!columnValidation.isValid) { + if (columnValidation.error) errors.push(columnValidation.error); + continue; + } + if (item.ruleIdSuffix !== undefined) { + if (typeof item.ruleIdSuffix !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); + continue; + } + if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { + errors.push( + `Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` + ); + continue; + } + } + item.severity = item.severity.toLowerCase(); + item.file = sanitizeContent(item.file); + item.severity = sanitizeContent(item.severity); + item.message = sanitizeContent(item.message); + if (item.ruleIdSuffix) { + item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); + } + break; + default: + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); + continue; + } + Object.assign(item, validation.normalizedItem); + } + break; + } + core.info(`Line ${i + 1}: Valid ${itemType} item`); + parsedItems.push(item); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); } - core.info(`Successfully parsed ${parsedItems.length} valid output items`); - const validatedOutput = { - items: parsedItems, - errors: errors, - }; - const agentOutputFile = "/tmp/agent_output.json"; - const validatedOutputJson = JSON.stringify(validatedOutput); - try { - fs.mkdirSync("/tmp", { recursive: true }); - fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); - core.info(`Stored validated output to: ${agentOutputFile}`); - core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); - } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.error(`Failed to write agent output file: ${errorMsg}`); - } - core.setOutput("output", JSON.stringify(validatedOutput)); - core.setOutput("raw_output", outputContent); - const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); - core.info(`output_types: ${outputTypes.join(", ")}`); - core.setOutput("output_types", outputTypes.join(",")); - try { - await core.summary - .addRaw("## Processed Output\n\n") - .addRaw("```json\n") - .addRaw(JSON.stringify(validatedOutput)) - .addRaw("\n```\n") - .write(); - core.info("Successfully wrote processed output to step summary"); - } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.warning(`Failed to write to step summary: ${errorMsg}`); + } + if (errors.length > 0) { + core.warning("Validation errors found:"); + errors.forEach(error => core.warning(` - ${error}`)); + if (parsedItems.length === 0) { + core.setFailed(errors.map(e => ` - ${e}`).join("\n")); + return; } + } + for (const itemType of Object.keys(expectedOutputTypes)) { + const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); + if (minRequired > 0) { + const actualCount = parsedItems.filter(item => item.type === itemType).length; + if (actualCount < minRequired) { + errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); + } + } + } + core.info(`Successfully parsed ${parsedItems.length} valid output items`); + const validatedOutput = { + items: parsedItems, + errors: errors, + }; + const agentOutputFile = "/tmp/agent_output.json"; + const validatedOutputJson = JSON.stringify(validatedOutput); + try { + fs.mkdirSync("/tmp", { recursive: true }); + fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); + core.info(`Stored validated output to: ${agentOutputFile}`); + core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.error(`Failed to write agent output file: ${errorMsg}`); + } + core.setOutput("output", JSON.stringify(validatedOutput)); + core.setOutput("raw_output", outputContent); + const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); + core.info(`output_types: ${outputTypes.join(", ")}`); + core.setOutput("output_types", outputTypes.join(",")); + try { + await core.summary + .addRaw("## Processed Output\n\n") + .addRaw("```json\n") + .addRaw(JSON.stringify(validatedOutput)) + .addRaw("\n```\n") + .write(); + core.info("Successfully wrote processed output to step summary"); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.warning(`Failed to write to step summary: ${errorMsg}`); + } } await main(); - name: Upload sanitized agent output @@ -2927,163 +2927,165 @@ jobs: with: script: | function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); + if (!content || typeof content !== "string") { + return ""; + } + let sanitized = content.trim(); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); + sanitized = sanitized.replace(/[<>&'"]/g, ""); + return sanitized.trim(); } async function main() { - const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; - const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; - if (!outputContent) { - core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); - return; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return; + const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; + const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; + if (!outputContent) { + core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); + return; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); + return; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + return; + } + const createIssueItems = validatedOutput.items.filter(item => item.type === "create-issue"); + if (createIssueItems.length === 0) { + core.info("No create-issue items found in agent output"); + return; + } + core.info(`Found ${createIssueItems.length} create-issue item(s)`); + if (isStaged) { + let summaryContent = "## 🎭 Staged Mode: Create Issues Preview\n\n"; + summaryContent += "The following issues would be created if staged mode was disabled:\n\n"; + for (let i = 0; i < createIssueItems.length; i++) { + const item = createIssueItems[i]; + summaryContent += `### Issue ${i + 1}\n`; + summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; + if (item.body) { + summaryContent += `**Body:**\n${item.body}\n\n`; + } + if (item.labels && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Issue creation preview written to step summary"); + return; + } + const parentIssueNumber = context.payload?.issue?.number; + const labelsEnv = process.env.GITHUB_AW_ISSUE_LABELS; + let envLabels = labelsEnv + ? labelsEnv + .split(",") + .map(label => label.trim()) + .filter(label => label) + : []; + const createdIssues = []; + for (let i = 0; i < createIssueItems.length; i++) { + const createIssueItem = createIssueItems[i]; + core.info( + `Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}` + ); + let labels = [...envLabels]; + if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { + labels = [...labels, ...createIssueItem.labels]; + } + labels = labels + .filter(label => label != null && label !== false && label !== 0) + .map(label => String(label).trim()) + .filter(label => label) + .map(label => sanitizeLabelContent(label)) + .filter(label => label) + .map(label => (label.length > 64 ? label.substring(0, 64) : label)) + .filter((label, index, arr) => arr.indexOf(label) === index); + let title = createIssueItem.title ? createIssueItem.title.trim() : ""; + let bodyLines = createIssueItem.body.split("\n"); + if (!title) { + title = createIssueItem.body || "Agent Output"; + } + const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; + if (titlePrefix && !title.startsWith(titlePrefix)) { + title = titlePrefix + title; + } + if (parentIssueNumber) { + core.info("Detected issue context, parent issue #" + parentIssueNumber); + bodyLines.push(`Related to #${parentIssueNumber}`); + } + const workflowName = process.env.GITHUB_AW_WORKFLOW_NAME || "Workflow"; + const runId = context.runId; + const runUrl = context.payload.repository + ? `${context.payload.repository.html_url}/actions/runs/${runId}` + : `https://github.com/actions/runs/${runId}`; + bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); + const body = bodyLines.join("\n").trim(); + core.info(`Creating issue with title: ${title}`); + core.info(`Labels: ${labels}`); + core.info(`Body length: ${body.length}`); try { - validatedOutput = JSON.parse(outputContent); - } - catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - return; - } - const createIssueItems = validatedOutput.items.filter(item => item.type === "create-issue"); - if (createIssueItems.length === 0) { - core.info("No create-issue items found in agent output"); - return; - } - core.info(`Found ${createIssueItems.length} create-issue item(s)`); - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Issues Preview\n\n"; - summaryContent += "The following issues would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createIssueItems.length; i++) { - const item = createIssueItems[i]; - summaryContent += `### Issue ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; - } - if (item.labels && item.labels.length > 0) { - summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; - } - summaryContent += "---\n\n"; - } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Issue creation preview written to step summary"); - return; - } - const parentIssueNumber = context.payload?.issue?.number; - const labelsEnv = process.env.GITHUB_AW_ISSUE_LABELS; - let envLabels = labelsEnv - ? labelsEnv - .split(",") - .map((label) => label.trim()) - .filter((label) => label) - : []; - const createdIssues = []; - for (let i = 0; i < createIssueItems.length; i++) { - const createIssueItem = createIssueItems[i]; - core.info(`Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}`); - let labels = [...envLabels]; - if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { - labels = [...labels, ...createIssueItem.labels]; - } - labels = labels - .filter(label => label != null && label !== false && label !== 0) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - let title = createIssueItem.title ? createIssueItem.title.trim() : ""; - let bodyLines = createIssueItem.body.split("\n"); - if (!title) { - title = createIssueItem.body || "Agent Output"; - } - const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - if (parentIssueNumber) { - core.info("Detected issue context, parent issue #" + parentIssueNumber); - bodyLines.push(`Related to #${parentIssueNumber}`); - } - const workflowName = process.env.GITHUB_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const runUrl = context.payload.repository - ? `${context.payload.repository.html_url}/actions/runs/${runId}` - : `https://github.com/actions/runs/${runId}`; - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating issue with title: ${title}`); - core.info(`Labels: ${labels}`); - core.info(`Body length: ${body.length}`); + const { data: issue } = await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: title, + body: body, + labels: labels, + }); + core.info("Created issue #" + issue.number + ": " + issue.html_url); + createdIssues.push(issue); + if (parentIssueNumber) { try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: body, - labels: labels, - }); - core.info("Created issue #" + issue.number + ": " + issue.html_url); - createdIssues.push(issue); - if (parentIssueNumber) { - try { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: parentIssueNumber, - body: `Created related issue: #${issue.number}`, - }); - core.info("Added comment to parent issue #" + parentIssueNumber); - } - catch (error) { - core.info(`Warning: Could not add comment to parent issue: ${error instanceof Error ? error.message : String(error)}`); - } - } - if (i === createIssueItems.length - 1) { - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - } - } - catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Issues has been disabled in this repository")) { - core.info(`⚠ Cannot create issue "${title}": Issues are disabled for this repository`); - core.info("Consider enabling issues in repository settings if you want to create issues automatically"); - continue; - } - core.error(`✗ Failed to create issue "${title}": ${errorMessage}`); - throw error; + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: parentIssueNumber, + body: `Created related issue: #${issue.number}`, + }); + core.info("Added comment to parent issue #" + parentIssueNumber); + } catch (error) { + core.info(`Warning: Could not add comment to parent issue: ${error instanceof Error ? error.message : String(error)}`); } + } + if (i === createIssueItems.length - 1) { + core.setOutput("issue_number", issue.number); + core.setOutput("issue_url", issue.html_url); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage.includes("Issues has been disabled in this repository")) { + core.info(`⚠ Cannot create issue "${title}": Issues are disabled for this repository`); + core.info("Consider enabling issues in repository settings if you want to create issues automatically"); + continue; + } + core.error(`✗ Failed to create issue "${title}": ${errorMessage}`); + throw error; } - if (createdIssues.length > 0) { - let summaryContent = "\n\n## GitHub Issues\n"; - for (const issue of createdIssues) { - summaryContent += `- Issue #${issue.number}: [${issue.title}](${issue.html_url})\n`; - } - await core.summary.addRaw(summaryContent).write(); + } + if (createdIssues.length > 0) { + let summaryContent = "\n\n## GitHub Issues\n"; + for (const issue of createdIssues) { + summaryContent += `- Issue #${issue.number}: [${issue.title}](${issue.html_url})\n`; } - core.info(`Successfully created ${createdIssues.length} issue(s)`); + await core.summary.addRaw(summaryContent).write(); + } + core.info(`Successfully created ${createdIssues.length} issue(s)`); } (async () => { - await main(); + await main(); })(); missing_tool: diff --git a/.github/workflows/go-pattern-detector.lock.yml b/.github/workflows/go-pattern-detector.lock.yml index 4888ccfc553..9ad685a59e6 100644 --- a/.github/workflows/go-pattern-detector.lock.yml +++ b/.github/workflows/go-pattern-detector.lock.yml @@ -1338,742 +1338,742 @@ jobs: with: script: | async function main() { - const fs = require("fs"); - function sanitizeContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; - const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; - const allowedDomains = allowedDomainsEnv - ? allowedDomainsEnv - .split(",") - .map(d => d.trim()) - .filter(d => d) - : defaultAllowedDomains; - let sanitized = content; - sanitized = neutralizeMentions(sanitized); - sanitized = removeXmlComments(sanitized); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitizeUrlProtocols(sanitized); - sanitized = sanitizeUrlDomains(sanitized); - const maxLength = 524288; - if (sanitized.length > maxLength) { - sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; - } - const lines = sanitized.split("\n"); - const maxLines = 65000; - if (lines.length > maxLines) { - sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; - } - sanitized = neutralizeBotTriggers(sanitized); - return sanitized.trim(); - function sanitizeUrlDomains(s) { - return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { - const urlAfterProtocol = match.slice(8); - const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); - const isAllowed = allowedDomains.some(allowedDomain => { - const normalizedAllowed = allowedDomain.toLowerCase(); - return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); - }); - return isAllowed ? match : "(redacted)"; - }); - } - function sanitizeUrlProtocols(s) { - return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { - return protocol.toLowerCase() === "https" ? match : "(redacted)"; - }); - } - function neutralizeMentions(s) { - return s.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - } - function removeXmlComments(s) { - return s.replace(//g, "").replace(//g, ""); - } - function neutralizeBotTriggers(s) { - return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); - } + const fs = require("fs"); + function sanitizeContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; + const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; + const allowedDomains = allowedDomainsEnv + ? allowedDomainsEnv + .split(",") + .map(d => d.trim()) + .filter(d => d) + : defaultAllowedDomains; + let sanitized = content; + sanitized = neutralizeMentions(sanitized); + sanitized = removeXmlComments(sanitized); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitizeUrlProtocols(sanitized); + sanitized = sanitizeUrlDomains(sanitized); + const maxLength = 524288; + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; + } + const lines = sanitized.split("\n"); + const maxLines = 65000; + if (lines.length > maxLines) { + sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; + } + sanitized = neutralizeBotTriggers(sanitized); + return sanitized.trim(); + function sanitizeUrlDomains(s) { + return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { + const urlAfterProtocol = match.slice(8); + const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); + const isAllowed = allowedDomains.some(allowedDomain => { + const normalizedAllowed = allowedDomain.toLowerCase(); + return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); + }); + return isAllowed ? match : "(redacted)"; + }); } - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create-issue": - return 1; - case "add-comment": - return 1; - case "create-pull-request": - return 1; - case "create-pull-request-review-comment": - return 1; - case "add-labels": - return 5; - case "update-issue": - return 1; - case "push-to-pull-request-branch": - return 1; - case "create-discussion": - return 1; - case "missing-tool": - return 1000; - case "create-code-scanning-alert": - return 1000; - case "upload-asset": - return 10; - default: - return 1; - } + function sanitizeUrlProtocols(s) { + return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { + return protocol.toLowerCase() === "https" ? match : "(redacted)"; + }); } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; - } - return 0; - } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } - else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } - else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; - } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; - } - return { isValid: true, normalizedValue: parsed }; + function neutralizeMentions(s) { + return s.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; - } - return { isValid: true, normalizedValue: parsed }; + function removeXmlComments(s) { + return s.replace(//g, "").replace(//g, ""); } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - return { isValid: true }; - } - function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { - if (inputSchema.required && (value === undefined || value === null)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; - } - if (value === undefined || value === null) { - return { - isValid: true, - normalizedValue: inputSchema.default || undefined, - }; - } - const inputType = inputSchema.type || "string"; - let normalizedValue = value; - switch (inputType) { - case "string": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string`, - }; - } - normalizedValue = sanitizeContent(value); - break; - case "boolean": - if (typeof value !== "boolean") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a boolean`, - }; - } - break; - case "number": - if (typeof value !== "number") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number`, - }; - } - break; - case "choice": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, - }; - } - if (inputSchema.options && !inputSchema.options.includes(value)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, - }; - } - normalizedValue = sanitizeContent(value); - break; - default: - if (typeof value === "string") { - normalizedValue = sanitizeContent(value); - } - break; - } + function neutralizeBotTriggers(s) { + return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); + } + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + switch (itemType) { + case "create-issue": + return 1; + case "add-comment": + return 1; + case "create-pull-request": + return 1; + case "create-pull-request-review-comment": + return 1; + case "add-labels": + return 5; + case "update-issue": + return 1; + case "push-to-pull-request-branch": + return 1; + case "create-discussion": + return 1; + case "missing-tool": + return 1000; + case "create-code-scanning-alert": + return 1000; + case "upload-asset": + return 10; + default: + return 1; + } + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; + } + return 0; + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { return { - isValid: true, - normalizedValue, + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; } - function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { - const errors = []; - const normalizedItem = { ...item }; - if (!jobConfig.inputs) { - return { - isValid: true, - errors: [], - normalizedItem: item, - }; - } - for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { - const fieldValue = item[fieldName]; - const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); - if (!validation.isValid && validation.error) { - errors.push(validation.error); - } - else if (validation.normalizedValue !== undefined) { - normalizedItem[fieldName] = validation.normalizedValue; - } - } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-code-scanning-alert 'line'")) { return { - isValid: errors.length === 0, - errors, - normalizedItem, + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; } - function parseJsonWithRepair(jsonStr) { - try { - return JSON.parse(jsonStr); - } - catch (originalError) { - try { - const repairedJson = repairJson(jsonStr); - return JSON.parse(repairedJson); - } - catch (repairError) { - core.info(`invalid input json: ${jsonStr}`); - const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); - const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); - throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); - } - } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; } - const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; - const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; - if (!outputFile) { - core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); - core.setOutput("output", ""); - return; + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; } - if (!fs.existsSync(outputFile)) { - core.info(`Output file does not exist: ${outputFile}`); - core.setOutput("output", ""); - return; + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; } - const outputContent = fs.readFileSync(outputFile, "utf8"); - if (outputContent.trim() === "") { - core.info("Output file is empty"); + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; } - core.info(`Raw output content length: ${outputContent.length}`); - let expectedOutputTypes = {}; - if (safeOutputsConfig) { - try { - expectedOutputTypes = JSON.parse(safeOutputsConfig); - core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { + if (inputSchema.required && (value === undefined || value === null)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (value === undefined || value === null) { + return { + isValid: true, + normalizedValue: inputSchema.default || undefined, + }; + } + const inputType = inputSchema.type || "string"; + let normalizedValue = value; + switch (inputType) { + case "string": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string`, + }; + } + normalizedValue = sanitizeContent(value); + break; + case "boolean": + if (typeof value !== "boolean") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a boolean`, + }; + } + break; + case "number": + if (typeof value !== "number") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number`, + }; + } + break; + case "choice": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, + }; } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); + if (inputSchema.options && !inputSchema.options.includes(value)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, + }; + } + normalizedValue = sanitizeContent(value); + break; + default: + if (typeof value === "string") { + normalizedValue = sanitizeContent(value); } + break; } - const lines = outputContent.trim().split("\n"); - const parsedItems = []; + return { + isValid: true, + normalizedValue, + }; + } + function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { const errors = []; - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); - if (line === "") + const normalizedItem = { ...item }; + if (!jobConfig.inputs) { + return { + isValid: true, + errors: [], + normalizedItem: item, + }; + } + for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { + const fieldValue = item[fieldName]; + const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); + if (!validation.isValid && validation.error) { + errors.push(validation.error); + } else if (validation.normalizedValue !== undefined) { + normalizedItem[fieldName] = validation.normalizedValue; + } + } + return { + isValid: errors.length === 0, + errors, + normalizedItem, + }; + } + function parseJsonWithRepair(jsonStr) { + try { + return JSON.parse(jsonStr); + } catch (originalError) { + try { + const repairedJson = repairJson(jsonStr); + return JSON.parse(repairedJson); + } catch (repairError) { + core.info(`invalid input json: ${jsonStr}`); + const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); + const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); + throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); + } + } + } + const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; + const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; + if (!outputFile) { + core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); + core.setOutput("output", ""); + return; + } + if (!fs.existsSync(outputFile)) { + core.info(`Output file does not exist: ${outputFile}`); + core.setOutput("output", ""); + return; + } + const outputContent = fs.readFileSync(outputFile, "utf8"); + if (outputContent.trim() === "") { + core.info("Output file is empty"); + } + core.info(`Raw output content length: ${outputContent.length}`); + let expectedOutputTypes = {}; + if (safeOutputsConfig) { + try { + expectedOutputTypes = JSON.parse(safeOutputsConfig); + core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); + } + } + const lines = outputContent.trim().split("\n"); + const parsedItems = []; + const errors = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line === "") continue; + try { + const item = parseJsonWithRepair(line); + if (item === undefined) { + errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); + continue; + } + if (!item.type) { + errors.push(`Line ${i + 1}: Missing required 'type' field`); + continue; + } + const itemType = item.type; + if (!expectedOutputTypes[itemType]) { + errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); + continue; + } + const typeCount = parsedItems.filter(existing => existing.type === itemType).length; + const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); + if (typeCount >= maxAllowed) { + errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); + continue; + } + core.info(`Line ${i + 1}: type '${itemType}'`); + switch (itemType) { + case "create-issue": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); continue; - try { - const item = parseJsonWithRepair(line); - if (item === undefined) { - errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); - continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-comment": + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); + continue; + } + const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); + if (!issueNumValidation.isValid) { + if (issueNumValidation.error) errors.push(issueNumValidation.error); + continue; + } + item.body = sanitizeContent(item.body); + break; + case "create-pull-request": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); + continue; + } + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + item.branch = sanitizeContent(item.branch); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-labels": + if (!item.labels || !Array.isArray(item.labels)) { + errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); + continue; + } + if (item.labels.some(label => typeof label !== "string")) { + errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); + continue; + } + const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); + if (!labelsIssueNumValidation.isValid) { + if (labelsIssueNumValidation.error) errors.push(labelsIssueNumValidation.error); + continue; + } + item.labels = item.labels.map(label => sanitizeContent(label)); + break; + case "update-issue": + const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; + if (!hasValidField) { + errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); + continue; + } + if (item.status !== undefined) { + if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { + errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); + continue; } - if (!item.type) { - errors.push(`Line ${i + 1}: Missing required 'type' field`); - continue; + } + if (item.title !== undefined) { + if (typeof item.title !== "string") { + errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); + continue; } - const itemType = item.type; - if (!expectedOutputTypes[itemType]) { - errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); - continue; + item.title = sanitizeContent(item.title); + } + if (item.body !== undefined) { + if (typeof item.body !== "string") { + errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); + continue; } - const typeCount = parsedItems.filter(existing => existing.type === itemType).length; - const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); - if (typeCount >= maxAllowed) { - errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); - continue; + item.body = sanitizeContent(item.body); + } + const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); + if (!updateIssueNumValidation.isValid) { + if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); + continue; + } + break; + case "push-to-pull-request-branch": + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); + continue; + } + item.branch = sanitizeContent(item.branch); + item.message = sanitizeContent(item.message); + const pushPRNumValidation = validateIssueOrPRNumber( + item.pull_request_number, + "push-to-pull-request-branch 'pull_request_number'", + i + 1 + ); + if (!pushPRNumValidation.isValid) { + if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); + continue; + } + break; + case "create-pull-request-review-comment": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); + continue; + } + const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); + if (!lineValidation.isValid) { + if (lineValidation.error) errors.push(lineValidation.error); + continue; + } + const lineNumber = lineValidation.normalizedValue; + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); + continue; + } + item.body = sanitizeContent(item.body); + const startLineValidation = validateOptionalPositiveInteger( + item.start_line, + "create-pull-request-review-comment 'start_line'", + i + 1 + ); + if (!startLineValidation.isValid) { + if (startLineValidation.error) errors.push(startLineValidation.error); + continue; + } + if ( + startLineValidation.normalizedValue !== undefined && + lineNumber !== undefined && + startLineValidation.normalizedValue > lineNumber + ) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); + continue; + } + if (item.side !== undefined) { + if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); + continue; } - core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create-issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); - if (!issueNumValidation.isValid) { - if (issueNumValidation.error) - errors.push(issueNumValidation.error); - continue; - } - item.body = sanitizeContent(item.body); - break; - case "create-pull-request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - item.branch = sanitizeContent(item.branch); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some((label) => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); - if (!labelsIssueNumValidation.isValid) { - if (labelsIssueNumValidation.error) - errors.push(labelsIssueNumValidation.error); - continue; - } - item.labels = item.labels.map((label) => sanitizeContent(label)); - break; - case "update-issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) - errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "push-to-pull-request-branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch); - item.message = sanitizeContent(item.message); - const pushPRNumValidation = validateIssueOrPRNumber(item.pull_request_number, "push-to-pull-request-branch 'pull_request_number'", i + 1); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) - errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create-pull-request-review-comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) - errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body); - const startLineValidation = validateOptionalPositiveInteger(item.start_line, "create-pull-request-review-comment 'start_line'", i + 1); - if (!startLineValidation.isValid) { - if (startLineValidation.error) - errors.push(startLineValidation.error); - continue; - } - if (startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create-discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category); - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - break; - case "missing-tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool); - item.reason = sanitizeContent(item.reason); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives); - } - break; - case "upload-asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "create-code-scanning-alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}`); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) - errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores`); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file); - item.severity = sanitizeContent(item.severity); - item.message = sanitizeContent(item.message); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); - continue; - } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + } + break; + case "create-discussion": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); + continue; + } + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); + continue; } - core.info(`Line ${i + 1}: Valid ${itemType} item`); - parsedItems.push(item); - } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); - } - } - if (errors.length > 0) { - core.warning("Validation errors found:"); - errors.forEach(error => core.warning(` - ${error}`)); - if (parsedItems.length === 0) { - core.setFailed(errors.map(e => ` - ${e}`).join("\n")); - return; - } - } - for (const itemType of Object.keys(expectedOutputTypes)) { - const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); - if (minRequired > 0) { - const actualCount = parsedItems.filter(item => item.type === itemType).length; - if (actualCount < minRequired) { - errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); + item.category = sanitizeContent(item.category); + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + break; + case "missing-tool": + if (!item.tool || typeof item.tool !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); + continue; + } + if (!item.reason || typeof item.reason !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); + continue; + } + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); + continue; } - } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "upload-asset": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); + continue; + } + break; + case "create-code-scanning-alert": + if (!item.file || typeof item.file !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); + continue; + } + const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); + if (!alertLineValidation.isValid) { + if (alertLineValidation.error) { + errors.push(alertLineValidation.error); + } + continue; + } + if (!item.severity || typeof item.severity !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); + continue; + } + const allowedSeverities = ["error", "warning", "info", "note"]; + if (!allowedSeverities.includes(item.severity.toLowerCase())) { + errors.push( + `Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` + ); + continue; + } + const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); + if (!columnValidation.isValid) { + if (columnValidation.error) errors.push(columnValidation.error); + continue; + } + if (item.ruleIdSuffix !== undefined) { + if (typeof item.ruleIdSuffix !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); + continue; + } + if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { + errors.push( + `Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` + ); + continue; + } + } + item.severity = item.severity.toLowerCase(); + item.file = sanitizeContent(item.file); + item.severity = sanitizeContent(item.severity); + item.message = sanitizeContent(item.message); + if (item.ruleIdSuffix) { + item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); + } + break; + default: + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); + continue; + } + Object.assign(item, validation.normalizedItem); + } + break; + } + core.info(`Line ${i + 1}: Valid ${itemType} item`); + parsedItems.push(item); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); } - core.info(`Successfully parsed ${parsedItems.length} valid output items`); - const validatedOutput = { - items: parsedItems, - errors: errors, - }; - const agentOutputFile = "/tmp/agent_output.json"; - const validatedOutputJson = JSON.stringify(validatedOutput); - try { - fs.mkdirSync("/tmp", { recursive: true }); - fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); - core.info(`Stored validated output to: ${agentOutputFile}`); - core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); - } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.error(`Failed to write agent output file: ${errorMsg}`); - } - core.setOutput("output", JSON.stringify(validatedOutput)); - core.setOutput("raw_output", outputContent); - const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); - core.info(`output_types: ${outputTypes.join(", ")}`); - core.setOutput("output_types", outputTypes.join(",")); - try { - await core.summary - .addRaw("## Processed Output\n\n") - .addRaw("```json\n") - .addRaw(JSON.stringify(validatedOutput)) - .addRaw("\n```\n") - .write(); - core.info("Successfully wrote processed output to step summary"); - } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.warning(`Failed to write to step summary: ${errorMsg}`); + } + if (errors.length > 0) { + core.warning("Validation errors found:"); + errors.forEach(error => core.warning(` - ${error}`)); + if (parsedItems.length === 0) { + core.setFailed(errors.map(e => ` - ${e}`).join("\n")); + return; } + } + for (const itemType of Object.keys(expectedOutputTypes)) { + const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); + if (minRequired > 0) { + const actualCount = parsedItems.filter(item => item.type === itemType).length; + if (actualCount < minRequired) { + errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); + } + } + } + core.info(`Successfully parsed ${parsedItems.length} valid output items`); + const validatedOutput = { + items: parsedItems, + errors: errors, + }; + const agentOutputFile = "/tmp/agent_output.json"; + const validatedOutputJson = JSON.stringify(validatedOutput); + try { + fs.mkdirSync("/tmp", { recursive: true }); + fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); + core.info(`Stored validated output to: ${agentOutputFile}`); + core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.error(`Failed to write agent output file: ${errorMsg}`); + } + core.setOutput("output", JSON.stringify(validatedOutput)); + core.setOutput("raw_output", outputContent); + const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); + core.info(`output_types: ${outputTypes.join(", ")}`); + core.setOutput("output_types", outputTypes.join(",")); + try { + await core.summary + .addRaw("## Processed Output\n\n") + .addRaw("```json\n") + .addRaw(JSON.stringify(validatedOutput)) + .addRaw("\n```\n") + .write(); + core.info("Successfully wrote processed output to step summary"); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.warning(`Failed to write to step summary: ${errorMsg}`); + } } await main(); - name: Upload sanitized agent output @@ -2809,163 +2809,165 @@ jobs: with: script: | function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); + if (!content || typeof content !== "string") { + return ""; + } + let sanitized = content.trim(); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); + sanitized = sanitized.replace(/[<>&'"]/g, ""); + return sanitized.trim(); } async function main() { - const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; - const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; - if (!outputContent) { - core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); - return; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return; + const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; + const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; + if (!outputContent) { + core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); + return; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); + return; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + return; + } + const createIssueItems = validatedOutput.items.filter(item => item.type === "create-issue"); + if (createIssueItems.length === 0) { + core.info("No create-issue items found in agent output"); + return; + } + core.info(`Found ${createIssueItems.length} create-issue item(s)`); + if (isStaged) { + let summaryContent = "## 🎭 Staged Mode: Create Issues Preview\n\n"; + summaryContent += "The following issues would be created if staged mode was disabled:\n\n"; + for (let i = 0; i < createIssueItems.length; i++) { + const item = createIssueItems[i]; + summaryContent += `### Issue ${i + 1}\n`; + summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; + if (item.body) { + summaryContent += `**Body:**\n${item.body}\n\n`; + } + if (item.labels && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Issue creation preview written to step summary"); + return; + } + const parentIssueNumber = context.payload?.issue?.number; + const labelsEnv = process.env.GITHUB_AW_ISSUE_LABELS; + let envLabels = labelsEnv + ? labelsEnv + .split(",") + .map(label => label.trim()) + .filter(label => label) + : []; + const createdIssues = []; + for (let i = 0; i < createIssueItems.length; i++) { + const createIssueItem = createIssueItems[i]; + core.info( + `Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}` + ); + let labels = [...envLabels]; + if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { + labels = [...labels, ...createIssueItem.labels]; + } + labels = labels + .filter(label => label != null && label !== false && label !== 0) + .map(label => String(label).trim()) + .filter(label => label) + .map(label => sanitizeLabelContent(label)) + .filter(label => label) + .map(label => (label.length > 64 ? label.substring(0, 64) : label)) + .filter((label, index, arr) => arr.indexOf(label) === index); + let title = createIssueItem.title ? createIssueItem.title.trim() : ""; + let bodyLines = createIssueItem.body.split("\n"); + if (!title) { + title = createIssueItem.body || "Agent Output"; + } + const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; + if (titlePrefix && !title.startsWith(titlePrefix)) { + title = titlePrefix + title; + } + if (parentIssueNumber) { + core.info("Detected issue context, parent issue #" + parentIssueNumber); + bodyLines.push(`Related to #${parentIssueNumber}`); + } + const workflowName = process.env.GITHUB_AW_WORKFLOW_NAME || "Workflow"; + const runId = context.runId; + const runUrl = context.payload.repository + ? `${context.payload.repository.html_url}/actions/runs/${runId}` + : `https://github.com/actions/runs/${runId}`; + bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); + const body = bodyLines.join("\n").trim(); + core.info(`Creating issue with title: ${title}`); + core.info(`Labels: ${labels}`); + core.info(`Body length: ${body.length}`); try { - validatedOutput = JSON.parse(outputContent); - } - catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - return; - } - const createIssueItems = validatedOutput.items.filter(item => item.type === "create-issue"); - if (createIssueItems.length === 0) { - core.info("No create-issue items found in agent output"); - return; - } - core.info(`Found ${createIssueItems.length} create-issue item(s)`); - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Issues Preview\n\n"; - summaryContent += "The following issues would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createIssueItems.length; i++) { - const item = createIssueItems[i]; - summaryContent += `### Issue ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; - } - if (item.labels && item.labels.length > 0) { - summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; - } - summaryContent += "---\n\n"; - } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Issue creation preview written to step summary"); - return; - } - const parentIssueNumber = context.payload?.issue?.number; - const labelsEnv = process.env.GITHUB_AW_ISSUE_LABELS; - let envLabels = labelsEnv - ? labelsEnv - .split(",") - .map((label) => label.trim()) - .filter((label) => label) - : []; - const createdIssues = []; - for (let i = 0; i < createIssueItems.length; i++) { - const createIssueItem = createIssueItems[i]; - core.info(`Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}`); - let labels = [...envLabels]; - if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { - labels = [...labels, ...createIssueItem.labels]; - } - labels = labels - .filter(label => label != null && label !== false && label !== 0) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - let title = createIssueItem.title ? createIssueItem.title.trim() : ""; - let bodyLines = createIssueItem.body.split("\n"); - if (!title) { - title = createIssueItem.body || "Agent Output"; - } - const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - if (parentIssueNumber) { - core.info("Detected issue context, parent issue #" + parentIssueNumber); - bodyLines.push(`Related to #${parentIssueNumber}`); - } - const workflowName = process.env.GITHUB_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const runUrl = context.payload.repository - ? `${context.payload.repository.html_url}/actions/runs/${runId}` - : `https://github.com/actions/runs/${runId}`; - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating issue with title: ${title}`); - core.info(`Labels: ${labels}`); - core.info(`Body length: ${body.length}`); + const { data: issue } = await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: title, + body: body, + labels: labels, + }); + core.info("Created issue #" + issue.number + ": " + issue.html_url); + createdIssues.push(issue); + if (parentIssueNumber) { try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: body, - labels: labels, - }); - core.info("Created issue #" + issue.number + ": " + issue.html_url); - createdIssues.push(issue); - if (parentIssueNumber) { - try { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: parentIssueNumber, - body: `Created related issue: #${issue.number}`, - }); - core.info("Added comment to parent issue #" + parentIssueNumber); - } - catch (error) { - core.info(`Warning: Could not add comment to parent issue: ${error instanceof Error ? error.message : String(error)}`); - } - } - if (i === createIssueItems.length - 1) { - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - } - } - catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Issues has been disabled in this repository")) { - core.info(`⚠ Cannot create issue "${title}": Issues are disabled for this repository`); - core.info("Consider enabling issues in repository settings if you want to create issues automatically"); - continue; - } - core.error(`✗ Failed to create issue "${title}": ${errorMessage}`); - throw error; + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: parentIssueNumber, + body: `Created related issue: #${issue.number}`, + }); + core.info("Added comment to parent issue #" + parentIssueNumber); + } catch (error) { + core.info(`Warning: Could not add comment to parent issue: ${error instanceof Error ? error.message : String(error)}`); } + } + if (i === createIssueItems.length - 1) { + core.setOutput("issue_number", issue.number); + core.setOutput("issue_url", issue.html_url); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage.includes("Issues has been disabled in this repository")) { + core.info(`⚠ Cannot create issue "${title}": Issues are disabled for this repository`); + core.info("Consider enabling issues in repository settings if you want to create issues automatically"); + continue; + } + core.error(`✗ Failed to create issue "${title}": ${errorMessage}`); + throw error; } - if (createdIssues.length > 0) { - let summaryContent = "\n\n## GitHub Issues\n"; - for (const issue of createdIssues) { - summaryContent += `- Issue #${issue.number}: [${issue.title}](${issue.html_url})\n`; - } - await core.summary.addRaw(summaryContent).write(); + } + if (createdIssues.length > 0) { + let summaryContent = "\n\n## GitHub Issues\n"; + for (const issue of createdIssues) { + summaryContent += `- Issue #${issue.number}: [${issue.title}](${issue.html_url})\n`; } - core.info(`Successfully created ${createdIssues.length} issue(s)`); + await core.summary.addRaw(summaryContent).write(); + } + core.info(`Successfully created ${createdIssues.length} issue(s)`); } (async () => { - await main(); + await main(); })(); missing_tool: diff --git a/.github/workflows/issue-classifier.lock.yml b/.github/workflows/issue-classifier.lock.yml index 769d16a0fd7..57d25d154f0 100644 --- a/.github/workflows/issue-classifier.lock.yml +++ b/.github/workflows/issue-classifier.lock.yml @@ -1310,742 +1310,742 @@ jobs: with: script: | async function main() { - const fs = require("fs"); - function sanitizeContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; - const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; - const allowedDomains = allowedDomainsEnv - ? allowedDomainsEnv - .split(",") - .map(d => d.trim()) - .filter(d => d) - : defaultAllowedDomains; - let sanitized = content; - sanitized = neutralizeMentions(sanitized); - sanitized = removeXmlComments(sanitized); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitizeUrlProtocols(sanitized); - sanitized = sanitizeUrlDomains(sanitized); - const maxLength = 524288; - if (sanitized.length > maxLength) { - sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; - } - const lines = sanitized.split("\n"); - const maxLines = 65000; - if (lines.length > maxLines) { - sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; - } - sanitized = neutralizeBotTriggers(sanitized); - return sanitized.trim(); - function sanitizeUrlDomains(s) { - return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { - const urlAfterProtocol = match.slice(8); - const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); - const isAllowed = allowedDomains.some(allowedDomain => { - const normalizedAllowed = allowedDomain.toLowerCase(); - return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); - }); - return isAllowed ? match : "(redacted)"; - }); - } - function sanitizeUrlProtocols(s) { - return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { - return protocol.toLowerCase() === "https" ? match : "(redacted)"; - }); - } - function neutralizeMentions(s) { - return s.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - } - function removeXmlComments(s) { - return s.replace(//g, "").replace(//g, ""); - } - function neutralizeBotTriggers(s) { - return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); - } + const fs = require("fs"); + function sanitizeContent(content) { + if (!content || typeof content !== "string") { + return ""; } - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create-issue": - return 1; - case "add-comment": - return 1; - case "create-pull-request": - return 1; - case "create-pull-request-review-comment": - return 1; - case "add-labels": - return 5; - case "update-issue": - return 1; - case "push-to-pull-request-branch": - return 1; - case "create-discussion": - return 1; - case "missing-tool": - return 1000; - case "create-code-scanning-alert": - return 1000; - case "upload-asset": - return 10; - default: - return 1; - } + const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; + const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; + const allowedDomains = allowedDomainsEnv + ? allowedDomainsEnv + .split(",") + .map(d => d.trim()) + .filter(d => d) + : defaultAllowedDomains; + let sanitized = content; + sanitized = neutralizeMentions(sanitized); + sanitized = removeXmlComments(sanitized); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitizeUrlProtocols(sanitized); + sanitized = sanitizeUrlDomains(sanitized); + const maxLength = 524288; + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; - } - return 0; + const lines = sanitized.split("\n"); + const maxLines = 65000; + if (lines.length > maxLines) { + sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; + sanitized = neutralizeBotTriggers(sanitized); + return sanitized.trim(); + function sanitizeUrlDomains(s) { + return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { + const urlAfterProtocol = match.slice(8); + const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); + const isAllowed = allowedDomains.some(allowedDomain => { + const normalizedAllowed = allowedDomain.toLowerCase(); + return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } - else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } - else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + return isAllowed ? match : "(redacted)"; + }); } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; - } - return { isValid: true, normalizedValue: parsed }; + function sanitizeUrlProtocols(s) { + return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { + return protocol.toLowerCase() === "https" ? match : "(redacted)"; + }); } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; - } - return { isValid: true, normalizedValue: parsed }; + function neutralizeMentions(s) { + return s.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - return { isValid: true }; + function removeXmlComments(s) { + return s.replace(//g, "").replace(//g, ""); } - function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { - if (inputSchema.required && (value === undefined || value === null)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; - } - if (value === undefined || value === null) { - return { - isValid: true, - normalizedValue: inputSchema.default || undefined, - }; - } - const inputType = inputSchema.type || "string"; - let normalizedValue = value; - switch (inputType) { - case "string": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string`, - }; - } - normalizedValue = sanitizeContent(value); - break; - case "boolean": - if (typeof value !== "boolean") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a boolean`, - }; - } - break; - case "number": - if (typeof value !== "number") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number`, - }; - } - break; - case "choice": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, - }; - } - if (inputSchema.options && !inputSchema.options.includes(value)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, - }; - } - normalizedValue = sanitizeContent(value); - break; - default: - if (typeof value === "string") { - normalizedValue = sanitizeContent(value); - } - break; - } + function neutralizeBotTriggers(s) { + return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); + } + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + switch (itemType) { + case "create-issue": + return 1; + case "add-comment": + return 1; + case "create-pull-request": + return 1; + case "create-pull-request-review-comment": + return 1; + case "add-labels": + return 5; + case "update-issue": + return 1; + case "push-to-pull-request-branch": + return 1; + case "create-discussion": + return 1; + case "missing-tool": + return 1000; + case "create-code-scanning-alert": + return 1000; + case "upload-asset": + return 10; + default: + return 1; + } + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; + } + return 0; + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { return { - isValid: true, - normalizedValue, + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; } - function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { - const errors = []; - const normalizedItem = { ...item }; - if (!jobConfig.inputs) { - return { - isValid: true, - errors: [], - normalizedItem: item, - }; - } - for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { - const fieldValue = item[fieldName]; - const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); - if (!validation.isValid && validation.error) { - errors.push(validation.error); - } - else if (validation.normalizedValue !== undefined) { - normalizedItem[fieldName] = validation.normalizedValue; - } - } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-code-scanning-alert 'line'")) { return { - isValid: errors.length === 0, - errors, - normalizedItem, + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; } - function parseJsonWithRepair(jsonStr) { - try { - return JSON.parse(jsonStr); - } - catch (originalError) { - try { - const repairedJson = repairJson(jsonStr); - return JSON.parse(repairedJson); - } - catch (repairError) { - core.info(`invalid input json: ${jsonStr}`); - const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); - const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); - throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); - } - } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; } - const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; - const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; - if (!outputFile) { - core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); - core.setOutput("output", ""); - return; + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; } - if (!fs.existsSync(outputFile)) { - core.info(`Output file does not exist: ${outputFile}`); - core.setOutput("output", ""); - return; + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; } - const outputContent = fs.readFileSync(outputFile, "utf8"); - if (outputContent.trim() === "") { - core.info("Output file is empty"); + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; } - core.info(`Raw output content length: ${outputContent.length}`); - let expectedOutputTypes = {}; - if (safeOutputsConfig) { - try { - expectedOutputTypes = JSON.parse(safeOutputsConfig); - core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { + if (inputSchema.required && (value === undefined || value === null)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (value === undefined || value === null) { + return { + isValid: true, + normalizedValue: inputSchema.default || undefined, + }; + } + const inputType = inputSchema.type || "string"; + let normalizedValue = value; + switch (inputType) { + case "string": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string`, + }; } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); + normalizedValue = sanitizeContent(value); + break; + case "boolean": + if (typeof value !== "boolean") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a boolean`, + }; } - } - const lines = outputContent.trim().split("\n"); - const parsedItems = []; - const errors = []; - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); - if (line === "") - continue; - try { - const item = parseJsonWithRepair(line); - if (item === undefined) { - errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); - continue; - } - if (!item.type) { - errors.push(`Line ${i + 1}: Missing required 'type' field`); - continue; - } - const itemType = item.type; - if (!expectedOutputTypes[itemType]) { - errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); - continue; - } - const typeCount = parsedItems.filter(existing => existing.type === itemType).length; - const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); - if (typeCount >= maxAllowed) { - errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); - continue; - } - core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create-issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); - if (!issueNumValidation.isValid) { - if (issueNumValidation.error) - errors.push(issueNumValidation.error); - continue; - } - item.body = sanitizeContent(item.body); - break; - case "create-pull-request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - item.branch = sanitizeContent(item.branch); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some((label) => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); - if (!labelsIssueNumValidation.isValid) { - if (labelsIssueNumValidation.error) - errors.push(labelsIssueNumValidation.error); - continue; - } - item.labels = item.labels.map((label) => sanitizeContent(label)); - break; - case "update-issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) - errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "push-to-pull-request-branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch); - item.message = sanitizeContent(item.message); - const pushPRNumValidation = validateIssueOrPRNumber(item.pull_request_number, "push-to-pull-request-branch 'pull_request_number'", i + 1); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) - errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create-pull-request-review-comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) - errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body); - const startLineValidation = validateOptionalPositiveInteger(item.start_line, "create-pull-request-review-comment 'start_line'", i + 1); - if (!startLineValidation.isValid) { - if (startLineValidation.error) - errors.push(startLineValidation.error); - continue; - } - if (startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create-discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category); - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - break; - case "missing-tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool); - item.reason = sanitizeContent(item.reason); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives); - } - break; - case "upload-asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "create-code-scanning-alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}`); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) - errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores`); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file); - item.severity = sanitizeContent(item.severity); - item.message = sanitizeContent(item.message); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); - continue; - } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; - } - core.info(`Line ${i + 1}: Valid ${itemType} item`); - parsedItems.push(item); + break; + case "number": + if (typeof value !== "number") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number`, + }; } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); + break; + case "choice": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, + }; } - } - if (errors.length > 0) { - core.warning("Validation errors found:"); - errors.forEach(error => core.warning(` - ${error}`)); - if (parsedItems.length === 0) { - core.setFailed(errors.map(e => ` - ${e}`).join("\n")); - return; + if (inputSchema.options && !inputSchema.options.includes(value)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, + }; } - } - for (const itemType of Object.keys(expectedOutputTypes)) { - const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); - if (minRequired > 0) { - const actualCount = parsedItems.filter(item => item.type === itemType).length; - if (actualCount < minRequired) { - errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); - } + normalizedValue = sanitizeContent(value); + break; + default: + if (typeof value === "string") { + normalizedValue = sanitizeContent(value); } + break; } - core.info(`Successfully parsed ${parsedItems.length} valid output items`); - const validatedOutput = { - items: parsedItems, - errors: errors, + return { + isValid: true, + normalizedValue, }; - const agentOutputFile = "/tmp/agent_output.json"; - const validatedOutputJson = JSON.stringify(validatedOutput); + } + function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { + const errors = []; + const normalizedItem = { ...item }; + if (!jobConfig.inputs) { + return { + isValid: true, + errors: [], + normalizedItem: item, + }; + } + for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { + const fieldValue = item[fieldName]; + const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); + if (!validation.isValid && validation.error) { + errors.push(validation.error); + } else if (validation.normalizedValue !== undefined) { + normalizedItem[fieldName] = validation.normalizedValue; + } + } + return { + isValid: errors.length === 0, + errors, + normalizedItem, + }; + } + function parseJsonWithRepair(jsonStr) { try { - fs.mkdirSync("/tmp", { recursive: true }); - fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); - core.info(`Stored validated output to: ${agentOutputFile}`); - core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); + return JSON.parse(jsonStr); + } catch (originalError) { + try { + const repairedJson = repairJson(jsonStr); + return JSON.parse(repairedJson); + } catch (repairError) { + core.info(`invalid input json: ${jsonStr}`); + const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); + const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); + throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); + } } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.error(`Failed to write agent output file: ${errorMsg}`); + } + const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; + const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; + if (!outputFile) { + core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); + core.setOutput("output", ""); + return; + } + if (!fs.existsSync(outputFile)) { + core.info(`Output file does not exist: ${outputFile}`); + core.setOutput("output", ""); + return; + } + const outputContent = fs.readFileSync(outputFile, "utf8"); + if (outputContent.trim() === "") { + core.info("Output file is empty"); + } + core.info(`Raw output content length: ${outputContent.length}`); + let expectedOutputTypes = {}; + if (safeOutputsConfig) { + try { + expectedOutputTypes = JSON.parse(safeOutputsConfig); + core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); } - core.setOutput("output", JSON.stringify(validatedOutput)); - core.setOutput("raw_output", outputContent); - const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); - core.info(`output_types: ${outputTypes.join(", ")}`); - core.setOutput("output_types", outputTypes.join(",")); + } + const lines = outputContent.trim().split("\n"); + const parsedItems = []; + const errors = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line === "") continue; try { - await core.summary - .addRaw("## Processed Output\n\n") - .addRaw("```json\n") - .addRaw(JSON.stringify(validatedOutput)) - .addRaw("\n```\n") - .write(); - core.info("Successfully wrote processed output to step summary"); + const item = parseJsonWithRepair(line); + if (item === undefined) { + errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); + continue; + } + if (!item.type) { + errors.push(`Line ${i + 1}: Missing required 'type' field`); + continue; + } + const itemType = item.type; + if (!expectedOutputTypes[itemType]) { + errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); + continue; + } + const typeCount = parsedItems.filter(existing => existing.type === itemType).length; + const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); + if (typeCount >= maxAllowed) { + errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); + continue; + } + core.info(`Line ${i + 1}: type '${itemType}'`); + switch (itemType) { + case "create-issue": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-comment": + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); + continue; + } + const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); + if (!issueNumValidation.isValid) { + if (issueNumValidation.error) errors.push(issueNumValidation.error); + continue; + } + item.body = sanitizeContent(item.body); + break; + case "create-pull-request": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); + continue; + } + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + item.branch = sanitizeContent(item.branch); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-labels": + if (!item.labels || !Array.isArray(item.labels)) { + errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); + continue; + } + if (item.labels.some(label => typeof label !== "string")) { + errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); + continue; + } + const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); + if (!labelsIssueNumValidation.isValid) { + if (labelsIssueNumValidation.error) errors.push(labelsIssueNumValidation.error); + continue; + } + item.labels = item.labels.map(label => sanitizeContent(label)); + break; + case "update-issue": + const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; + if (!hasValidField) { + errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); + continue; + } + if (item.status !== undefined) { + if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { + errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); + continue; + } + } + if (item.title !== undefined) { + if (typeof item.title !== "string") { + errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); + continue; + } + item.title = sanitizeContent(item.title); + } + if (item.body !== undefined) { + if (typeof item.body !== "string") { + errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); + continue; + } + item.body = sanitizeContent(item.body); + } + const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); + if (!updateIssueNumValidation.isValid) { + if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); + continue; + } + break; + case "push-to-pull-request-branch": + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); + continue; + } + item.branch = sanitizeContent(item.branch); + item.message = sanitizeContent(item.message); + const pushPRNumValidation = validateIssueOrPRNumber( + item.pull_request_number, + "push-to-pull-request-branch 'pull_request_number'", + i + 1 + ); + if (!pushPRNumValidation.isValid) { + if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); + continue; + } + break; + case "create-pull-request-review-comment": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); + continue; + } + const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); + if (!lineValidation.isValid) { + if (lineValidation.error) errors.push(lineValidation.error); + continue; + } + const lineNumber = lineValidation.normalizedValue; + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); + continue; + } + item.body = sanitizeContent(item.body); + const startLineValidation = validateOptionalPositiveInteger( + item.start_line, + "create-pull-request-review-comment 'start_line'", + i + 1 + ); + if (!startLineValidation.isValid) { + if (startLineValidation.error) errors.push(startLineValidation.error); + continue; + } + if ( + startLineValidation.normalizedValue !== undefined && + lineNumber !== undefined && + startLineValidation.normalizedValue > lineNumber + ) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); + continue; + } + if (item.side !== undefined) { + if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); + continue; + } + } + break; + case "create-discussion": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); + continue; + } + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); + continue; + } + item.category = sanitizeContent(item.category); + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + break; + case "missing-tool": + if (!item.tool || typeof item.tool !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); + continue; + } + if (!item.reason || typeof item.reason !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); + continue; + } + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); + continue; + } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "upload-asset": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); + continue; + } + break; + case "create-code-scanning-alert": + if (!item.file || typeof item.file !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); + continue; + } + const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); + if (!alertLineValidation.isValid) { + if (alertLineValidation.error) { + errors.push(alertLineValidation.error); + } + continue; + } + if (!item.severity || typeof item.severity !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); + continue; + } + const allowedSeverities = ["error", "warning", "info", "note"]; + if (!allowedSeverities.includes(item.severity.toLowerCase())) { + errors.push( + `Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` + ); + continue; + } + const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); + if (!columnValidation.isValid) { + if (columnValidation.error) errors.push(columnValidation.error); + continue; + } + if (item.ruleIdSuffix !== undefined) { + if (typeof item.ruleIdSuffix !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); + continue; + } + if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { + errors.push( + `Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` + ); + continue; + } + } + item.severity = item.severity.toLowerCase(); + item.file = sanitizeContent(item.file); + item.severity = sanitizeContent(item.severity); + item.message = sanitizeContent(item.message); + if (item.ruleIdSuffix) { + item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); + } + break; + default: + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); + continue; + } + Object.assign(item, validation.normalizedItem); + } + break; + } + core.info(`Line ${i + 1}: Valid ${itemType} item`); + parsedItems.push(item); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.warning(`Failed to write to step summary: ${errorMsg}`); + } + if (errors.length > 0) { + core.warning("Validation errors found:"); + errors.forEach(error => core.warning(` - ${error}`)); + if (parsedItems.length === 0) { + core.setFailed(errors.map(e => ` - ${e}`).join("\n")); + return; } + } + for (const itemType of Object.keys(expectedOutputTypes)) { + const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); + if (minRequired > 0) { + const actualCount = parsedItems.filter(item => item.type === itemType).length; + if (actualCount < minRequired) { + errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); + } + } + } + core.info(`Successfully parsed ${parsedItems.length} valid output items`); + const validatedOutput = { + items: parsedItems, + errors: errors, + }; + const agentOutputFile = "/tmp/agent_output.json"; + const validatedOutputJson = JSON.stringify(validatedOutput); + try { + fs.mkdirSync("/tmp", { recursive: true }); + fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); + core.info(`Stored validated output to: ${agentOutputFile}`); + core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.error(`Failed to write agent output file: ${errorMsg}`); + } + core.setOutput("output", JSON.stringify(validatedOutput)); + core.setOutput("raw_output", outputContent); + const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); + core.info(`output_types: ${outputTypes.join(", ")}`); + core.setOutput("output_types", outputTypes.join(",")); + try { + await core.summary + .addRaw("## Processed Output\n\n") + .addRaw("```json\n") + .addRaw(JSON.stringify(validatedOutput)) + .addRaw("\n```\n") + .write(); + core.info("Successfully wrote processed output to step summary"); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.warning(`Failed to write to step summary: ${errorMsg}`); + } } await main(); - name: Upload sanitized agent output @@ -2264,203 +2264,200 @@ jobs: with: script: | function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); + if (!content || typeof content !== "string") { + return ""; + } + let sanitized = content.trim(); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); + sanitized = sanitized.replace(/[<>&'"]/g, ""); + return sanitized.trim(); } async function main() { - const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; - if (!outputContent) { - core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); - return; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return; - } - core.debug(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } - catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.warning("No valid items found in agent output"); - return; - } - const labelsItem = validatedOutput.items.find(item => item.type === "add-labels"); - if (!labelsItem) { - core.warning("No add-labels item found in agent output"); - return; - } - core.debug(`Found add-labels item with ${labelsItem.labels.length} labels`); - if (process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Add Labels Preview\n\n"; - summaryContent += "The following labels would be added if staged mode was disabled:\n\n"; - if (labelsItem.issue_number) { - summaryContent += `**Target Issue:** #${labelsItem.issue_number}\n\n`; - } - else { - summaryContent += `**Target:** Current issue/PR\n\n`; - } - if (labelsItem.labels && labelsItem.labels.length > 0) { - summaryContent += `**Labels to add:** ${labelsItem.labels.join(", ")}\n\n`; - } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Label addition preview written to step summary"); - return; - } - const allowedLabelsEnv = process.env.GITHUB_AW_LABELS_ALLOWED?.trim(); - const allowedLabels = allowedLabelsEnv - ? allowedLabelsEnv - .split(",") - .map(label => label.trim()) - .filter(label => label) - : undefined; - if (allowedLabels) { - core.debug(`Allowed labels: ${JSON.stringify(allowedLabels)}`); - } - else { - core.debug("No label restrictions - any labels are allowed"); + const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; + if (!outputContent) { + core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); + return; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return; + } + core.debug(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); + return; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.warning("No valid items found in agent output"); + return; + } + const labelsItem = validatedOutput.items.find(item => item.type === "add-labels"); + if (!labelsItem) { + core.warning("No add-labels item found in agent output"); + return; + } + core.debug(`Found add-labels item with ${labelsItem.labels.length} labels`); + if (process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true") { + let summaryContent = "## 🎭 Staged Mode: Add Labels Preview\n\n"; + summaryContent += "The following labels would be added if staged mode was disabled:\n\n"; + if (labelsItem.issue_number) { + summaryContent += `**Target Issue:** #${labelsItem.issue_number}\n\n`; + } else { + summaryContent += `**Target:** Current issue/PR\n\n`; } - const maxCountEnv = process.env.GITHUB_AW_LABELS_MAX_COUNT; - const maxCount = maxCountEnv ? parseInt(maxCountEnv, 10) : 3; - if (isNaN(maxCount) || maxCount < 1) { - core.setFailed(`Invalid max value: ${maxCountEnv}. Must be a positive integer`); - return; + if (labelsItem.labels && labelsItem.labels.length > 0) { + summaryContent += `**Labels to add:** ${labelsItem.labels.join(", ")}\n\n`; } - core.debug(`Max count: ${maxCount}`); - const labelsTarget = process.env.GITHUB_AW_LABELS_TARGET || "triggering"; - core.info(`Labels target configuration: ${labelsTarget}`); - const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; - const isPRContext = context.eventName === "pull_request" || - context.eventName === "pull_request_review" || - context.eventName === "pull_request_review_comment"; - if (labelsTarget === "triggering" && !isIssueContext && !isPRContext) { - core.info('Target is "triggering" but not running in issue or pull request context, skipping label addition'); + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Label addition preview written to step summary"); + return; + } + const allowedLabelsEnv = process.env.GITHUB_AW_LABELS_ALLOWED?.trim(); + const allowedLabels = allowedLabelsEnv + ? allowedLabelsEnv + .split(",") + .map(label => label.trim()) + .filter(label => label) + : undefined; + if (allowedLabels) { + core.debug(`Allowed labels: ${JSON.stringify(allowedLabels)}`); + } else { + core.debug("No label restrictions - any labels are allowed"); + } + const maxCountEnv = process.env.GITHUB_AW_LABELS_MAX_COUNT; + const maxCount = maxCountEnv ? parseInt(maxCountEnv, 10) : 3; + if (isNaN(maxCount) || maxCount < 1) { + core.setFailed(`Invalid max value: ${maxCountEnv}. Must be a positive integer`); + return; + } + core.debug(`Max count: ${maxCount}`); + const labelsTarget = process.env.GITHUB_AW_LABELS_TARGET || "triggering"; + core.info(`Labels target configuration: ${labelsTarget}`); + const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; + const isPRContext = + context.eventName === "pull_request" || + context.eventName === "pull_request_review" || + context.eventName === "pull_request_review_comment"; + if (labelsTarget === "triggering" && !isIssueContext && !isPRContext) { + core.info('Target is "triggering" but not running in issue or pull request context, skipping label addition'); + return; + } + let issueNumber; + let contextType; + if (labelsTarget === "*") { + if (labelsItem.issue_number) { + issueNumber = typeof labelsItem.issue_number === "number" ? labelsItem.issue_number : parseInt(String(labelsItem.issue_number), 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + core.setFailed(`Invalid issue number specified: ${labelsItem.issue_number}`); return; + } + contextType = "issue"; + } else { + core.setFailed('Target is "*" but no issue_number specified in labels item'); + return; } - let issueNumber; - let contextType; - if (labelsTarget === "*") { - if (labelsItem.issue_number) { - issueNumber = typeof labelsItem.issue_number === "number" ? labelsItem.issue_number : parseInt(String(labelsItem.issue_number), 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - core.setFailed(`Invalid issue number specified: ${labelsItem.issue_number}`); - return; - } - contextType = "issue"; - } - else { - core.setFailed('Target is "*" but no issue_number specified in labels item'); - return; - } + } else if (labelsTarget && labelsTarget !== "triggering") { + issueNumber = parseInt(labelsTarget, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + core.setFailed(`Invalid issue number in target configuration: ${labelsTarget}`); + return; } - else if (labelsTarget && labelsTarget !== "triggering") { - issueNumber = parseInt(labelsTarget, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - core.setFailed(`Invalid issue number in target configuration: ${labelsTarget}`); - return; - } + contextType = "issue"; + } else { + if (isIssueContext) { + if (context.payload.issue) { + issueNumber = context.payload.issue.number; contextType = "issue"; - } - else { - if (isIssueContext) { - if (context.payload.issue) { - issueNumber = context.payload.issue.number; - contextType = "issue"; - } - else { - core.setFailed("Issue context detected but no issue found in payload"); - return; - } - } - else if (isPRContext) { - if (context.payload.pull_request) { - issueNumber = context.payload.pull_request.number; - contextType = "pull request"; - } - else { - core.setFailed("Pull request context detected but no pull request found in payload"); - return; - } - } - } - if (!issueNumber) { - core.setFailed("Could not determine issue or pull request number"); + } else { + core.setFailed("Issue context detected but no issue found in payload"); return; + } + } else if (isPRContext) { + if (context.payload.pull_request) { + issueNumber = context.payload.pull_request.number; + contextType = "pull request"; + } else { + core.setFailed("Pull request context detected but no pull request found in payload"); + return; + } } - const requestedLabels = labelsItem.labels || []; - core.debug(`Requested labels: ${JSON.stringify(requestedLabels)}`); - for (const label of requestedLabels) { - if (label && typeof label === "string" && label.startsWith("-")) { - core.setFailed(`Label removal is not permitted. Found line starting with '-': ${label}`); - return; - } - } - let validLabels; - if (allowedLabels) { - validLabels = requestedLabels.filter(label => allowedLabels.includes(label)); - } - else { - validLabels = requestedLabels; - } - let uniqueLabels = validLabels - .filter(label => label != null && label !== false && label !== 0) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - if (uniqueLabels.length > maxCount) { - core.debug(`too many labels, keep ${maxCount}`); - uniqueLabels = uniqueLabels.slice(0, maxCount); + } + if (!issueNumber) { + core.setFailed("Could not determine issue or pull request number"); + return; + } + const requestedLabels = labelsItem.labels || []; + core.debug(`Requested labels: ${JSON.stringify(requestedLabels)}`); + for (const label of requestedLabels) { + if (label && typeof label === "string" && label.startsWith("-")) { + core.setFailed(`Label removal is not permitted. Found line starting with '-': ${label}`); + return; } - if (uniqueLabels.length === 0) { - core.info("No labels to add"); - core.setOutput("labels_added", ""); - await core.summary - .addRaw(` + } + let validLabels; + if (allowedLabels) { + validLabels = requestedLabels.filter(label => allowedLabels.includes(label)); + } else { + validLabels = requestedLabels; + } + let uniqueLabels = validLabels + .filter(label => label != null && label !== false && label !== 0) + .map(label => String(label).trim()) + .filter(label => label) + .map(label => sanitizeLabelContent(label)) + .filter(label => label) + .map(label => (label.length > 64 ? label.substring(0, 64) : label)) + .filter((label, index, arr) => arr.indexOf(label) === index); + if (uniqueLabels.length > maxCount) { + core.debug(`too many labels, keep ${maxCount}`); + uniqueLabels = uniqueLabels.slice(0, maxCount); + } + if (uniqueLabels.length === 0) { + core.info("No labels to add"); + core.setOutput("labels_added", ""); + await core.summary + .addRaw( + ` ## Label Addition No labels were added (no valid labels found in agent output). - `) - .write(); - return; - } - core.info(`Adding ${uniqueLabels.length} labels to ${contextType} #${issueNumber}: ${JSON.stringify(uniqueLabels)}`); - try { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - labels: uniqueLabels, - }); - core.info(`Successfully added ${uniqueLabels.length} labels to ${contextType} #${issueNumber}`); - core.setOutput("labels_added", uniqueLabels.join("\n")); - const labelsListMarkdown = uniqueLabels.map(label => `- \`${label}\``).join("\n"); - await core.summary - .addRaw(` + ` + ) + .write(); + return; + } + core.info(`Adding ${uniqueLabels.length} labels to ${contextType} #${issueNumber}: ${JSON.stringify(uniqueLabels)}`); + try { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + labels: uniqueLabels, + }); + core.info(`Successfully added ${uniqueLabels.length} labels to ${contextType} #${issueNumber}`); + core.setOutput("labels_added", uniqueLabels.join("\n")); + const labelsListMarkdown = uniqueLabels.map(label => `- \`${label}\``).join("\n"); + await core.summary + .addRaw( + ` ## Label Addition Successfully added ${uniqueLabels.length} label(s) to ${contextType} #${issueNumber}: ${labelsListMarkdown} - `) - .write(); - } - catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - core.error(`Failed to add labels: ${errorMessage}`); - core.setFailed(`Failed to add labels: ${errorMessage}`); - } + ` + ) + .write(); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + core.error(`Failed to add labels: ${errorMessage}`); + core.setFailed(`Failed to add labels: ${errorMessage}`); + } } await main(); diff --git a/.github/workflows/notion-issue-summary.lock.yml b/.github/workflows/notion-issue-summary.lock.yml index 2e807675dc9..b1d6d1272b6 100644 --- a/.github/workflows/notion-issue-summary.lock.yml +++ b/.github/workflows/notion-issue-summary.lock.yml @@ -1384,742 +1384,742 @@ jobs: with: script: | async function main() { - const fs = require("fs"); - function sanitizeContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; - const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; - const allowedDomains = allowedDomainsEnv - ? allowedDomainsEnv - .split(",") - .map(d => d.trim()) - .filter(d => d) - : defaultAllowedDomains; - let sanitized = content; - sanitized = neutralizeMentions(sanitized); - sanitized = removeXmlComments(sanitized); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitizeUrlProtocols(sanitized); - sanitized = sanitizeUrlDomains(sanitized); - const maxLength = 524288; - if (sanitized.length > maxLength) { - sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; - } - const lines = sanitized.split("\n"); - const maxLines = 65000; - if (lines.length > maxLines) { - sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; - } - sanitized = neutralizeBotTriggers(sanitized); - return sanitized.trim(); - function sanitizeUrlDomains(s) { - return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { - const urlAfterProtocol = match.slice(8); - const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); - const isAllowed = allowedDomains.some(allowedDomain => { - const normalizedAllowed = allowedDomain.toLowerCase(); - return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); - }); - return isAllowed ? match : "(redacted)"; - }); - } - function sanitizeUrlProtocols(s) { - return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { - return protocol.toLowerCase() === "https" ? match : "(redacted)"; - }); - } - function neutralizeMentions(s) { - return s.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - } - function removeXmlComments(s) { - return s.replace(//g, "").replace(//g, ""); - } - function neutralizeBotTriggers(s) { - return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); - } + const fs = require("fs"); + function sanitizeContent(content) { + if (!content || typeof content !== "string") { + return ""; } - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create-issue": - return 1; - case "add-comment": - return 1; - case "create-pull-request": - return 1; - case "create-pull-request-review-comment": - return 1; - case "add-labels": - return 5; - case "update-issue": - return 1; - case "push-to-pull-request-branch": - return 1; - case "create-discussion": - return 1; - case "missing-tool": - return 1000; - case "create-code-scanning-alert": - return 1000; - case "upload-asset": - return 10; - default: - return 1; - } + const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; + const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; + const allowedDomains = allowedDomainsEnv + ? allowedDomainsEnv + .split(",") + .map(d => d.trim()) + .filter(d => d) + : defaultAllowedDomains; + let sanitized = content; + sanitized = neutralizeMentions(sanitized); + sanitized = removeXmlComments(sanitized); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitizeUrlProtocols(sanitized); + sanitized = sanitizeUrlDomains(sanitized); + const maxLength = 524288; + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; - } - return 0; - } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; + const lines = sanitized.split("\n"); + const maxLines = 65000; + if (lines.length > maxLines) { + sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; + } + sanitized = neutralizeBotTriggers(sanitized); + return sanitized.trim(); + function sanitizeUrlDomains(s) { + return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { + const urlAfterProtocol = match.slice(8); + const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); + const isAllowed = allowedDomains.some(allowedDomain => { + const normalizedAllowed = allowedDomain.toLowerCase(); + return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } - else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } - else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; - } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; - } - return { isValid: true, normalizedValue: parsed }; + return isAllowed ? match : "(redacted)"; + }); } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; - } - return { isValid: true, normalizedValue: parsed }; + function sanitizeUrlProtocols(s) { + return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { + return protocol.toLowerCase() === "https" ? match : "(redacted)"; + }); } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - return { isValid: true }; - } - function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { - if (inputSchema.required && (value === undefined || value === null)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; - } - if (value === undefined || value === null) { - return { - isValid: true, - normalizedValue: inputSchema.default || undefined, - }; - } - const inputType = inputSchema.type || "string"; - let normalizedValue = value; - switch (inputType) { - case "string": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string`, - }; - } - normalizedValue = sanitizeContent(value); - break; - case "boolean": - if (typeof value !== "boolean") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a boolean`, - }; - } - break; - case "number": - if (typeof value !== "number") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number`, - }; - } - break; - case "choice": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, - }; - } - if (inputSchema.options && !inputSchema.options.includes(value)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, - }; - } - normalizedValue = sanitizeContent(value); - break; - default: - if (typeof value === "string") { - normalizedValue = sanitizeContent(value); - } - break; - } + function neutralizeMentions(s) { + return s.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); + } + function removeXmlComments(s) { + return s.replace(//g, "").replace(//g, ""); + } + function neutralizeBotTriggers(s) { + return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); + } + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + switch (itemType) { + case "create-issue": + return 1; + case "add-comment": + return 1; + case "create-pull-request": + return 1; + case "create-pull-request-review-comment": + return 1; + case "add-labels": + return 5; + case "update-issue": + return 1; + case "push-to-pull-request-branch": + return 1; + case "create-discussion": + return 1; + case "missing-tool": + return 1000; + case "create-code-scanning-alert": + return 1000; + case "upload-asset": + return 10; + default: + return 1; + } + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; + } + return 0; + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { return { - isValid: true, - normalizedValue, + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; } - function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { - const errors = []; - const normalizedItem = { ...item }; - if (!jobConfig.inputs) { - return { - isValid: true, - errors: [], - normalizedItem: item, - }; - } - for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { - const fieldValue = item[fieldName]; - const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); - if (!validation.isValid && validation.error) { - errors.push(validation.error); - } - else if (validation.normalizedValue !== undefined) { - normalizedItem[fieldName] = validation.normalizedValue; - } - } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-code-scanning-alert 'line'")) { return { - isValid: errors.length === 0, - errors, - normalizedItem, + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; } - function parseJsonWithRepair(jsonStr) { - try { - return JSON.parse(jsonStr); - } - catch (originalError) { - try { - const repairedJson = repairJson(jsonStr); - return JSON.parse(repairedJson); - } - catch (repairError) { - core.info(`invalid input json: ${jsonStr}`); - const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); - const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); - throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); - } - } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; } - const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; - const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; - if (!outputFile) { - core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); - core.setOutput("output", ""); - return; + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; } - if (!fs.existsSync(outputFile)) { - core.info(`Output file does not exist: ${outputFile}`); - core.setOutput("output", ""); - return; + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; } - const outputContent = fs.readFileSync(outputFile, "utf8"); - if (outputContent.trim() === "") { - core.info("Output file is empty"); + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; } - core.info(`Raw output content length: ${outputContent.length}`); - let expectedOutputTypes = {}; - if (safeOutputsConfig) { - try { - expectedOutputTypes = JSON.parse(safeOutputsConfig); - core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { + if (inputSchema.required && (value === undefined || value === null)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (value === undefined || value === null) { + return { + isValid: true, + normalizedValue: inputSchema.default || undefined, + }; + } + const inputType = inputSchema.type || "string"; + let normalizedValue = value; + switch (inputType) { + case "string": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string`, + }; } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); + normalizedValue = sanitizeContent(value); + break; + case "boolean": + if (typeof value !== "boolean") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a boolean`, + }; + } + break; + case "number": + if (typeof value !== "number") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number`, + }; + } + break; + case "choice": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, + }; + } + if (inputSchema.options && !inputSchema.options.includes(value)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, + }; + } + normalizedValue = sanitizeContent(value); + break; + default: + if (typeof value === "string") { + normalizedValue = sanitizeContent(value); } + break; } - const lines = outputContent.trim().split("\n"); - const parsedItems = []; + return { + isValid: true, + normalizedValue, + }; + } + function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { const errors = []; - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); - if (line === "") + const normalizedItem = { ...item }; + if (!jobConfig.inputs) { + return { + isValid: true, + errors: [], + normalizedItem: item, + }; + } + for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { + const fieldValue = item[fieldName]; + const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); + if (!validation.isValid && validation.error) { + errors.push(validation.error); + } else if (validation.normalizedValue !== undefined) { + normalizedItem[fieldName] = validation.normalizedValue; + } + } + return { + isValid: errors.length === 0, + errors, + normalizedItem, + }; + } + function parseJsonWithRepair(jsonStr) { + try { + return JSON.parse(jsonStr); + } catch (originalError) { + try { + const repairedJson = repairJson(jsonStr); + return JSON.parse(repairedJson); + } catch (repairError) { + core.info(`invalid input json: ${jsonStr}`); + const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); + const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); + throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); + } + } + } + const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; + const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; + if (!outputFile) { + core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); + core.setOutput("output", ""); + return; + } + if (!fs.existsSync(outputFile)) { + core.info(`Output file does not exist: ${outputFile}`); + core.setOutput("output", ""); + return; + } + const outputContent = fs.readFileSync(outputFile, "utf8"); + if (outputContent.trim() === "") { + core.info("Output file is empty"); + } + core.info(`Raw output content length: ${outputContent.length}`); + let expectedOutputTypes = {}; + if (safeOutputsConfig) { + try { + expectedOutputTypes = JSON.parse(safeOutputsConfig); + core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); + } + } + const lines = outputContent.trim().split("\n"); + const parsedItems = []; + const errors = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line === "") continue; + try { + const item = parseJsonWithRepair(line); + if (item === undefined) { + errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); + continue; + } + if (!item.type) { + errors.push(`Line ${i + 1}: Missing required 'type' field`); + continue; + } + const itemType = item.type; + if (!expectedOutputTypes[itemType]) { + errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); + continue; + } + const typeCount = parsedItems.filter(existing => existing.type === itemType).length; + const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); + if (typeCount >= maxAllowed) { + errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); + continue; + } + core.info(`Line ${i + 1}: type '${itemType}'`); + switch (itemType) { + case "create-issue": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); continue; - try { - const item = parseJsonWithRepair(line); - if (item === undefined) { - errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); - continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-comment": + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); + continue; + } + const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); + if (!issueNumValidation.isValid) { + if (issueNumValidation.error) errors.push(issueNumValidation.error); + continue; + } + item.body = sanitizeContent(item.body); + break; + case "create-pull-request": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); + continue; + } + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + item.branch = sanitizeContent(item.branch); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-labels": + if (!item.labels || !Array.isArray(item.labels)) { + errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); + continue; + } + if (item.labels.some(label => typeof label !== "string")) { + errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); + continue; + } + const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); + if (!labelsIssueNumValidation.isValid) { + if (labelsIssueNumValidation.error) errors.push(labelsIssueNumValidation.error); + continue; + } + item.labels = item.labels.map(label => sanitizeContent(label)); + break; + case "update-issue": + const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; + if (!hasValidField) { + errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); + continue; + } + if (item.status !== undefined) { + if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { + errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); + continue; + } + } + if (item.title !== undefined) { + if (typeof item.title !== "string") { + errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); + continue; + } + item.title = sanitizeContent(item.title); + } + if (item.body !== undefined) { + if (typeof item.body !== "string") { + errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); + continue; + } + item.body = sanitizeContent(item.body); + } + const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); + if (!updateIssueNumValidation.isValid) { + if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); + continue; + } + break; + case "push-to-pull-request-branch": + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); + continue; + } + item.branch = sanitizeContent(item.branch); + item.message = sanitizeContent(item.message); + const pushPRNumValidation = validateIssueOrPRNumber( + item.pull_request_number, + "push-to-pull-request-branch 'pull_request_number'", + i + 1 + ); + if (!pushPRNumValidation.isValid) { + if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); + continue; + } + break; + case "create-pull-request-review-comment": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); + continue; + } + const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); + if (!lineValidation.isValid) { + if (lineValidation.error) errors.push(lineValidation.error); + continue; + } + const lineNumber = lineValidation.normalizedValue; + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); + continue; + } + item.body = sanitizeContent(item.body); + const startLineValidation = validateOptionalPositiveInteger( + item.start_line, + "create-pull-request-review-comment 'start_line'", + i + 1 + ); + if (!startLineValidation.isValid) { + if (startLineValidation.error) errors.push(startLineValidation.error); + continue; + } + if ( + startLineValidation.normalizedValue !== undefined && + lineNumber !== undefined && + startLineValidation.normalizedValue > lineNumber + ) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); + continue; + } + if (item.side !== undefined) { + if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); + continue; } - if (!item.type) { - errors.push(`Line ${i + 1}: Missing required 'type' field`); - continue; + } + break; + case "create-discussion": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); + continue; + } + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); + continue; } - const itemType = item.type; - if (!expectedOutputTypes[itemType]) { - errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); - continue; + item.category = sanitizeContent(item.category); + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + break; + case "missing-tool": + if (!item.tool || typeof item.tool !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); + continue; + } + if (!item.reason || typeof item.reason !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); + continue; + } + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); + continue; } - const typeCount = parsedItems.filter(existing => existing.type === itemType).length; - const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); - if (typeCount >= maxAllowed) { - errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); - continue; + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "upload-asset": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); + continue; + } + break; + case "create-code-scanning-alert": + if (!item.file || typeof item.file !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); + continue; + } + const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); + if (!alertLineValidation.isValid) { + if (alertLineValidation.error) { + errors.push(alertLineValidation.error); } - core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create-issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); - if (!issueNumValidation.isValid) { - if (issueNumValidation.error) - errors.push(issueNumValidation.error); - continue; - } - item.body = sanitizeContent(item.body); - break; - case "create-pull-request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - item.branch = sanitizeContent(item.branch); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some((label) => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); - if (!labelsIssueNumValidation.isValid) { - if (labelsIssueNumValidation.error) - errors.push(labelsIssueNumValidation.error); - continue; - } - item.labels = item.labels.map((label) => sanitizeContent(label)); - break; - case "update-issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) - errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "push-to-pull-request-branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch); - item.message = sanitizeContent(item.message); - const pushPRNumValidation = validateIssueOrPRNumber(item.pull_request_number, "push-to-pull-request-branch 'pull_request_number'", i + 1); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) - errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create-pull-request-review-comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) - errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body); - const startLineValidation = validateOptionalPositiveInteger(item.start_line, "create-pull-request-review-comment 'start_line'", i + 1); - if (!startLineValidation.isValid) { - if (startLineValidation.error) - errors.push(startLineValidation.error); - continue; - } - if (startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create-discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category); - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - break; - case "missing-tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool); - item.reason = sanitizeContent(item.reason); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives); - } - break; - case "upload-asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "create-code-scanning-alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}`); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) - errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores`); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file); - item.severity = sanitizeContent(item.severity); - item.message = sanitizeContent(item.message); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); - continue; - } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + continue; + } + if (!item.severity || typeof item.severity !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); + continue; + } + const allowedSeverities = ["error", "warning", "info", "note"]; + if (!allowedSeverities.includes(item.severity.toLowerCase())) { + errors.push( + `Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` + ); + continue; + } + const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); + if (!columnValidation.isValid) { + if (columnValidation.error) errors.push(columnValidation.error); + continue; + } + if (item.ruleIdSuffix !== undefined) { + if (typeof item.ruleIdSuffix !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); + continue; } - core.info(`Line ${i + 1}: Valid ${itemType} item`); - parsedItems.push(item); - } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); - } - } - if (errors.length > 0) { - core.warning("Validation errors found:"); - errors.forEach(error => core.warning(` - ${error}`)); - if (parsedItems.length === 0) { - core.setFailed(errors.map(e => ` - ${e}`).join("\n")); - return; - } - } - for (const itemType of Object.keys(expectedOutputTypes)) { - const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); - if (minRequired > 0) { - const actualCount = parsedItems.filter(item => item.type === itemType).length; - if (actualCount < minRequired) { - errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); + if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { + errors.push( + `Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` + ); + continue; } - } + } + item.severity = item.severity.toLowerCase(); + item.file = sanitizeContent(item.file); + item.severity = sanitizeContent(item.severity); + item.message = sanitizeContent(item.message); + if (item.ruleIdSuffix) { + item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); + } + break; + default: + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); + continue; + } + Object.assign(item, validation.normalizedItem); + } + break; + } + core.info(`Line ${i + 1}: Valid ${itemType} item`); + parsedItems.push(item); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); } - core.info(`Successfully parsed ${parsedItems.length} valid output items`); - const validatedOutput = { - items: parsedItems, - errors: errors, - }; - const agentOutputFile = "/tmp/agent_output.json"; - const validatedOutputJson = JSON.stringify(validatedOutput); - try { - fs.mkdirSync("/tmp", { recursive: true }); - fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); - core.info(`Stored validated output to: ${agentOutputFile}`); - core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); - } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.error(`Failed to write agent output file: ${errorMsg}`); - } - core.setOutput("output", JSON.stringify(validatedOutput)); - core.setOutput("raw_output", outputContent); - const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); - core.info(`output_types: ${outputTypes.join(", ")}`); - core.setOutput("output_types", outputTypes.join(",")); - try { - await core.summary - .addRaw("## Processed Output\n\n") - .addRaw("```json\n") - .addRaw(JSON.stringify(validatedOutput)) - .addRaw("\n```\n") - .write(); - core.info("Successfully wrote processed output to step summary"); - } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.warning(`Failed to write to step summary: ${errorMsg}`); + } + if (errors.length > 0) { + core.warning("Validation errors found:"); + errors.forEach(error => core.warning(` - ${error}`)); + if (parsedItems.length === 0) { + core.setFailed(errors.map(e => ` - ${e}`).join("\n")); + return; + } + } + for (const itemType of Object.keys(expectedOutputTypes)) { + const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); + if (minRequired > 0) { + const actualCount = parsedItems.filter(item => item.type === itemType).length; + if (actualCount < minRequired) { + errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); + } } + } + core.info(`Successfully parsed ${parsedItems.length} valid output items`); + const validatedOutput = { + items: parsedItems, + errors: errors, + }; + const agentOutputFile = "/tmp/agent_output.json"; + const validatedOutputJson = JSON.stringify(validatedOutput); + try { + fs.mkdirSync("/tmp", { recursive: true }); + fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); + core.info(`Stored validated output to: ${agentOutputFile}`); + core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.error(`Failed to write agent output file: ${errorMsg}`); + } + core.setOutput("output", JSON.stringify(validatedOutput)); + core.setOutput("raw_output", outputContent); + const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); + core.info(`output_types: ${outputTypes.join(", ")}`); + core.setOutput("output_types", outputTypes.join(",")); + try { + await core.summary + .addRaw("## Processed Output\n\n") + .addRaw("```json\n") + .addRaw(JSON.stringify(validatedOutput)) + .addRaw("\n```\n") + .write(); + core.info("Successfully wrote processed output to step summary"); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.warning(`Failed to write to step summary: ${errorMsg}`); + } } await main(); - name: Upload sanitized agent output diff --git a/.github/workflows/pdf-summary.lock.yml b/.github/workflows/pdf-summary.lock.yml index d4d76d04d97..38a889809bf 100644 --- a/.github/workflows/pdf-summary.lock.yml +++ b/.github/workflows/pdf-summary.lock.yml @@ -1623,742 +1623,742 @@ jobs: with: script: | async function main() { - const fs = require("fs"); - function sanitizeContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; - const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; - const allowedDomains = allowedDomainsEnv - ? allowedDomainsEnv - .split(",") - .map(d => d.trim()) - .filter(d => d) - : defaultAllowedDomains; - let sanitized = content; - sanitized = neutralizeMentions(sanitized); - sanitized = removeXmlComments(sanitized); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitizeUrlProtocols(sanitized); - sanitized = sanitizeUrlDomains(sanitized); - const maxLength = 524288; - if (sanitized.length > maxLength) { - sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; - } - const lines = sanitized.split("\n"); - const maxLines = 65000; - if (lines.length > maxLines) { - sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; - } - sanitized = neutralizeBotTriggers(sanitized); - return sanitized.trim(); - function sanitizeUrlDomains(s) { - return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { - const urlAfterProtocol = match.slice(8); - const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); - const isAllowed = allowedDomains.some(allowedDomain => { - const normalizedAllowed = allowedDomain.toLowerCase(); - return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); - }); - return isAllowed ? match : "(redacted)"; - }); - } - function sanitizeUrlProtocols(s) { - return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { - return protocol.toLowerCase() === "https" ? match : "(redacted)"; - }); - } - function neutralizeMentions(s) { - return s.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - } - function removeXmlComments(s) { - return s.replace(//g, "").replace(//g, ""); - } - function neutralizeBotTriggers(s) { - return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); - } + const fs = require("fs"); + function sanitizeContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; + const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; + const allowedDomains = allowedDomainsEnv + ? allowedDomainsEnv + .split(",") + .map(d => d.trim()) + .filter(d => d) + : defaultAllowedDomains; + let sanitized = content; + sanitized = neutralizeMentions(sanitized); + sanitized = removeXmlComments(sanitized); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitizeUrlProtocols(sanitized); + sanitized = sanitizeUrlDomains(sanitized); + const maxLength = 524288; + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; + } + const lines = sanitized.split("\n"); + const maxLines = 65000; + if (lines.length > maxLines) { + sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; + } + sanitized = neutralizeBotTriggers(sanitized); + return sanitized.trim(); + function sanitizeUrlDomains(s) { + return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { + const urlAfterProtocol = match.slice(8); + const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); + const isAllowed = allowedDomains.some(allowedDomain => { + const normalizedAllowed = allowedDomain.toLowerCase(); + return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); + }); + return isAllowed ? match : "(redacted)"; + }); } - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create-issue": - return 1; - case "add-comment": - return 1; - case "create-pull-request": - return 1; - case "create-pull-request-review-comment": - return 1; - case "add-labels": - return 5; - case "update-issue": - return 1; - case "push-to-pull-request-branch": - return 1; - case "create-discussion": - return 1; - case "missing-tool": - return 1000; - case "create-code-scanning-alert": - return 1000; - case "upload-asset": - return 10; - default: - return 1; - } + function sanitizeUrlProtocols(s) { + return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { + return protocol.toLowerCase() === "https" ? match : "(redacted)"; + }); } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; - } - return 0; - } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } - else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } - else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; - } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; - } - return { isValid: true, normalizedValue: parsed }; + function neutralizeMentions(s) { + return s.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); + } + function removeXmlComments(s) { + return s.replace(//g, "").replace(//g, ""); + } + function neutralizeBotTriggers(s) { + return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); + } + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + switch (itemType) { + case "create-issue": + return 1; + case "add-comment": + return 1; + case "create-pull-request": + return 1; + case "create-pull-request-review-comment": + return 1; + case "add-labels": + return 5; + case "update-issue": + return 1; + case "push-to-pull-request-branch": + return 1; + case "create-discussion": + return 1; + case "missing-tool": + return 1000; + case "create-code-scanning-alert": + return 1000; + case "upload-asset": + return 10; + default: + return 1; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; - } - return { isValid: true, normalizedValue: parsed }; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - return { isValid: true }; - } - function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { - if (inputSchema.required && (value === undefined || value === null)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; - } - if (value === undefined || value === null) { - return { - isValid: true, - normalizedValue: inputSchema.default || undefined, - }; - } - const inputType = inputSchema.type || "string"; - let normalizedValue = value; - switch (inputType) { - case "string": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string`, - }; - } - normalizedValue = sanitizeContent(value); - break; - case "boolean": - if (typeof value !== "boolean") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a boolean`, - }; - } - break; - case "number": - if (typeof value !== "number") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number`, - }; - } - break; - case "choice": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, - }; - } - if (inputSchema.options && !inputSchema.options.includes(value)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, - }; - } - normalizedValue = sanitizeContent(value); - break; - default: - if (typeof value === "string") { - normalizedValue = sanitizeContent(value); - } - break; - } + return 0; + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { return { - isValid: true, - normalizedValue, + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; } - function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { - const errors = []; - const normalizedItem = { ...item }; - if (!jobConfig.inputs) { - return { - isValid: true, - errors: [], - normalizedItem: item, - }; - } - for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { - const fieldValue = item[fieldName]; - const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); - if (!validation.isValid && validation.error) { - errors.push(validation.error); - } - else if (validation.normalizedValue !== undefined) { - normalizedItem[fieldName] = validation.normalizedValue; - } - } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-code-scanning-alert 'line'")) { return { - isValid: errors.length === 0, - errors, - normalizedItem, + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; } - function parseJsonWithRepair(jsonStr) { - try { - return JSON.parse(jsonStr); - } - catch (originalError) { - try { - const repairedJson = repairJson(jsonStr); - return JSON.parse(repairedJson); - } - catch (repairError) { - core.info(`invalid input json: ${jsonStr}`); - const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); - const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); - throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); - } - } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; } - const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; - const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; - if (!outputFile) { - core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); - core.setOutput("output", ""); - return; + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; } - if (!fs.existsSync(outputFile)) { - core.info(`Output file does not exist: ${outputFile}`); - core.setOutput("output", ""); - return; + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; } - const outputContent = fs.readFileSync(outputFile, "utf8"); - if (outputContent.trim() === "") { - core.info("Output file is empty"); + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { + if (inputSchema.required && (value === undefined || value === null)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; } - core.info(`Raw output content length: ${outputContent.length}`); - let expectedOutputTypes = {}; - if (safeOutputsConfig) { - try { - expectedOutputTypes = JSON.parse(safeOutputsConfig); - core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); + if (value === undefined || value === null) { + return { + isValid: true, + normalizedValue: inputSchema.default || undefined, + }; + } + const inputType = inputSchema.type || "string"; + let normalizedValue = value; + switch (inputType) { + case "string": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string`, + }; + } + normalizedValue = sanitizeContent(value); + break; + case "boolean": + if (typeof value !== "boolean") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a boolean`, + }; } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); + break; + case "number": + if (typeof value !== "number") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number`, + }; } + break; + case "choice": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, + }; + } + if (inputSchema.options && !inputSchema.options.includes(value)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, + }; + } + normalizedValue = sanitizeContent(value); + break; + default: + if (typeof value === "string") { + normalizedValue = sanitizeContent(value); + } + break; } - const lines = outputContent.trim().split("\n"); - const parsedItems = []; + return { + isValid: true, + normalizedValue, + }; + } + function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { const errors = []; - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); - if (line === "") + const normalizedItem = { ...item }; + if (!jobConfig.inputs) { + return { + isValid: true, + errors: [], + normalizedItem: item, + }; + } + for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { + const fieldValue = item[fieldName]; + const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); + if (!validation.isValid && validation.error) { + errors.push(validation.error); + } else if (validation.normalizedValue !== undefined) { + normalizedItem[fieldName] = validation.normalizedValue; + } + } + return { + isValid: errors.length === 0, + errors, + normalizedItem, + }; + } + function parseJsonWithRepair(jsonStr) { + try { + return JSON.parse(jsonStr); + } catch (originalError) { + try { + const repairedJson = repairJson(jsonStr); + return JSON.parse(repairedJson); + } catch (repairError) { + core.info(`invalid input json: ${jsonStr}`); + const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); + const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); + throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); + } + } + } + const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; + const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; + if (!outputFile) { + core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); + core.setOutput("output", ""); + return; + } + if (!fs.existsSync(outputFile)) { + core.info(`Output file does not exist: ${outputFile}`); + core.setOutput("output", ""); + return; + } + const outputContent = fs.readFileSync(outputFile, "utf8"); + if (outputContent.trim() === "") { + core.info("Output file is empty"); + } + core.info(`Raw output content length: ${outputContent.length}`); + let expectedOutputTypes = {}; + if (safeOutputsConfig) { + try { + expectedOutputTypes = JSON.parse(safeOutputsConfig); + core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); + } + } + const lines = outputContent.trim().split("\n"); + const parsedItems = []; + const errors = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line === "") continue; + try { + const item = parseJsonWithRepair(line); + if (item === undefined) { + errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); + continue; + } + if (!item.type) { + errors.push(`Line ${i + 1}: Missing required 'type' field`); + continue; + } + const itemType = item.type; + if (!expectedOutputTypes[itemType]) { + errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); + continue; + } + const typeCount = parsedItems.filter(existing => existing.type === itemType).length; + const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); + if (typeCount >= maxAllowed) { + errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); + continue; + } + core.info(`Line ${i + 1}: type '${itemType}'`); + switch (itemType) { + case "create-issue": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); continue; - try { - const item = parseJsonWithRepair(line); - if (item === undefined) { - errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); - continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-comment": + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); + continue; + } + const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); + if (!issueNumValidation.isValid) { + if (issueNumValidation.error) errors.push(issueNumValidation.error); + continue; + } + item.body = sanitizeContent(item.body); + break; + case "create-pull-request": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); + continue; + } + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + item.branch = sanitizeContent(item.branch); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-labels": + if (!item.labels || !Array.isArray(item.labels)) { + errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); + continue; + } + if (item.labels.some(label => typeof label !== "string")) { + errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); + continue; + } + const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); + if (!labelsIssueNumValidation.isValid) { + if (labelsIssueNumValidation.error) errors.push(labelsIssueNumValidation.error); + continue; + } + item.labels = item.labels.map(label => sanitizeContent(label)); + break; + case "update-issue": + const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; + if (!hasValidField) { + errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); + continue; + } + if (item.status !== undefined) { + if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { + errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); + continue; } - if (!item.type) { - errors.push(`Line ${i + 1}: Missing required 'type' field`); - continue; + } + if (item.title !== undefined) { + if (typeof item.title !== "string") { + errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); + continue; } - const itemType = item.type; - if (!expectedOutputTypes[itemType]) { - errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); - continue; + item.title = sanitizeContent(item.title); + } + if (item.body !== undefined) { + if (typeof item.body !== "string") { + errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); + continue; } - const typeCount = parsedItems.filter(existing => existing.type === itemType).length; - const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); - if (typeCount >= maxAllowed) { - errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); - continue; + item.body = sanitizeContent(item.body); + } + const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); + if (!updateIssueNumValidation.isValid) { + if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); + continue; + } + break; + case "push-to-pull-request-branch": + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); + continue; + } + item.branch = sanitizeContent(item.branch); + item.message = sanitizeContent(item.message); + const pushPRNumValidation = validateIssueOrPRNumber( + item.pull_request_number, + "push-to-pull-request-branch 'pull_request_number'", + i + 1 + ); + if (!pushPRNumValidation.isValid) { + if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); + continue; + } + break; + case "create-pull-request-review-comment": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); + continue; + } + const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); + if (!lineValidation.isValid) { + if (lineValidation.error) errors.push(lineValidation.error); + continue; + } + const lineNumber = lineValidation.normalizedValue; + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); + continue; + } + item.body = sanitizeContent(item.body); + const startLineValidation = validateOptionalPositiveInteger( + item.start_line, + "create-pull-request-review-comment 'start_line'", + i + 1 + ); + if (!startLineValidation.isValid) { + if (startLineValidation.error) errors.push(startLineValidation.error); + continue; + } + if ( + startLineValidation.normalizedValue !== undefined && + lineNumber !== undefined && + startLineValidation.normalizedValue > lineNumber + ) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); + continue; + } + if (item.side !== undefined) { + if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); + continue; } - core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create-issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); - if (!issueNumValidation.isValid) { - if (issueNumValidation.error) - errors.push(issueNumValidation.error); - continue; - } - item.body = sanitizeContent(item.body); - break; - case "create-pull-request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - item.branch = sanitizeContent(item.branch); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some((label) => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); - if (!labelsIssueNumValidation.isValid) { - if (labelsIssueNumValidation.error) - errors.push(labelsIssueNumValidation.error); - continue; - } - item.labels = item.labels.map((label) => sanitizeContent(label)); - break; - case "update-issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) - errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "push-to-pull-request-branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch); - item.message = sanitizeContent(item.message); - const pushPRNumValidation = validateIssueOrPRNumber(item.pull_request_number, "push-to-pull-request-branch 'pull_request_number'", i + 1); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) - errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create-pull-request-review-comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) - errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body); - const startLineValidation = validateOptionalPositiveInteger(item.start_line, "create-pull-request-review-comment 'start_line'", i + 1); - if (!startLineValidation.isValid) { - if (startLineValidation.error) - errors.push(startLineValidation.error); - continue; - } - if (startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create-discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category); - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - break; - case "missing-tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool); - item.reason = sanitizeContent(item.reason); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives); - } - break; - case "upload-asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "create-code-scanning-alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}`); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) - errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores`); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file); - item.severity = sanitizeContent(item.severity); - item.message = sanitizeContent(item.message); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); - continue; - } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + } + break; + case "create-discussion": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); + continue; + } + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); + continue; } - core.info(`Line ${i + 1}: Valid ${itemType} item`); - parsedItems.push(item); - } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); - } - } - if (errors.length > 0) { - core.warning("Validation errors found:"); - errors.forEach(error => core.warning(` - ${error}`)); - if (parsedItems.length === 0) { - core.setFailed(errors.map(e => ` - ${e}`).join("\n")); - return; - } - } - for (const itemType of Object.keys(expectedOutputTypes)) { - const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); - if (minRequired > 0) { - const actualCount = parsedItems.filter(item => item.type === itemType).length; - if (actualCount < minRequired) { - errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); + item.category = sanitizeContent(item.category); + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + break; + case "missing-tool": + if (!item.tool || typeof item.tool !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); + continue; + } + if (!item.reason || typeof item.reason !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); + continue; + } + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); + continue; } - } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "upload-asset": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); + continue; + } + break; + case "create-code-scanning-alert": + if (!item.file || typeof item.file !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); + continue; + } + const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); + if (!alertLineValidation.isValid) { + if (alertLineValidation.error) { + errors.push(alertLineValidation.error); + } + continue; + } + if (!item.severity || typeof item.severity !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); + continue; + } + const allowedSeverities = ["error", "warning", "info", "note"]; + if (!allowedSeverities.includes(item.severity.toLowerCase())) { + errors.push( + `Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` + ); + continue; + } + const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); + if (!columnValidation.isValid) { + if (columnValidation.error) errors.push(columnValidation.error); + continue; + } + if (item.ruleIdSuffix !== undefined) { + if (typeof item.ruleIdSuffix !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); + continue; + } + if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { + errors.push( + `Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` + ); + continue; + } + } + item.severity = item.severity.toLowerCase(); + item.file = sanitizeContent(item.file); + item.severity = sanitizeContent(item.severity); + item.message = sanitizeContent(item.message); + if (item.ruleIdSuffix) { + item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); + } + break; + default: + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); + continue; + } + Object.assign(item, validation.normalizedItem); + } + break; + } + core.info(`Line ${i + 1}: Valid ${itemType} item`); + parsedItems.push(item); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); } - core.info(`Successfully parsed ${parsedItems.length} valid output items`); - const validatedOutput = { - items: parsedItems, - errors: errors, - }; - const agentOutputFile = "/tmp/agent_output.json"; - const validatedOutputJson = JSON.stringify(validatedOutput); - try { - fs.mkdirSync("/tmp", { recursive: true }); - fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); - core.info(`Stored validated output to: ${agentOutputFile}`); - core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); - } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.error(`Failed to write agent output file: ${errorMsg}`); - } - core.setOutput("output", JSON.stringify(validatedOutput)); - core.setOutput("raw_output", outputContent); - const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); - core.info(`output_types: ${outputTypes.join(", ")}`); - core.setOutput("output_types", outputTypes.join(",")); - try { - await core.summary - .addRaw("## Processed Output\n\n") - .addRaw("```json\n") - .addRaw(JSON.stringify(validatedOutput)) - .addRaw("\n```\n") - .write(); - core.info("Successfully wrote processed output to step summary"); - } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.warning(`Failed to write to step summary: ${errorMsg}`); + } + if (errors.length > 0) { + core.warning("Validation errors found:"); + errors.forEach(error => core.warning(` - ${error}`)); + if (parsedItems.length === 0) { + core.setFailed(errors.map(e => ` - ${e}`).join("\n")); + return; } + } + for (const itemType of Object.keys(expectedOutputTypes)) { + const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); + if (minRequired > 0) { + const actualCount = parsedItems.filter(item => item.type === itemType).length; + if (actualCount < minRequired) { + errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); + } + } + } + core.info(`Successfully parsed ${parsedItems.length} valid output items`); + const validatedOutput = { + items: parsedItems, + errors: errors, + }; + const agentOutputFile = "/tmp/agent_output.json"; + const validatedOutputJson = JSON.stringify(validatedOutput); + try { + fs.mkdirSync("/tmp", { recursive: true }); + fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); + core.info(`Stored validated output to: ${agentOutputFile}`); + core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.error(`Failed to write agent output file: ${errorMsg}`); + } + core.setOutput("output", JSON.stringify(validatedOutput)); + core.setOutput("raw_output", outputContent); + const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); + core.info(`output_types: ${outputTypes.join(", ")}`); + core.setOutput("output_types", outputTypes.join(",")); + try { + await core.summary + .addRaw("## Processed Output\n\n") + .addRaw("```json\n") + .addRaw(JSON.stringify(validatedOutput)) + .addRaw("\n```\n") + .write(); + core.info("Successfully wrote processed output to step summary"); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.warning(`Failed to write to step summary: ${errorMsg}`); + } } await main(); - name: Upload sanitized agent output diff --git a/.github/workflows/poem-bot.lock.yml b/.github/workflows/poem-bot.lock.yml index 9faa4c7d3df..ffe0dd5b5b5 100644 --- a/.github/workflows/poem-bot.lock.yml +++ b/.github/workflows/poem-bot.lock.yml @@ -1598,742 +1598,742 @@ jobs: with: script: | async function main() { - const fs = require("fs"); - function sanitizeContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; - const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; - const allowedDomains = allowedDomainsEnv - ? allowedDomainsEnv - .split(",") - .map(d => d.trim()) - .filter(d => d) - : defaultAllowedDomains; - let sanitized = content; - sanitized = neutralizeMentions(sanitized); - sanitized = removeXmlComments(sanitized); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitizeUrlProtocols(sanitized); - sanitized = sanitizeUrlDomains(sanitized); - const maxLength = 524288; - if (sanitized.length > maxLength) { - sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; - } - const lines = sanitized.split("\n"); - const maxLines = 65000; - if (lines.length > maxLines) { - sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; - } - sanitized = neutralizeBotTriggers(sanitized); - return sanitized.trim(); - function sanitizeUrlDomains(s) { - return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { - const urlAfterProtocol = match.slice(8); - const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); - const isAllowed = allowedDomains.some(allowedDomain => { - const normalizedAllowed = allowedDomain.toLowerCase(); - return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); - }); - return isAllowed ? match : "(redacted)"; - }); - } - function sanitizeUrlProtocols(s) { - return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { - return protocol.toLowerCase() === "https" ? match : "(redacted)"; - }); - } - function neutralizeMentions(s) { - return s.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - } - function removeXmlComments(s) { - return s.replace(//g, "").replace(//g, ""); - } - function neutralizeBotTriggers(s) { - return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); - } + const fs = require("fs"); + function sanitizeContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; + const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; + const allowedDomains = allowedDomainsEnv + ? allowedDomainsEnv + .split(",") + .map(d => d.trim()) + .filter(d => d) + : defaultAllowedDomains; + let sanitized = content; + sanitized = neutralizeMentions(sanitized); + sanitized = removeXmlComments(sanitized); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitizeUrlProtocols(sanitized); + sanitized = sanitizeUrlDomains(sanitized); + const maxLength = 524288; + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; + } + const lines = sanitized.split("\n"); + const maxLines = 65000; + if (lines.length > maxLines) { + sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; + } + sanitized = neutralizeBotTriggers(sanitized); + return sanitized.trim(); + function sanitizeUrlDomains(s) { + return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { + const urlAfterProtocol = match.slice(8); + const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); + const isAllowed = allowedDomains.some(allowedDomain => { + const normalizedAllowed = allowedDomain.toLowerCase(); + return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); + }); + return isAllowed ? match : "(redacted)"; + }); } - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create-issue": - return 1; - case "add-comment": - return 1; - case "create-pull-request": - return 1; - case "create-pull-request-review-comment": - return 1; - case "add-labels": - return 5; - case "update-issue": - return 1; - case "push-to-pull-request-branch": - return 1; - case "create-discussion": - return 1; - case "missing-tool": - return 1000; - case "create-code-scanning-alert": - return 1000; - case "upload-asset": - return 10; - default: - return 1; - } + function sanitizeUrlProtocols(s) { + return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { + return protocol.toLowerCase() === "https" ? match : "(redacted)"; + }); } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; - } - return 0; - } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } - else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } - else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; - } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; - } - return { isValid: true, normalizedValue: parsed }; + function neutralizeMentions(s) { + return s.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; - } - return { isValid: true, normalizedValue: parsed }; + function removeXmlComments(s) { + return s.replace(//g, "").replace(//g, ""); + } + function neutralizeBotTriggers(s) { + return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); + } + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + switch (itemType) { + case "create-issue": + return 1; + case "add-comment": + return 1; + case "create-pull-request": + return 1; + case "create-pull-request-review-comment": + return 1; + case "add-labels": + return 5; + case "update-issue": + return 1; + case "push-to-pull-request-branch": + return 1; + case "create-discussion": + return 1; + case "missing-tool": + return 1000; + case "create-code-scanning-alert": + return 1000; + case "upload-asset": + return 10; + default: + return 1; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - return { isValid: true }; - } - function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { - if (inputSchema.required && (value === undefined || value === null)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; - } - if (value === undefined || value === null) { - return { - isValid: true, - normalizedValue: inputSchema.default || undefined, - }; - } - const inputType = inputSchema.type || "string"; - let normalizedValue = value; - switch (inputType) { - case "string": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string`, - }; - } - normalizedValue = sanitizeContent(value); - break; - case "boolean": - if (typeof value !== "boolean") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a boolean`, - }; - } - break; - case "number": - if (typeof value !== "number") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number`, - }; - } - break; - case "choice": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, - }; - } - if (inputSchema.options && !inputSchema.options.includes(value)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, - }; - } - normalizedValue = sanitizeContent(value); - break; - default: - if (typeof value === "string") { - normalizedValue = sanitizeContent(value); - } - break; - } + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; + } + return 0; + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { return { - isValid: true, - normalizedValue, + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; } - function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { - const errors = []; - const normalizedItem = { ...item }; - if (!jobConfig.inputs) { - return { - isValid: true, - errors: [], - normalizedItem: item, - }; - } - for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { - const fieldValue = item[fieldName]; - const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); - if (!validation.isValid && validation.error) { - errors.push(validation.error); - } - else if (validation.normalizedValue !== undefined) { - normalizedItem[fieldName] = validation.normalizedValue; - } - } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { return { - isValid: errors.length === 0, - errors, - normalizedItem, + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; } - function parseJsonWithRepair(jsonStr) { - try { - return JSON.parse(jsonStr); - } - catch (originalError) { - try { - const repairedJson = repairJson(jsonStr); - return JSON.parse(repairedJson); - } - catch (repairError) { - core.info(`invalid input json: ${jsonStr}`); - const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); - const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); - throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); - } - } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; } - const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; - const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; - if (!outputFile) { - core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); - core.setOutput("output", ""); - return; + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; } - if (!fs.existsSync(outputFile)) { - core.info(`Output file does not exist: ${outputFile}`); - core.setOutput("output", ""); - return; + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; } - const outputContent = fs.readFileSync(outputFile, "utf8"); - if (outputContent.trim() === "") { - core.info("Output file is empty"); + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; } - core.info(`Raw output content length: ${outputContent.length}`); - let expectedOutputTypes = {}; - if (safeOutputsConfig) { - try { - expectedOutputTypes = JSON.parse(safeOutputsConfig); - core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { + if (inputSchema.required && (value === undefined || value === null)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (value === undefined || value === null) { + return { + isValid: true, + normalizedValue: inputSchema.default || undefined, + }; + } + const inputType = inputSchema.type || "string"; + let normalizedValue = value; + switch (inputType) { + case "string": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string`, + }; } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); + normalizedValue = sanitizeContent(value); + break; + case "boolean": + if (typeof value !== "boolean") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a boolean`, + }; + } + break; + case "number": + if (typeof value !== "number") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number`, + }; } + break; + case "choice": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, + }; + } + if (inputSchema.options && !inputSchema.options.includes(value)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, + }; + } + normalizedValue = sanitizeContent(value); + break; + default: + if (typeof value === "string") { + normalizedValue = sanitizeContent(value); + } + break; } - const lines = outputContent.trim().split("\n"); - const parsedItems = []; + return { + isValid: true, + normalizedValue, + }; + } + function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { const errors = []; - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); - if (line === "") + const normalizedItem = { ...item }; + if (!jobConfig.inputs) { + return { + isValid: true, + errors: [], + normalizedItem: item, + }; + } + for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { + const fieldValue = item[fieldName]; + const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); + if (!validation.isValid && validation.error) { + errors.push(validation.error); + } else if (validation.normalizedValue !== undefined) { + normalizedItem[fieldName] = validation.normalizedValue; + } + } + return { + isValid: errors.length === 0, + errors, + normalizedItem, + }; + } + function parseJsonWithRepair(jsonStr) { + try { + return JSON.parse(jsonStr); + } catch (originalError) { + try { + const repairedJson = repairJson(jsonStr); + return JSON.parse(repairedJson); + } catch (repairError) { + core.info(`invalid input json: ${jsonStr}`); + const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); + const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); + throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); + } + } + } + const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; + const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; + if (!outputFile) { + core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); + core.setOutput("output", ""); + return; + } + if (!fs.existsSync(outputFile)) { + core.info(`Output file does not exist: ${outputFile}`); + core.setOutput("output", ""); + return; + } + const outputContent = fs.readFileSync(outputFile, "utf8"); + if (outputContent.trim() === "") { + core.info("Output file is empty"); + } + core.info(`Raw output content length: ${outputContent.length}`); + let expectedOutputTypes = {}; + if (safeOutputsConfig) { + try { + expectedOutputTypes = JSON.parse(safeOutputsConfig); + core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); + } + } + const lines = outputContent.trim().split("\n"); + const parsedItems = []; + const errors = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line === "") continue; + try { + const item = parseJsonWithRepair(line); + if (item === undefined) { + errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); + continue; + } + if (!item.type) { + errors.push(`Line ${i + 1}: Missing required 'type' field`); + continue; + } + const itemType = item.type; + if (!expectedOutputTypes[itemType]) { + errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); + continue; + } + const typeCount = parsedItems.filter(existing => existing.type === itemType).length; + const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); + if (typeCount >= maxAllowed) { + errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); + continue; + } + core.info(`Line ${i + 1}: type '${itemType}'`); + switch (itemType) { + case "create-issue": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); continue; - try { - const item = parseJsonWithRepair(line); - if (item === undefined) { - errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); - continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-comment": + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); + continue; + } + const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); + if (!issueNumValidation.isValid) { + if (issueNumValidation.error) errors.push(issueNumValidation.error); + continue; + } + item.body = sanitizeContent(item.body); + break; + case "create-pull-request": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); + continue; + } + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + item.branch = sanitizeContent(item.branch); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-labels": + if (!item.labels || !Array.isArray(item.labels)) { + errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); + continue; + } + if (item.labels.some(label => typeof label !== "string")) { + errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); + continue; + } + const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); + if (!labelsIssueNumValidation.isValid) { + if (labelsIssueNumValidation.error) errors.push(labelsIssueNumValidation.error); + continue; + } + item.labels = item.labels.map(label => sanitizeContent(label)); + break; + case "update-issue": + const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; + if (!hasValidField) { + errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); + continue; + } + if (item.status !== undefined) { + if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { + errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); + continue; } - if (!item.type) { - errors.push(`Line ${i + 1}: Missing required 'type' field`); - continue; + } + if (item.title !== undefined) { + if (typeof item.title !== "string") { + errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); + continue; } - const itemType = item.type; - if (!expectedOutputTypes[itemType]) { - errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); - continue; + item.title = sanitizeContent(item.title); + } + if (item.body !== undefined) { + if (typeof item.body !== "string") { + errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); + continue; } - const typeCount = parsedItems.filter(existing => existing.type === itemType).length; - const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); - if (typeCount >= maxAllowed) { - errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); - continue; + item.body = sanitizeContent(item.body); + } + const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); + if (!updateIssueNumValidation.isValid) { + if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); + continue; + } + break; + case "push-to-pull-request-branch": + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); + continue; + } + item.branch = sanitizeContent(item.branch); + item.message = sanitizeContent(item.message); + const pushPRNumValidation = validateIssueOrPRNumber( + item.pull_request_number, + "push-to-pull-request-branch 'pull_request_number'", + i + 1 + ); + if (!pushPRNumValidation.isValid) { + if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); + continue; + } + break; + case "create-pull-request-review-comment": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); + continue; + } + const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); + if (!lineValidation.isValid) { + if (lineValidation.error) errors.push(lineValidation.error); + continue; + } + const lineNumber = lineValidation.normalizedValue; + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); + continue; + } + item.body = sanitizeContent(item.body); + const startLineValidation = validateOptionalPositiveInteger( + item.start_line, + "create-pull-request-review-comment 'start_line'", + i + 1 + ); + if (!startLineValidation.isValid) { + if (startLineValidation.error) errors.push(startLineValidation.error); + continue; + } + if ( + startLineValidation.normalizedValue !== undefined && + lineNumber !== undefined && + startLineValidation.normalizedValue > lineNumber + ) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); + continue; + } + if (item.side !== undefined) { + if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); + continue; } - core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create-issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); - if (!issueNumValidation.isValid) { - if (issueNumValidation.error) - errors.push(issueNumValidation.error); - continue; - } - item.body = sanitizeContent(item.body); - break; - case "create-pull-request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - item.branch = sanitizeContent(item.branch); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some((label) => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); - if (!labelsIssueNumValidation.isValid) { - if (labelsIssueNumValidation.error) - errors.push(labelsIssueNumValidation.error); - continue; - } - item.labels = item.labels.map((label) => sanitizeContent(label)); - break; - case "update-issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) - errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "push-to-pull-request-branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch); - item.message = sanitizeContent(item.message); - const pushPRNumValidation = validateIssueOrPRNumber(item.pull_request_number, "push-to-pull-request-branch 'pull_request_number'", i + 1); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) - errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create-pull-request-review-comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) - errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body); - const startLineValidation = validateOptionalPositiveInteger(item.start_line, "create-pull-request-review-comment 'start_line'", i + 1); - if (!startLineValidation.isValid) { - if (startLineValidation.error) - errors.push(startLineValidation.error); - continue; - } - if (startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create-discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category); - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - break; - case "missing-tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool); - item.reason = sanitizeContent(item.reason); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives); - } - break; - case "upload-asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "create-code-scanning-alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}`); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) - errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores`); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file); - item.severity = sanitizeContent(item.severity); - item.message = sanitizeContent(item.message); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); - continue; - } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + } + break; + case "create-discussion": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); + continue; + } + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); + continue; } - core.info(`Line ${i + 1}: Valid ${itemType} item`); - parsedItems.push(item); - } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); - } - } - if (errors.length > 0) { - core.warning("Validation errors found:"); - errors.forEach(error => core.warning(` - ${error}`)); - if (parsedItems.length === 0) { - core.setFailed(errors.map(e => ` - ${e}`).join("\n")); - return; - } - } - for (const itemType of Object.keys(expectedOutputTypes)) { - const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); - if (minRequired > 0) { - const actualCount = parsedItems.filter(item => item.type === itemType).length; - if (actualCount < minRequired) { - errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); + item.category = sanitizeContent(item.category); + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + break; + case "missing-tool": + if (!item.tool || typeof item.tool !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); + continue; + } + if (!item.reason || typeof item.reason !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); + continue; + } + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); + continue; } - } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "upload-asset": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); + continue; + } + break; + case "create-code-scanning-alert": + if (!item.file || typeof item.file !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); + continue; + } + const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); + if (!alertLineValidation.isValid) { + if (alertLineValidation.error) { + errors.push(alertLineValidation.error); + } + continue; + } + if (!item.severity || typeof item.severity !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); + continue; + } + const allowedSeverities = ["error", "warning", "info", "note"]; + if (!allowedSeverities.includes(item.severity.toLowerCase())) { + errors.push( + `Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` + ); + continue; + } + const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); + if (!columnValidation.isValid) { + if (columnValidation.error) errors.push(columnValidation.error); + continue; + } + if (item.ruleIdSuffix !== undefined) { + if (typeof item.ruleIdSuffix !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); + continue; + } + if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { + errors.push( + `Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` + ); + continue; + } + } + item.severity = item.severity.toLowerCase(); + item.file = sanitizeContent(item.file); + item.severity = sanitizeContent(item.severity); + item.message = sanitizeContent(item.message); + if (item.ruleIdSuffix) { + item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); + } + break; + default: + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); + continue; + } + Object.assign(item, validation.normalizedItem); + } + break; + } + core.info(`Line ${i + 1}: Valid ${itemType} item`); + parsedItems.push(item); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); } - core.info(`Successfully parsed ${parsedItems.length} valid output items`); - const validatedOutput = { - items: parsedItems, - errors: errors, - }; - const agentOutputFile = "/tmp/agent_output.json"; - const validatedOutputJson = JSON.stringify(validatedOutput); - try { - fs.mkdirSync("/tmp", { recursive: true }); - fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); - core.info(`Stored validated output to: ${agentOutputFile}`); - core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); - } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.error(`Failed to write agent output file: ${errorMsg}`); - } - core.setOutput("output", JSON.stringify(validatedOutput)); - core.setOutput("raw_output", outputContent); - const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); - core.info(`output_types: ${outputTypes.join(", ")}`); - core.setOutput("output_types", outputTypes.join(",")); - try { - await core.summary - .addRaw("## Processed Output\n\n") - .addRaw("```json\n") - .addRaw(JSON.stringify(validatedOutput)) - .addRaw("\n```\n") - .write(); - core.info("Successfully wrote processed output to step summary"); + } + if (errors.length > 0) { + core.warning("Validation errors found:"); + errors.forEach(error => core.warning(` - ${error}`)); + if (parsedItems.length === 0) { + core.setFailed(errors.map(e => ` - ${e}`).join("\n")); + return; } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.warning(`Failed to write to step summary: ${errorMsg}`); + } + for (const itemType of Object.keys(expectedOutputTypes)) { + const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); + if (minRequired > 0) { + const actualCount = parsedItems.filter(item => item.type === itemType).length; + if (actualCount < minRequired) { + errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); + } } + } + core.info(`Successfully parsed ${parsedItems.length} valid output items`); + const validatedOutput = { + items: parsedItems, + errors: errors, + }; + const agentOutputFile = "/tmp/agent_output.json"; + const validatedOutputJson = JSON.stringify(validatedOutput); + try { + fs.mkdirSync("/tmp", { recursive: true }); + fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); + core.info(`Stored validated output to: ${agentOutputFile}`); + core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.error(`Failed to write agent output file: ${errorMsg}`); + } + core.setOutput("output", JSON.stringify(validatedOutput)); + core.setOutput("raw_output", outputContent); + const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); + core.info(`output_types: ${outputTypes.join(", ")}`); + core.setOutput("output_types", outputTypes.join(",")); + try { + await core.summary + .addRaw("## Processed Output\n\n") + .addRaw("```json\n") + .addRaw(JSON.stringify(validatedOutput)) + .addRaw("\n```\n") + .write(); + core.info("Successfully wrote processed output to step summary"); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.warning(`Failed to write to step summary: ${errorMsg}`); + } } await main(); - name: Upload sanitized agent output @@ -3328,163 +3328,165 @@ jobs: with: script: | function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); + if (!content || typeof content !== "string") { + return ""; + } + let sanitized = content.trim(); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); + sanitized = sanitized.replace(/[<>&'"]/g, ""); + return sanitized.trim(); } async function main() { - const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; - const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; - if (!outputContent) { - core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); - return; + const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; + const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; + if (!outputContent) { + core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); + return; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); + return; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + return; + } + const createIssueItems = validatedOutput.items.filter(item => item.type === "create-issue"); + if (createIssueItems.length === 0) { + core.info("No create-issue items found in agent output"); + return; + } + core.info(`Found ${createIssueItems.length} create-issue item(s)`); + if (isStaged) { + let summaryContent = "## 🎭 Staged Mode: Create Issues Preview\n\n"; + summaryContent += "The following issues would be created if staged mode was disabled:\n\n"; + for (let i = 0; i < createIssueItems.length; i++) { + const item = createIssueItems[i]; + summaryContent += `### Issue ${i + 1}\n`; + summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; + if (item.body) { + summaryContent += `**Body:**\n${item.body}\n\n`; + } + if (item.labels && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return; + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Issue creation preview written to step summary"); + return; + } + const parentIssueNumber = context.payload?.issue?.number; + const labelsEnv = process.env.GITHUB_AW_ISSUE_LABELS; + let envLabels = labelsEnv + ? labelsEnv + .split(",") + .map(label => label.trim()) + .filter(label => label) + : []; + const createdIssues = []; + for (let i = 0; i < createIssueItems.length; i++) { + const createIssueItem = createIssueItems[i]; + core.info( + `Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}` + ); + let labels = [...envLabels]; + if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { + labels = [...labels, ...createIssueItem.labels]; + } + labels = labels + .filter(label => label != null && label !== false && label !== 0) + .map(label => String(label).trim()) + .filter(label => label) + .map(label => sanitizeLabelContent(label)) + .filter(label => label) + .map(label => (label.length > 64 ? label.substring(0, 64) : label)) + .filter((label, index, arr) => arr.indexOf(label) === index); + let title = createIssueItem.title ? createIssueItem.title.trim() : ""; + let bodyLines = createIssueItem.body.split("\n"); + if (!title) { + title = createIssueItem.body || "Agent Output"; + } + const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; + if (titlePrefix && !title.startsWith(titlePrefix)) { + title = titlePrefix + title; + } + if (parentIssueNumber) { + core.info("Detected issue context, parent issue #" + parentIssueNumber); + bodyLines.push(`Related to #${parentIssueNumber}`); } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; + const workflowName = process.env.GITHUB_AW_WORKFLOW_NAME || "Workflow"; + const runId = context.runId; + const runUrl = context.payload.repository + ? `${context.payload.repository.html_url}/actions/runs/${runId}` + : `https://github.com/actions/runs/${runId}`; + bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); + const body = bodyLines.join("\n").trim(); + core.info(`Creating issue with title: ${title}`); + core.info(`Labels: ${labels}`); + core.info(`Body length: ${body.length}`); try { - validatedOutput = JSON.parse(outputContent); - } - catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - return; - } - const createIssueItems = validatedOutput.items.filter(item => item.type === "create-issue"); - if (createIssueItems.length === 0) { - core.info("No create-issue items found in agent output"); - return; - } - core.info(`Found ${createIssueItems.length} create-issue item(s)`); - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Issues Preview\n\n"; - summaryContent += "The following issues would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createIssueItems.length; i++) { - const item = createIssueItems[i]; - summaryContent += `### Issue ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; - } - if (item.labels && item.labels.length > 0) { - summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; - } - summaryContent += "---\n\n"; - } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Issue creation preview written to step summary"); - return; - } - const parentIssueNumber = context.payload?.issue?.number; - const labelsEnv = process.env.GITHUB_AW_ISSUE_LABELS; - let envLabels = labelsEnv - ? labelsEnv - .split(",") - .map((label) => label.trim()) - .filter((label) => label) - : []; - const createdIssues = []; - for (let i = 0; i < createIssueItems.length; i++) { - const createIssueItem = createIssueItems[i]; - core.info(`Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}`); - let labels = [...envLabels]; - if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { - labels = [...labels, ...createIssueItem.labels]; - } - labels = labels - .filter(label => label != null && label !== false && label !== 0) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - let title = createIssueItem.title ? createIssueItem.title.trim() : ""; - let bodyLines = createIssueItem.body.split("\n"); - if (!title) { - title = createIssueItem.body || "Agent Output"; - } - const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - if (parentIssueNumber) { - core.info("Detected issue context, parent issue #" + parentIssueNumber); - bodyLines.push(`Related to #${parentIssueNumber}`); - } - const workflowName = process.env.GITHUB_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const runUrl = context.payload.repository - ? `${context.payload.repository.html_url}/actions/runs/${runId}` - : `https://github.com/actions/runs/${runId}`; - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating issue with title: ${title}`); - core.info(`Labels: ${labels}`); - core.info(`Body length: ${body.length}`); + const { data: issue } = await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: title, + body: body, + labels: labels, + }); + core.info("Created issue #" + issue.number + ": " + issue.html_url); + createdIssues.push(issue); + if (parentIssueNumber) { try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: body, - labels: labels, - }); - core.info("Created issue #" + issue.number + ": " + issue.html_url); - createdIssues.push(issue); - if (parentIssueNumber) { - try { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: parentIssueNumber, - body: `Created related issue: #${issue.number}`, - }); - core.info("Added comment to parent issue #" + parentIssueNumber); - } - catch (error) { - core.info(`Warning: Could not add comment to parent issue: ${error instanceof Error ? error.message : String(error)}`); - } - } - if (i === createIssueItems.length - 1) { - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - } - } - catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Issues has been disabled in this repository")) { - core.info(`⚠ Cannot create issue "${title}": Issues are disabled for this repository`); - core.info("Consider enabling issues in repository settings if you want to create issues automatically"); - continue; - } - core.error(`✗ Failed to create issue "${title}": ${errorMessage}`); - throw error; + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: parentIssueNumber, + body: `Created related issue: #${issue.number}`, + }); + core.info("Added comment to parent issue #" + parentIssueNumber); + } catch (error) { + core.info(`Warning: Could not add comment to parent issue: ${error instanceof Error ? error.message : String(error)}`); } + } + if (i === createIssueItems.length - 1) { + core.setOutput("issue_number", issue.number); + core.setOutput("issue_url", issue.html_url); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage.includes("Issues has been disabled in this repository")) { + core.info(`⚠ Cannot create issue "${title}": Issues are disabled for this repository`); + core.info("Consider enabling issues in repository settings if you want to create issues automatically"); + continue; + } + core.error(`✗ Failed to create issue "${title}": ${errorMessage}`); + throw error; } - if (createdIssues.length > 0) { - let summaryContent = "\n\n## GitHub Issues\n"; - for (const issue of createdIssues) { - summaryContent += `- Issue #${issue.number}: [${issue.title}](${issue.html_url})\n`; - } - await core.summary.addRaw(summaryContent).write(); + } + if (createdIssues.length > 0) { + let summaryContent = "\n\n## GitHub Issues\n"; + for (const issue of createdIssues) { + summaryContent += `- Issue #${issue.number}: [${issue.title}](${issue.html_url})\n`; } - core.info(`Successfully created ${createdIssues.length} issue(s)`); + await core.summary.addRaw(summaryContent).write(); + } + core.info(`Successfully created ${createdIssues.length} issue(s)`); } (async () => { - await main(); + await main(); })(); add_comment: @@ -4332,203 +4334,200 @@ jobs: with: script: | function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); + if (!content || typeof content !== "string") { + return ""; + } + let sanitized = content.trim(); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); + sanitized = sanitized.replace(/[<>&'"]/g, ""); + return sanitized.trim(); } async function main() { - const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; - if (!outputContent) { - core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); - return; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return; - } - core.debug(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } - catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.warning("No valid items found in agent output"); - return; - } - const labelsItem = validatedOutput.items.find(item => item.type === "add-labels"); - if (!labelsItem) { - core.warning("No add-labels item found in agent output"); - return; - } - core.debug(`Found add-labels item with ${labelsItem.labels.length} labels`); - if (process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Add Labels Preview\n\n"; - summaryContent += "The following labels would be added if staged mode was disabled:\n\n"; - if (labelsItem.issue_number) { - summaryContent += `**Target Issue:** #${labelsItem.issue_number}\n\n`; - } - else { - summaryContent += `**Target:** Current issue/PR\n\n`; - } - if (labelsItem.labels && labelsItem.labels.length > 0) { - summaryContent += `**Labels to add:** ${labelsItem.labels.join(", ")}\n\n`; - } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Label addition preview written to step summary"); - return; + const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; + if (!outputContent) { + core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); + return; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return; + } + core.debug(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); + return; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.warning("No valid items found in agent output"); + return; + } + const labelsItem = validatedOutput.items.find(item => item.type === "add-labels"); + if (!labelsItem) { + core.warning("No add-labels item found in agent output"); + return; + } + core.debug(`Found add-labels item with ${labelsItem.labels.length} labels`); + if (process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true") { + let summaryContent = "## 🎭 Staged Mode: Add Labels Preview\n\n"; + summaryContent += "The following labels would be added if staged mode was disabled:\n\n"; + if (labelsItem.issue_number) { + summaryContent += `**Target Issue:** #${labelsItem.issue_number}\n\n`; + } else { + summaryContent += `**Target:** Current issue/PR\n\n`; } - const allowedLabelsEnv = process.env.GITHUB_AW_LABELS_ALLOWED?.trim(); - const allowedLabels = allowedLabelsEnv - ? allowedLabelsEnv - .split(",") - .map(label => label.trim()) - .filter(label => label) - : undefined; - if (allowedLabels) { - core.debug(`Allowed labels: ${JSON.stringify(allowedLabels)}`); - } - else { - core.debug("No label restrictions - any labels are allowed"); - } - const maxCountEnv = process.env.GITHUB_AW_LABELS_MAX_COUNT; - const maxCount = maxCountEnv ? parseInt(maxCountEnv, 10) : 3; - if (isNaN(maxCount) || maxCount < 1) { - core.setFailed(`Invalid max value: ${maxCountEnv}. Must be a positive integer`); - return; + if (labelsItem.labels && labelsItem.labels.length > 0) { + summaryContent += `**Labels to add:** ${labelsItem.labels.join(", ")}\n\n`; } - core.debug(`Max count: ${maxCount}`); - const labelsTarget = process.env.GITHUB_AW_LABELS_TARGET || "triggering"; - core.info(`Labels target configuration: ${labelsTarget}`); - const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; - const isPRContext = context.eventName === "pull_request" || - context.eventName === "pull_request_review" || - context.eventName === "pull_request_review_comment"; - if (labelsTarget === "triggering" && !isIssueContext && !isPRContext) { - core.info('Target is "triggering" but not running in issue or pull request context, skipping label addition'); + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Label addition preview written to step summary"); + return; + } + const allowedLabelsEnv = process.env.GITHUB_AW_LABELS_ALLOWED?.trim(); + const allowedLabels = allowedLabelsEnv + ? allowedLabelsEnv + .split(",") + .map(label => label.trim()) + .filter(label => label) + : undefined; + if (allowedLabels) { + core.debug(`Allowed labels: ${JSON.stringify(allowedLabels)}`); + } else { + core.debug("No label restrictions - any labels are allowed"); + } + const maxCountEnv = process.env.GITHUB_AW_LABELS_MAX_COUNT; + const maxCount = maxCountEnv ? parseInt(maxCountEnv, 10) : 3; + if (isNaN(maxCount) || maxCount < 1) { + core.setFailed(`Invalid max value: ${maxCountEnv}. Must be a positive integer`); + return; + } + core.debug(`Max count: ${maxCount}`); + const labelsTarget = process.env.GITHUB_AW_LABELS_TARGET || "triggering"; + core.info(`Labels target configuration: ${labelsTarget}`); + const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; + const isPRContext = + context.eventName === "pull_request" || + context.eventName === "pull_request_review" || + context.eventName === "pull_request_review_comment"; + if (labelsTarget === "triggering" && !isIssueContext && !isPRContext) { + core.info('Target is "triggering" but not running in issue or pull request context, skipping label addition'); + return; + } + let issueNumber; + let contextType; + if (labelsTarget === "*") { + if (labelsItem.issue_number) { + issueNumber = typeof labelsItem.issue_number === "number" ? labelsItem.issue_number : parseInt(String(labelsItem.issue_number), 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + core.setFailed(`Invalid issue number specified: ${labelsItem.issue_number}`); return; + } + contextType = "issue"; + } else { + core.setFailed('Target is "*" but no issue_number specified in labels item'); + return; } - let issueNumber; - let contextType; - if (labelsTarget === "*") { - if (labelsItem.issue_number) { - issueNumber = typeof labelsItem.issue_number === "number" ? labelsItem.issue_number : parseInt(String(labelsItem.issue_number), 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - core.setFailed(`Invalid issue number specified: ${labelsItem.issue_number}`); - return; - } - contextType = "issue"; - } - else { - core.setFailed('Target is "*" but no issue_number specified in labels item'); - return; - } + } else if (labelsTarget && labelsTarget !== "triggering") { + issueNumber = parseInt(labelsTarget, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + core.setFailed(`Invalid issue number in target configuration: ${labelsTarget}`); + return; } - else if (labelsTarget && labelsTarget !== "triggering") { - issueNumber = parseInt(labelsTarget, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - core.setFailed(`Invalid issue number in target configuration: ${labelsTarget}`); - return; - } + contextType = "issue"; + } else { + if (isIssueContext) { + if (context.payload.issue) { + issueNumber = context.payload.issue.number; contextType = "issue"; - } - else { - if (isIssueContext) { - if (context.payload.issue) { - issueNumber = context.payload.issue.number; - contextType = "issue"; - } - else { - core.setFailed("Issue context detected but no issue found in payload"); - return; - } - } - else if (isPRContext) { - if (context.payload.pull_request) { - issueNumber = context.payload.pull_request.number; - contextType = "pull request"; - } - else { - core.setFailed("Pull request context detected but no pull request found in payload"); - return; - } - } - } - if (!issueNumber) { - core.setFailed("Could not determine issue or pull request number"); + } else { + core.setFailed("Issue context detected but no issue found in payload"); return; + } + } else if (isPRContext) { + if (context.payload.pull_request) { + issueNumber = context.payload.pull_request.number; + contextType = "pull request"; + } else { + core.setFailed("Pull request context detected but no pull request found in payload"); + return; + } } - const requestedLabels = labelsItem.labels || []; - core.debug(`Requested labels: ${JSON.stringify(requestedLabels)}`); - for (const label of requestedLabels) { - if (label && typeof label === "string" && label.startsWith("-")) { - core.setFailed(`Label removal is not permitted. Found line starting with '-': ${label}`); - return; - } - } - let validLabels; - if (allowedLabels) { - validLabels = requestedLabels.filter(label => allowedLabels.includes(label)); - } - else { - validLabels = requestedLabels; + } + if (!issueNumber) { + core.setFailed("Could not determine issue or pull request number"); + return; + } + const requestedLabels = labelsItem.labels || []; + core.debug(`Requested labels: ${JSON.stringify(requestedLabels)}`); + for (const label of requestedLabels) { + if (label && typeof label === "string" && label.startsWith("-")) { + core.setFailed(`Label removal is not permitted. Found line starting with '-': ${label}`); + return; } - let uniqueLabels = validLabels - .filter(label => label != null && label !== false && label !== 0) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - if (uniqueLabels.length > maxCount) { - core.debug(`too many labels, keep ${maxCount}`); - uniqueLabels = uniqueLabels.slice(0, maxCount); - } - if (uniqueLabels.length === 0) { - core.info("No labels to add"); - core.setOutput("labels_added", ""); - await core.summary - .addRaw(` + } + let validLabels; + if (allowedLabels) { + validLabels = requestedLabels.filter(label => allowedLabels.includes(label)); + } else { + validLabels = requestedLabels; + } + let uniqueLabels = validLabels + .filter(label => label != null && label !== false && label !== 0) + .map(label => String(label).trim()) + .filter(label => label) + .map(label => sanitizeLabelContent(label)) + .filter(label => label) + .map(label => (label.length > 64 ? label.substring(0, 64) : label)) + .filter((label, index, arr) => arr.indexOf(label) === index); + if (uniqueLabels.length > maxCount) { + core.debug(`too many labels, keep ${maxCount}`); + uniqueLabels = uniqueLabels.slice(0, maxCount); + } + if (uniqueLabels.length === 0) { + core.info("No labels to add"); + core.setOutput("labels_added", ""); + await core.summary + .addRaw( + ` ## Label Addition No labels were added (no valid labels found in agent output). - `) - .write(); - return; - } - core.info(`Adding ${uniqueLabels.length} labels to ${contextType} #${issueNumber}: ${JSON.stringify(uniqueLabels)}`); - try { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - labels: uniqueLabels, - }); - core.info(`Successfully added ${uniqueLabels.length} labels to ${contextType} #${issueNumber}`); - core.setOutput("labels_added", uniqueLabels.join("\n")); - const labelsListMarkdown = uniqueLabels.map(label => `- \`${label}\``).join("\n"); - await core.summary - .addRaw(` + ` + ) + .write(); + return; + } + core.info(`Adding ${uniqueLabels.length} labels to ${contextType} #${issueNumber}: ${JSON.stringify(uniqueLabels)}`); + try { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + labels: uniqueLabels, + }); + core.info(`Successfully added ${uniqueLabels.length} labels to ${contextType} #${issueNumber}`); + core.setOutput("labels_added", uniqueLabels.join("\n")); + const labelsListMarkdown = uniqueLabels.map(label => `- \`${label}\``).join("\n"); + await core.summary + .addRaw( + ` ## Label Addition Successfully added ${uniqueLabels.length} label(s) to ${contextType} #${issueNumber}: ${labelsListMarkdown} - `) - .write(); - } - catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - core.error(`Failed to add labels: ${errorMessage}`); - core.setFailed(`Failed to add labels: ${errorMessage}`); - } + ` + ) + .write(); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + core.error(`Failed to add labels: ${errorMessage}`); + core.setFailed(`Failed to add labels: ${errorMessage}`); + } } await main(); diff --git a/.github/workflows/scout.lock.yml b/.github/workflows/scout.lock.yml index 06ebc7b1613..85cee61d8d1 100644 --- a/.github/workflows/scout.lock.yml +++ b/.github/workflows/scout.lock.yml @@ -1628,742 +1628,742 @@ jobs: with: script: | async function main() { - const fs = require("fs"); - function sanitizeContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; - const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; - const allowedDomains = allowedDomainsEnv - ? allowedDomainsEnv - .split(",") - .map(d => d.trim()) - .filter(d => d) - : defaultAllowedDomains; - let sanitized = content; - sanitized = neutralizeMentions(sanitized); - sanitized = removeXmlComments(sanitized); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitizeUrlProtocols(sanitized); - sanitized = sanitizeUrlDomains(sanitized); - const maxLength = 524288; - if (sanitized.length > maxLength) { - sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; - } - const lines = sanitized.split("\n"); - const maxLines = 65000; - if (lines.length > maxLines) { - sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; - } - sanitized = neutralizeBotTriggers(sanitized); - return sanitized.trim(); - function sanitizeUrlDomains(s) { - return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { - const urlAfterProtocol = match.slice(8); - const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); - const isAllowed = allowedDomains.some(allowedDomain => { - const normalizedAllowed = allowedDomain.toLowerCase(); - return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); - }); - return isAllowed ? match : "(redacted)"; - }); - } - function sanitizeUrlProtocols(s) { - return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { - return protocol.toLowerCase() === "https" ? match : "(redacted)"; - }); - } - function neutralizeMentions(s) { - return s.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - } - function removeXmlComments(s) { - return s.replace(//g, "").replace(//g, ""); - } - function neutralizeBotTriggers(s) { - return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); - } + const fs = require("fs"); + function sanitizeContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; + const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; + const allowedDomains = allowedDomainsEnv + ? allowedDomainsEnv + .split(",") + .map(d => d.trim()) + .filter(d => d) + : defaultAllowedDomains; + let sanitized = content; + sanitized = neutralizeMentions(sanitized); + sanitized = removeXmlComments(sanitized); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitizeUrlProtocols(sanitized); + sanitized = sanitizeUrlDomains(sanitized); + const maxLength = 524288; + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; + } + const lines = sanitized.split("\n"); + const maxLines = 65000; + if (lines.length > maxLines) { + sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; + } + sanitized = neutralizeBotTriggers(sanitized); + return sanitized.trim(); + function sanitizeUrlDomains(s) { + return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { + const urlAfterProtocol = match.slice(8); + const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); + const isAllowed = allowedDomains.some(allowedDomain => { + const normalizedAllowed = allowedDomain.toLowerCase(); + return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); + }); + return isAllowed ? match : "(redacted)"; + }); } - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create-issue": - return 1; - case "add-comment": - return 1; - case "create-pull-request": - return 1; - case "create-pull-request-review-comment": - return 1; - case "add-labels": - return 5; - case "update-issue": - return 1; - case "push-to-pull-request-branch": - return 1; - case "create-discussion": - return 1; - case "missing-tool": - return 1000; - case "create-code-scanning-alert": - return 1000; - case "upload-asset": - return 10; - default: - return 1; - } + function sanitizeUrlProtocols(s) { + return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { + return protocol.toLowerCase() === "https" ? match : "(redacted)"; + }); } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; - } - return 0; - } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } - else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } - else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; - } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; - } - return { isValid: true, normalizedValue: parsed }; + function neutralizeMentions(s) { + return s.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); + } + function removeXmlComments(s) { + return s.replace(//g, "").replace(//g, ""); + } + function neutralizeBotTriggers(s) { + return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); + } + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + switch (itemType) { + case "create-issue": + return 1; + case "add-comment": + return 1; + case "create-pull-request": + return 1; + case "create-pull-request-review-comment": + return 1; + case "add-labels": + return 5; + case "update-issue": + return 1; + case "push-to-pull-request-branch": + return 1; + case "create-discussion": + return 1; + case "missing-tool": + return 1000; + case "create-code-scanning-alert": + return 1000; + case "upload-asset": + return 10; + default: + return 1; } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; - } - return { isValid: true, normalizedValue: parsed }; + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - return { isValid: true }; - } - function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { - if (inputSchema.required && (value === undefined || value === null)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; - } - if (value === undefined || value === null) { - return { - isValid: true, - normalizedValue: inputSchema.default || undefined, - }; - } - const inputType = inputSchema.type || "string"; - let normalizedValue = value; - switch (inputType) { - case "string": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string`, - }; - } - normalizedValue = sanitizeContent(value); - break; - case "boolean": - if (typeof value !== "boolean") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a boolean`, - }; - } - break; - case "number": - if (typeof value !== "number") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number`, - }; - } - break; - case "choice": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, - }; - } - if (inputSchema.options && !inputSchema.options.includes(value)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, - }; - } - normalizedValue = sanitizeContent(value); - break; - default: - if (typeof value === "string") { - normalizedValue = sanitizeContent(value); - } - break; - } + return 0; + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { return { - isValid: true, - normalizedValue, + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; } - function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { - const errors = []; - const normalizedItem = { ...item }; - if (!jobConfig.inputs) { - return { - isValid: true, - errors: [], - normalizedItem: item, - }; - } - for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { - const fieldValue = item[fieldName]; - const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); - if (!validation.isValid && validation.error) { - errors.push(validation.error); - } - else if (validation.normalizedValue !== undefined) { - normalizedItem[fieldName] = validation.normalizedValue; - } - } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-code-scanning-alert 'line'")) { return { - isValid: errors.length === 0, - errors, - normalizedItem, + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; } - function parseJsonWithRepair(jsonStr) { - try { - return JSON.parse(jsonStr); - } - catch (originalError) { - try { - const repairedJson = repairJson(jsonStr); - return JSON.parse(repairedJson); - } - catch (repairError) { - core.info(`invalid input json: ${jsonStr}`); - const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); - const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); - throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); - } - } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; } - const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; - const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; - if (!outputFile) { - core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); - core.setOutput("output", ""); - return; + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; } - if (!fs.existsSync(outputFile)) { - core.info(`Output file does not exist: ${outputFile}`); - core.setOutput("output", ""); - return; + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; } - const outputContent = fs.readFileSync(outputFile, "utf8"); - if (outputContent.trim() === "") { - core.info("Output file is empty"); + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { + if (inputSchema.required && (value === undefined || value === null)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; } - core.info(`Raw output content length: ${outputContent.length}`); - let expectedOutputTypes = {}; - if (safeOutputsConfig) { - try { - expectedOutputTypes = JSON.parse(safeOutputsConfig); - core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); + if (value === undefined || value === null) { + return { + isValid: true, + normalizedValue: inputSchema.default || undefined, + }; + } + const inputType = inputSchema.type || "string"; + let normalizedValue = value; + switch (inputType) { + case "string": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string`, + }; + } + normalizedValue = sanitizeContent(value); + break; + case "boolean": + if (typeof value !== "boolean") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a boolean`, + }; } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); + break; + case "number": + if (typeof value !== "number") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number`, + }; } + break; + case "choice": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, + }; + } + if (inputSchema.options && !inputSchema.options.includes(value)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, + }; + } + normalizedValue = sanitizeContent(value); + break; + default: + if (typeof value === "string") { + normalizedValue = sanitizeContent(value); + } + break; } - const lines = outputContent.trim().split("\n"); - const parsedItems = []; + return { + isValid: true, + normalizedValue, + }; + } + function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { const errors = []; - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); - if (line === "") + const normalizedItem = { ...item }; + if (!jobConfig.inputs) { + return { + isValid: true, + errors: [], + normalizedItem: item, + }; + } + for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { + const fieldValue = item[fieldName]; + const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); + if (!validation.isValid && validation.error) { + errors.push(validation.error); + } else if (validation.normalizedValue !== undefined) { + normalizedItem[fieldName] = validation.normalizedValue; + } + } + return { + isValid: errors.length === 0, + errors, + normalizedItem, + }; + } + function parseJsonWithRepair(jsonStr) { + try { + return JSON.parse(jsonStr); + } catch (originalError) { + try { + const repairedJson = repairJson(jsonStr); + return JSON.parse(repairedJson); + } catch (repairError) { + core.info(`invalid input json: ${jsonStr}`); + const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); + const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); + throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); + } + } + } + const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; + const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; + if (!outputFile) { + core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); + core.setOutput("output", ""); + return; + } + if (!fs.existsSync(outputFile)) { + core.info(`Output file does not exist: ${outputFile}`); + core.setOutput("output", ""); + return; + } + const outputContent = fs.readFileSync(outputFile, "utf8"); + if (outputContent.trim() === "") { + core.info("Output file is empty"); + } + core.info(`Raw output content length: ${outputContent.length}`); + let expectedOutputTypes = {}; + if (safeOutputsConfig) { + try { + expectedOutputTypes = JSON.parse(safeOutputsConfig); + core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); + } + } + const lines = outputContent.trim().split("\n"); + const parsedItems = []; + const errors = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line === "") continue; + try { + const item = parseJsonWithRepair(line); + if (item === undefined) { + errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); + continue; + } + if (!item.type) { + errors.push(`Line ${i + 1}: Missing required 'type' field`); + continue; + } + const itemType = item.type; + if (!expectedOutputTypes[itemType]) { + errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); + continue; + } + const typeCount = parsedItems.filter(existing => existing.type === itemType).length; + const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); + if (typeCount >= maxAllowed) { + errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); + continue; + } + core.info(`Line ${i + 1}: type '${itemType}'`); + switch (itemType) { + case "create-issue": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); continue; - try { - const item = parseJsonWithRepair(line); - if (item === undefined) { - errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); - continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-comment": + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); + continue; + } + const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); + if (!issueNumValidation.isValid) { + if (issueNumValidation.error) errors.push(issueNumValidation.error); + continue; + } + item.body = sanitizeContent(item.body); + break; + case "create-pull-request": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); + continue; + } + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + item.branch = sanitizeContent(item.branch); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-labels": + if (!item.labels || !Array.isArray(item.labels)) { + errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); + continue; + } + if (item.labels.some(label => typeof label !== "string")) { + errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); + continue; + } + const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); + if (!labelsIssueNumValidation.isValid) { + if (labelsIssueNumValidation.error) errors.push(labelsIssueNumValidation.error); + continue; + } + item.labels = item.labels.map(label => sanitizeContent(label)); + break; + case "update-issue": + const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; + if (!hasValidField) { + errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); + continue; + } + if (item.status !== undefined) { + if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { + errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); + continue; } - if (!item.type) { - errors.push(`Line ${i + 1}: Missing required 'type' field`); - continue; + } + if (item.title !== undefined) { + if (typeof item.title !== "string") { + errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); + continue; } - const itemType = item.type; - if (!expectedOutputTypes[itemType]) { - errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); - continue; + item.title = sanitizeContent(item.title); + } + if (item.body !== undefined) { + if (typeof item.body !== "string") { + errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); + continue; } - const typeCount = parsedItems.filter(existing => existing.type === itemType).length; - const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); - if (typeCount >= maxAllowed) { - errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); - continue; + item.body = sanitizeContent(item.body); + } + const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); + if (!updateIssueNumValidation.isValid) { + if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); + continue; + } + break; + case "push-to-pull-request-branch": + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); + continue; + } + item.branch = sanitizeContent(item.branch); + item.message = sanitizeContent(item.message); + const pushPRNumValidation = validateIssueOrPRNumber( + item.pull_request_number, + "push-to-pull-request-branch 'pull_request_number'", + i + 1 + ); + if (!pushPRNumValidation.isValid) { + if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); + continue; + } + break; + case "create-pull-request-review-comment": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); + continue; + } + const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); + if (!lineValidation.isValid) { + if (lineValidation.error) errors.push(lineValidation.error); + continue; + } + const lineNumber = lineValidation.normalizedValue; + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); + continue; + } + item.body = sanitizeContent(item.body); + const startLineValidation = validateOptionalPositiveInteger( + item.start_line, + "create-pull-request-review-comment 'start_line'", + i + 1 + ); + if (!startLineValidation.isValid) { + if (startLineValidation.error) errors.push(startLineValidation.error); + continue; + } + if ( + startLineValidation.normalizedValue !== undefined && + lineNumber !== undefined && + startLineValidation.normalizedValue > lineNumber + ) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); + continue; + } + if (item.side !== undefined) { + if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); + continue; } - core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create-issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); - if (!issueNumValidation.isValid) { - if (issueNumValidation.error) - errors.push(issueNumValidation.error); - continue; - } - item.body = sanitizeContent(item.body); - break; - case "create-pull-request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - item.branch = sanitizeContent(item.branch); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some((label) => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); - if (!labelsIssueNumValidation.isValid) { - if (labelsIssueNumValidation.error) - errors.push(labelsIssueNumValidation.error); - continue; - } - item.labels = item.labels.map((label) => sanitizeContent(label)); - break; - case "update-issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) - errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "push-to-pull-request-branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch); - item.message = sanitizeContent(item.message); - const pushPRNumValidation = validateIssueOrPRNumber(item.pull_request_number, "push-to-pull-request-branch 'pull_request_number'", i + 1); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) - errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create-pull-request-review-comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) - errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body); - const startLineValidation = validateOptionalPositiveInteger(item.start_line, "create-pull-request-review-comment 'start_line'", i + 1); - if (!startLineValidation.isValid) { - if (startLineValidation.error) - errors.push(startLineValidation.error); - continue; - } - if (startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create-discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category); - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - break; - case "missing-tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool); - item.reason = sanitizeContent(item.reason); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives); - } - break; - case "upload-asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "create-code-scanning-alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}`); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) - errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores`); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file); - item.severity = sanitizeContent(item.severity); - item.message = sanitizeContent(item.message); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); - continue; - } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + } + break; + case "create-discussion": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); + continue; + } + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); + continue; } - core.info(`Line ${i + 1}: Valid ${itemType} item`); - parsedItems.push(item); - } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); - } - } - if (errors.length > 0) { - core.warning("Validation errors found:"); - errors.forEach(error => core.warning(` - ${error}`)); - if (parsedItems.length === 0) { - core.setFailed(errors.map(e => ` - ${e}`).join("\n")); - return; - } - } - for (const itemType of Object.keys(expectedOutputTypes)) { - const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); - if (minRequired > 0) { - const actualCount = parsedItems.filter(item => item.type === itemType).length; - if (actualCount < minRequired) { - errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); + item.category = sanitizeContent(item.category); + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + break; + case "missing-tool": + if (!item.tool || typeof item.tool !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); + continue; + } + if (!item.reason || typeof item.reason !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); + continue; + } + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); + continue; } - } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "upload-asset": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); + continue; + } + break; + case "create-code-scanning-alert": + if (!item.file || typeof item.file !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); + continue; + } + const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); + if (!alertLineValidation.isValid) { + if (alertLineValidation.error) { + errors.push(alertLineValidation.error); + } + continue; + } + if (!item.severity || typeof item.severity !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); + continue; + } + const allowedSeverities = ["error", "warning", "info", "note"]; + if (!allowedSeverities.includes(item.severity.toLowerCase())) { + errors.push( + `Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` + ); + continue; + } + const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); + if (!columnValidation.isValid) { + if (columnValidation.error) errors.push(columnValidation.error); + continue; + } + if (item.ruleIdSuffix !== undefined) { + if (typeof item.ruleIdSuffix !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); + continue; + } + if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { + errors.push( + `Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` + ); + continue; + } + } + item.severity = item.severity.toLowerCase(); + item.file = sanitizeContent(item.file); + item.severity = sanitizeContent(item.severity); + item.message = sanitizeContent(item.message); + if (item.ruleIdSuffix) { + item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); + } + break; + default: + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); + continue; + } + Object.assign(item, validation.normalizedItem); + } + break; + } + core.info(`Line ${i + 1}: Valid ${itemType} item`); + parsedItems.push(item); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); } - core.info(`Successfully parsed ${parsedItems.length} valid output items`); - const validatedOutput = { - items: parsedItems, - errors: errors, - }; - const agentOutputFile = "/tmp/agent_output.json"; - const validatedOutputJson = JSON.stringify(validatedOutput); - try { - fs.mkdirSync("/tmp", { recursive: true }); - fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); - core.info(`Stored validated output to: ${agentOutputFile}`); - core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); - } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.error(`Failed to write agent output file: ${errorMsg}`); - } - core.setOutput("output", JSON.stringify(validatedOutput)); - core.setOutput("raw_output", outputContent); - const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); - core.info(`output_types: ${outputTypes.join(", ")}`); - core.setOutput("output_types", outputTypes.join(",")); - try { - await core.summary - .addRaw("## Processed Output\n\n") - .addRaw("```json\n") - .addRaw(JSON.stringify(validatedOutput)) - .addRaw("\n```\n") - .write(); - core.info("Successfully wrote processed output to step summary"); - } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.warning(`Failed to write to step summary: ${errorMsg}`); + } + if (errors.length > 0) { + core.warning("Validation errors found:"); + errors.forEach(error => core.warning(` - ${error}`)); + if (parsedItems.length === 0) { + core.setFailed(errors.map(e => ` - ${e}`).join("\n")); + return; } + } + for (const itemType of Object.keys(expectedOutputTypes)) { + const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); + if (minRequired > 0) { + const actualCount = parsedItems.filter(item => item.type === itemType).length; + if (actualCount < minRequired) { + errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); + } + } + } + core.info(`Successfully parsed ${parsedItems.length} valid output items`); + const validatedOutput = { + items: parsedItems, + errors: errors, + }; + const agentOutputFile = "/tmp/agent_output.json"; + const validatedOutputJson = JSON.stringify(validatedOutput); + try { + fs.mkdirSync("/tmp", { recursive: true }); + fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); + core.info(`Stored validated output to: ${agentOutputFile}`); + core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.error(`Failed to write agent output file: ${errorMsg}`); + } + core.setOutput("output", JSON.stringify(validatedOutput)); + core.setOutput("raw_output", outputContent); + const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); + core.info(`output_types: ${outputTypes.join(", ")}`); + core.setOutput("output_types", outputTypes.join(",")); + try { + await core.summary + .addRaw("## Processed Output\n\n") + .addRaw("```json\n") + .addRaw(JSON.stringify(validatedOutput)) + .addRaw("\n```\n") + .write(); + core.info("Successfully wrote processed output to step summary"); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.warning(`Failed to write to step summary: ${errorMsg}`); + } } await main(); - name: Upload sanitized agent output diff --git a/.github/workflows/technical-doc-writer.lock.yml b/.github/workflows/technical-doc-writer.lock.yml index 98fcb0a8454..a81fedcfc5f 100644 --- a/.github/workflows/technical-doc-writer.lock.yml +++ b/.github/workflows/technical-doc-writer.lock.yml @@ -1433,742 +1433,742 @@ jobs: with: script: | async function main() { - const fs = require("fs"); - function sanitizeContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; - const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; - const allowedDomains = allowedDomainsEnv - ? allowedDomainsEnv - .split(",") - .map(d => d.trim()) - .filter(d => d) - : defaultAllowedDomains; - let sanitized = content; - sanitized = neutralizeMentions(sanitized); - sanitized = removeXmlComments(sanitized); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitizeUrlProtocols(sanitized); - sanitized = sanitizeUrlDomains(sanitized); - const maxLength = 524288; - if (sanitized.length > maxLength) { - sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; - } - const lines = sanitized.split("\n"); - const maxLines = 65000; - if (lines.length > maxLines) { - sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; - } - sanitized = neutralizeBotTriggers(sanitized); - return sanitized.trim(); - function sanitizeUrlDomains(s) { - return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { - const urlAfterProtocol = match.slice(8); - const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); - const isAllowed = allowedDomains.some(allowedDomain => { - const normalizedAllowed = allowedDomain.toLowerCase(); - return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); - }); - return isAllowed ? match : "(redacted)"; - }); - } - function sanitizeUrlProtocols(s) { - return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { - return protocol.toLowerCase() === "https" ? match : "(redacted)"; - }); - } - function neutralizeMentions(s) { - return s.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - } - function removeXmlComments(s) { - return s.replace(//g, "").replace(//g, ""); - } - function neutralizeBotTriggers(s) { - return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); - } + const fs = require("fs"); + function sanitizeContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; + const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; + const allowedDomains = allowedDomainsEnv + ? allowedDomainsEnv + .split(",") + .map(d => d.trim()) + .filter(d => d) + : defaultAllowedDomains; + let sanitized = content; + sanitized = neutralizeMentions(sanitized); + sanitized = removeXmlComments(sanitized); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitizeUrlProtocols(sanitized); + sanitized = sanitizeUrlDomains(sanitized); + const maxLength = 524288; + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; + } + const lines = sanitized.split("\n"); + const maxLines = 65000; + if (lines.length > maxLines) { + sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; + } + sanitized = neutralizeBotTriggers(sanitized); + return sanitized.trim(); + function sanitizeUrlDomains(s) { + return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { + const urlAfterProtocol = match.slice(8); + const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); + const isAllowed = allowedDomains.some(allowedDomain => { + const normalizedAllowed = allowedDomain.toLowerCase(); + return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); + }); + return isAllowed ? match : "(redacted)"; + }); } - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create-issue": - return 1; - case "add-comment": - return 1; - case "create-pull-request": - return 1; - case "create-pull-request-review-comment": - return 1; - case "add-labels": - return 5; - case "update-issue": - return 1; - case "push-to-pull-request-branch": - return 1; - case "create-discussion": - return 1; - case "missing-tool": - return 1000; - case "create-code-scanning-alert": - return 1000; - case "upload-asset": - return 10; - default: - return 1; - } + function sanitizeUrlProtocols(s) { + return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { + return protocol.toLowerCase() === "https" ? match : "(redacted)"; + }); } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; - } - return 0; - } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } - else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } - else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; - } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; - } - return { isValid: true, normalizedValue: parsed }; + function neutralizeMentions(s) { + return s.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; - } - return { isValid: true, normalizedValue: parsed }; + function removeXmlComments(s) { + return s.replace(//g, "").replace(//g, ""); + } + function neutralizeBotTriggers(s) { + return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); + } + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + switch (itemType) { + case "create-issue": + return 1; + case "add-comment": + return 1; + case "create-pull-request": + return 1; + case "create-pull-request-review-comment": + return 1; + case "add-labels": + return 5; + case "update-issue": + return 1; + case "push-to-pull-request-branch": + return 1; + case "create-discussion": + return 1; + case "missing-tool": + return 1000; + case "create-code-scanning-alert": + return 1000; + case "upload-asset": + return 10; + default: + return 1; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - return { isValid: true }; - } - function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { - if (inputSchema.required && (value === undefined || value === null)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; - } - if (value === undefined || value === null) { - return { - isValid: true, - normalizedValue: inputSchema.default || undefined, - }; - } - const inputType = inputSchema.type || "string"; - let normalizedValue = value; - switch (inputType) { - case "string": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string`, - }; - } - normalizedValue = sanitizeContent(value); - break; - case "boolean": - if (typeof value !== "boolean") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a boolean`, - }; - } - break; - case "number": - if (typeof value !== "number") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number`, - }; - } - break; - case "choice": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, - }; - } - if (inputSchema.options && !inputSchema.options.includes(value)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, - }; - } - normalizedValue = sanitizeContent(value); - break; - default: - if (typeof value === "string") { - normalizedValue = sanitizeContent(value); - } - break; - } + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; + } + return 0; + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { return { - isValid: true, - normalizedValue, + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; } - function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { - const errors = []; - const normalizedItem = { ...item }; - if (!jobConfig.inputs) { - return { - isValid: true, - errors: [], - normalizedItem: item, - }; - } - for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { - const fieldValue = item[fieldName]; - const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); - if (!validation.isValid && validation.error) { - errors.push(validation.error); - } - else if (validation.normalizedValue !== undefined) { - normalizedItem[fieldName] = validation.normalizedValue; - } - } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { return { - isValid: errors.length === 0, - errors, - normalizedItem, + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; } - function parseJsonWithRepair(jsonStr) { - try { - return JSON.parse(jsonStr); - } - catch (originalError) { - try { - const repairedJson = repairJson(jsonStr); - return JSON.parse(repairedJson); - } - catch (repairError) { - core.info(`invalid input json: ${jsonStr}`); - const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); - const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); - throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); - } - } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; } - const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; - const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; - if (!outputFile) { - core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); - core.setOutput("output", ""); - return; + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; } - if (!fs.existsSync(outputFile)) { - core.info(`Output file does not exist: ${outputFile}`); - core.setOutput("output", ""); - return; + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; } - const outputContent = fs.readFileSync(outputFile, "utf8"); - if (outputContent.trim() === "") { - core.info("Output file is empty"); + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; } - core.info(`Raw output content length: ${outputContent.length}`); - let expectedOutputTypes = {}; - if (safeOutputsConfig) { - try { - expectedOutputTypes = JSON.parse(safeOutputsConfig); - core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + return { isValid: true }; + } + function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { + if (inputSchema.required && (value === undefined || value === null)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (value === undefined || value === null) { + return { + isValid: true, + normalizedValue: inputSchema.default || undefined, + }; + } + const inputType = inputSchema.type || "string"; + let normalizedValue = value; + switch (inputType) { + case "string": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string`, + }; + } + normalizedValue = sanitizeContent(value); + break; + case "boolean": + if (typeof value !== "boolean") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a boolean`, + }; + } + break; + case "number": + if (typeof value !== "number") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number`, + }; + } + break; + case "choice": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, + }; } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); + if (inputSchema.options && !inputSchema.options.includes(value)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, + }; } + normalizedValue = sanitizeContent(value); + break; + default: + if (typeof value === "string") { + normalizedValue = sanitizeContent(value); + } + break; } - const lines = outputContent.trim().split("\n"); - const parsedItems = []; + return { + isValid: true, + normalizedValue, + }; + } + function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { const errors = []; - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); - if (line === "") + const normalizedItem = { ...item }; + if (!jobConfig.inputs) { + return { + isValid: true, + errors: [], + normalizedItem: item, + }; + } + for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { + const fieldValue = item[fieldName]; + const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); + if (!validation.isValid && validation.error) { + errors.push(validation.error); + } else if (validation.normalizedValue !== undefined) { + normalizedItem[fieldName] = validation.normalizedValue; + } + } + return { + isValid: errors.length === 0, + errors, + normalizedItem, + }; + } + function parseJsonWithRepair(jsonStr) { + try { + return JSON.parse(jsonStr); + } catch (originalError) { + try { + const repairedJson = repairJson(jsonStr); + return JSON.parse(repairedJson); + } catch (repairError) { + core.info(`invalid input json: ${jsonStr}`); + const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); + const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); + throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); + } + } + } + const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; + const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; + if (!outputFile) { + core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); + core.setOutput("output", ""); + return; + } + if (!fs.existsSync(outputFile)) { + core.info(`Output file does not exist: ${outputFile}`); + core.setOutput("output", ""); + return; + } + const outputContent = fs.readFileSync(outputFile, "utf8"); + if (outputContent.trim() === "") { + core.info("Output file is empty"); + } + core.info(`Raw output content length: ${outputContent.length}`); + let expectedOutputTypes = {}; + if (safeOutputsConfig) { + try { + expectedOutputTypes = JSON.parse(safeOutputsConfig); + core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); + } + } + const lines = outputContent.trim().split("\n"); + const parsedItems = []; + const errors = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line === "") continue; + try { + const item = parseJsonWithRepair(line); + if (item === undefined) { + errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); + continue; + } + if (!item.type) { + errors.push(`Line ${i + 1}: Missing required 'type' field`); + continue; + } + const itemType = item.type; + if (!expectedOutputTypes[itemType]) { + errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); + continue; + } + const typeCount = parsedItems.filter(existing => existing.type === itemType).length; + const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); + if (typeCount >= maxAllowed) { + errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); + continue; + } + core.info(`Line ${i + 1}: type '${itemType}'`); + switch (itemType) { + case "create-issue": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); continue; - try { - const item = parseJsonWithRepair(line); - if (item === undefined) { - errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); - continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-comment": + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); + continue; + } + const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); + if (!issueNumValidation.isValid) { + if (issueNumValidation.error) errors.push(issueNumValidation.error); + continue; + } + item.body = sanitizeContent(item.body); + break; + case "create-pull-request": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); + continue; + } + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + item.branch = sanitizeContent(item.branch); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-labels": + if (!item.labels || !Array.isArray(item.labels)) { + errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); + continue; + } + if (item.labels.some(label => typeof label !== "string")) { + errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); + continue; + } + const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); + if (!labelsIssueNumValidation.isValid) { + if (labelsIssueNumValidation.error) errors.push(labelsIssueNumValidation.error); + continue; + } + item.labels = item.labels.map(label => sanitizeContent(label)); + break; + case "update-issue": + const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; + if (!hasValidField) { + errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); + continue; + } + if (item.status !== undefined) { + if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { + errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); + continue; } - if (!item.type) { - errors.push(`Line ${i + 1}: Missing required 'type' field`); - continue; + } + if (item.title !== undefined) { + if (typeof item.title !== "string") { + errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); + continue; } - const itemType = item.type; - if (!expectedOutputTypes[itemType]) { - errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); - continue; + item.title = sanitizeContent(item.title); + } + if (item.body !== undefined) { + if (typeof item.body !== "string") { + errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); + continue; } - const typeCount = parsedItems.filter(existing => existing.type === itemType).length; - const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); - if (typeCount >= maxAllowed) { - errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); - continue; + item.body = sanitizeContent(item.body); + } + const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); + if (!updateIssueNumValidation.isValid) { + if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); + continue; + } + break; + case "push-to-pull-request-branch": + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); + continue; + } + item.branch = sanitizeContent(item.branch); + item.message = sanitizeContent(item.message); + const pushPRNumValidation = validateIssueOrPRNumber( + item.pull_request_number, + "push-to-pull-request-branch 'pull_request_number'", + i + 1 + ); + if (!pushPRNumValidation.isValid) { + if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); + continue; + } + break; + case "create-pull-request-review-comment": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); + continue; + } + const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); + if (!lineValidation.isValid) { + if (lineValidation.error) errors.push(lineValidation.error); + continue; + } + const lineNumber = lineValidation.normalizedValue; + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); + continue; + } + item.body = sanitizeContent(item.body); + const startLineValidation = validateOptionalPositiveInteger( + item.start_line, + "create-pull-request-review-comment 'start_line'", + i + 1 + ); + if (!startLineValidation.isValid) { + if (startLineValidation.error) errors.push(startLineValidation.error); + continue; + } + if ( + startLineValidation.normalizedValue !== undefined && + lineNumber !== undefined && + startLineValidation.normalizedValue > lineNumber + ) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); + continue; + } + if (item.side !== undefined) { + if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); + continue; } - core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create-issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); - if (!issueNumValidation.isValid) { - if (issueNumValidation.error) - errors.push(issueNumValidation.error); - continue; - } - item.body = sanitizeContent(item.body); - break; - case "create-pull-request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - item.branch = sanitizeContent(item.branch); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some((label) => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); - if (!labelsIssueNumValidation.isValid) { - if (labelsIssueNumValidation.error) - errors.push(labelsIssueNumValidation.error); - continue; - } - item.labels = item.labels.map((label) => sanitizeContent(label)); - break; - case "update-issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) - errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "push-to-pull-request-branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch); - item.message = sanitizeContent(item.message); - const pushPRNumValidation = validateIssueOrPRNumber(item.pull_request_number, "push-to-pull-request-branch 'pull_request_number'", i + 1); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) - errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create-pull-request-review-comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) - errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body); - const startLineValidation = validateOptionalPositiveInteger(item.start_line, "create-pull-request-review-comment 'start_line'", i + 1); - if (!startLineValidation.isValid) { - if (startLineValidation.error) - errors.push(startLineValidation.error); - continue; - } - if (startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create-discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category); - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - break; - case "missing-tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool); - item.reason = sanitizeContent(item.reason); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives); - } - break; - case "upload-asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "create-code-scanning-alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}`); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) - errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores`); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file); - item.severity = sanitizeContent(item.severity); - item.message = sanitizeContent(item.message); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); - continue; - } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + } + break; + case "create-discussion": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); + continue; + } + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); + continue; } - core.info(`Line ${i + 1}: Valid ${itemType} item`); - parsedItems.push(item); - } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); - } - } - if (errors.length > 0) { - core.warning("Validation errors found:"); - errors.forEach(error => core.warning(` - ${error}`)); - if (parsedItems.length === 0) { - core.setFailed(errors.map(e => ` - ${e}`).join("\n")); - return; - } - } - for (const itemType of Object.keys(expectedOutputTypes)) { - const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); - if (minRequired > 0) { - const actualCount = parsedItems.filter(item => item.type === itemType).length; - if (actualCount < minRequired) { - errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); + item.category = sanitizeContent(item.category); + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + break; + case "missing-tool": + if (!item.tool || typeof item.tool !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); + continue; + } + if (!item.reason || typeof item.reason !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); + continue; + } + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); + continue; } - } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "upload-asset": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); + continue; + } + break; + case "create-code-scanning-alert": + if (!item.file || typeof item.file !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); + continue; + } + const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); + if (!alertLineValidation.isValid) { + if (alertLineValidation.error) { + errors.push(alertLineValidation.error); + } + continue; + } + if (!item.severity || typeof item.severity !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); + continue; + } + const allowedSeverities = ["error", "warning", "info", "note"]; + if (!allowedSeverities.includes(item.severity.toLowerCase())) { + errors.push( + `Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` + ); + continue; + } + const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); + if (!columnValidation.isValid) { + if (columnValidation.error) errors.push(columnValidation.error); + continue; + } + if (item.ruleIdSuffix !== undefined) { + if (typeof item.ruleIdSuffix !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); + continue; + } + if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { + errors.push( + `Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` + ); + continue; + } + } + item.severity = item.severity.toLowerCase(); + item.file = sanitizeContent(item.file); + item.severity = sanitizeContent(item.severity); + item.message = sanitizeContent(item.message); + if (item.ruleIdSuffix) { + item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); + } + break; + default: + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); + continue; + } + Object.assign(item, validation.normalizedItem); + } + break; + } + core.info(`Line ${i + 1}: Valid ${itemType} item`); + parsedItems.push(item); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); } - core.info(`Successfully parsed ${parsedItems.length} valid output items`); - const validatedOutput = { - items: parsedItems, - errors: errors, - }; - const agentOutputFile = "/tmp/agent_output.json"; - const validatedOutputJson = JSON.stringify(validatedOutput); - try { - fs.mkdirSync("/tmp", { recursive: true }); - fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); - core.info(`Stored validated output to: ${agentOutputFile}`); - core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); - } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.error(`Failed to write agent output file: ${errorMsg}`); - } - core.setOutput("output", JSON.stringify(validatedOutput)); - core.setOutput("raw_output", outputContent); - const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); - core.info(`output_types: ${outputTypes.join(", ")}`); - core.setOutput("output_types", outputTypes.join(",")); - try { - await core.summary - .addRaw("## Processed Output\n\n") - .addRaw("```json\n") - .addRaw(JSON.stringify(validatedOutput)) - .addRaw("\n```\n") - .write(); - core.info("Successfully wrote processed output to step summary"); + } + if (errors.length > 0) { + core.warning("Validation errors found:"); + errors.forEach(error => core.warning(` - ${error}`)); + if (parsedItems.length === 0) { + core.setFailed(errors.map(e => ` - ${e}`).join("\n")); + return; } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.warning(`Failed to write to step summary: ${errorMsg}`); + } + for (const itemType of Object.keys(expectedOutputTypes)) { + const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); + if (minRequired > 0) { + const actualCount = parsedItems.filter(item => item.type === itemType).length; + if (actualCount < minRequired) { + errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); + } } + } + core.info(`Successfully parsed ${parsedItems.length} valid output items`); + const validatedOutput = { + items: parsedItems, + errors: errors, + }; + const agentOutputFile = "/tmp/agent_output.json"; + const validatedOutputJson = JSON.stringify(validatedOutput); + try { + fs.mkdirSync("/tmp", { recursive: true }); + fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); + core.info(`Stored validated output to: ${agentOutputFile}`); + core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.error(`Failed to write agent output file: ${errorMsg}`); + } + core.setOutput("output", JSON.stringify(validatedOutput)); + core.setOutput("raw_output", outputContent); + const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); + core.info(`output_types: ${outputTypes.join(", ")}`); + core.setOutput("output_types", outputTypes.join(",")); + try { + await core.summary + .addRaw("## Processed Output\n\n") + .addRaw("```json\n") + .addRaw(JSON.stringify(validatedOutput)) + .addRaw("\n```\n") + .write(); + core.info("Successfully wrote processed output to step summary"); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.warning(`Failed to write to step summary: ${errorMsg}`); + } } await main(); - name: Upload sanitized agent output diff --git a/.github/workflows/tidy.lock.yml b/.github/workflows/tidy.lock.yml index e511276d344..ad483863f8c 100644 --- a/.github/workflows/tidy.lock.yml +++ b/.github/workflows/tidy.lock.yml @@ -1370,742 +1370,742 @@ jobs: with: script: | async function main() { - const fs = require("fs"); - function sanitizeContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; - const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; - const allowedDomains = allowedDomainsEnv - ? allowedDomainsEnv - .split(",") - .map(d => d.trim()) - .filter(d => d) - : defaultAllowedDomains; - let sanitized = content; - sanitized = neutralizeMentions(sanitized); - sanitized = removeXmlComments(sanitized); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitizeUrlProtocols(sanitized); - sanitized = sanitizeUrlDomains(sanitized); - const maxLength = 524288; - if (sanitized.length > maxLength) { - sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; - } - const lines = sanitized.split("\n"); - const maxLines = 65000; - if (lines.length > maxLines) { - sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; - } - sanitized = neutralizeBotTriggers(sanitized); - return sanitized.trim(); - function sanitizeUrlDomains(s) { - return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { - const urlAfterProtocol = match.slice(8); - const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); - const isAllowed = allowedDomains.some(allowedDomain => { - const normalizedAllowed = allowedDomain.toLowerCase(); - return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); - }); - return isAllowed ? match : "(redacted)"; - }); - } - function sanitizeUrlProtocols(s) { - return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { - return protocol.toLowerCase() === "https" ? match : "(redacted)"; - }); - } - function neutralizeMentions(s) { - return s.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - } - function removeXmlComments(s) { - return s.replace(//g, "").replace(//g, ""); - } - function neutralizeBotTriggers(s) { - return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); - } + const fs = require("fs"); + function sanitizeContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; + const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; + const allowedDomains = allowedDomainsEnv + ? allowedDomainsEnv + .split(",") + .map(d => d.trim()) + .filter(d => d) + : defaultAllowedDomains; + let sanitized = content; + sanitized = neutralizeMentions(sanitized); + sanitized = removeXmlComments(sanitized); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitizeUrlProtocols(sanitized); + sanitized = sanitizeUrlDomains(sanitized); + const maxLength = 524288; + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; + } + const lines = sanitized.split("\n"); + const maxLines = 65000; + if (lines.length > maxLines) { + sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; + } + sanitized = neutralizeBotTriggers(sanitized); + return sanitized.trim(); + function sanitizeUrlDomains(s) { + return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { + const urlAfterProtocol = match.slice(8); + const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); + const isAllowed = allowedDomains.some(allowedDomain => { + const normalizedAllowed = allowedDomain.toLowerCase(); + return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); + }); + return isAllowed ? match : "(redacted)"; + }); } - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create-issue": - return 1; - case "add-comment": - return 1; - case "create-pull-request": - return 1; - case "create-pull-request-review-comment": - return 1; - case "add-labels": - return 5; - case "update-issue": - return 1; - case "push-to-pull-request-branch": - return 1; - case "create-discussion": - return 1; - case "missing-tool": - return 1000; - case "create-code-scanning-alert": - return 1000; - case "upload-asset": - return 10; - default: - return 1; - } + function sanitizeUrlProtocols(s) { + return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { + return protocol.toLowerCase() === "https" ? match : "(redacted)"; + }); } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; - } - return 0; - } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; - }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } - else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } - else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; - } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; - } - return { isValid: true, normalizedValue: parsed }; + function neutralizeMentions(s) { + return s.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; - } - return { isValid: true, normalizedValue: parsed }; + function removeXmlComments(s) { + return s.replace(//g, "").replace(//g, ""); + } + function neutralizeBotTriggers(s) { + return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); + } + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + switch (itemType) { + case "create-issue": + return 1; + case "add-comment": + return 1; + case "create-pull-request": + return 1; + case "create-pull-request-review-comment": + return 1; + case "add-labels": + return 5; + case "update-issue": + return 1; + case "push-to-pull-request-branch": + return 1; + case "create-discussion": + return 1; + case "missing-tool": + return 1000; + case "create-code-scanning-alert": + return 1000; + case "upload-asset": + return 10; + default: + return 1; } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - return { isValid: true }; - } - function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { - if (inputSchema.required && (value === undefined || value === null)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; - } - if (value === undefined || value === null) { - return { - isValid: true, - normalizedValue: inputSchema.default || undefined, - }; - } - const inputType = inputSchema.type || "string"; - let normalizedValue = value; - switch (inputType) { - case "string": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string`, - }; - } - normalizedValue = sanitizeContent(value); - break; - case "boolean": - if (typeof value !== "boolean") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a boolean`, - }; - } - break; - case "number": - if (typeof value !== "number") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number`, - }; - } - break; - case "choice": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, - }; - } - if (inputSchema.options && !inputSchema.options.includes(value)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, - }; - } - normalizedValue = sanitizeContent(value); - break; - default: - if (typeof value === "string") { - normalizedValue = sanitizeContent(value); - } - break; - } + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; + } + return 0; + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { return { - isValid: true, - normalizedValue, + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; } - function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { - const errors = []; - const normalizedItem = { ...item }; - if (!jobConfig.inputs) { - return { - isValid: true, - errors: [], - normalizedItem: item, - }; - } - for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { - const fieldValue = item[fieldName]; - const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); - if (!validation.isValid && validation.error) { - errors.push(validation.error); - } - else if (validation.normalizedValue !== undefined) { - normalizedItem[fieldName] = validation.normalizedValue; - } - } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { return { - isValid: errors.length === 0, - errors, - normalizedItem, + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; } - function parseJsonWithRepair(jsonStr) { - try { - return JSON.parse(jsonStr); - } - catch (originalError) { - try { - const repairedJson = repairJson(jsonStr); - return JSON.parse(repairedJson); - } - catch (repairError) { - core.info(`invalid input json: ${jsonStr}`); - const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); - const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); - throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); - } - } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; } - const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; - const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; - if (!outputFile) { - core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); - core.setOutput("output", ""); - return; + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; } - if (!fs.existsSync(outputFile)) { - core.info(`Output file does not exist: ${outputFile}`); - core.setOutput("output", ""); - return; + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; + } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; } - const outputContent = fs.readFileSync(outputFile, "utf8"); - if (outputContent.trim() === "") { - core.info("Output file is empty"); + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; } - core.info(`Raw output content length: ${outputContent.length}`); - let expectedOutputTypes = {}; - if (safeOutputsConfig) { - try { - expectedOutputTypes = JSON.parse(safeOutputsConfig); - core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); + return { isValid: true }; + } + function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { + if (inputSchema.required && (value === undefined || value === null)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; + } + if (value === undefined || value === null) { + return { + isValid: true, + normalizedValue: inputSchema.default || undefined, + }; + } + const inputType = inputSchema.type || "string"; + let normalizedValue = value; + switch (inputType) { + case "string": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string`, + }; + } + normalizedValue = sanitizeContent(value); + break; + case "boolean": + if (typeof value !== "boolean") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a boolean`, + }; + } + break; + case "number": + if (typeof value !== "number") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number`, + }; } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); + break; + case "choice": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, + }; + } + if (inputSchema.options && !inputSchema.options.includes(value)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, + }; } + normalizedValue = sanitizeContent(value); + break; + default: + if (typeof value === "string") { + normalizedValue = sanitizeContent(value); + } + break; } - const lines = outputContent.trim().split("\n"); - const parsedItems = []; + return { + isValid: true, + normalizedValue, + }; + } + function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { const errors = []; - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); - if (line === "") + const normalizedItem = { ...item }; + if (!jobConfig.inputs) { + return { + isValid: true, + errors: [], + normalizedItem: item, + }; + } + for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { + const fieldValue = item[fieldName]; + const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); + if (!validation.isValid && validation.error) { + errors.push(validation.error); + } else if (validation.normalizedValue !== undefined) { + normalizedItem[fieldName] = validation.normalizedValue; + } + } + return { + isValid: errors.length === 0, + errors, + normalizedItem, + }; + } + function parseJsonWithRepair(jsonStr) { + try { + return JSON.parse(jsonStr); + } catch (originalError) { + try { + const repairedJson = repairJson(jsonStr); + return JSON.parse(repairedJson); + } catch (repairError) { + core.info(`invalid input json: ${jsonStr}`); + const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); + const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); + throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); + } + } + } + const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; + const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; + if (!outputFile) { + core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); + core.setOutput("output", ""); + return; + } + if (!fs.existsSync(outputFile)) { + core.info(`Output file does not exist: ${outputFile}`); + core.setOutput("output", ""); + return; + } + const outputContent = fs.readFileSync(outputFile, "utf8"); + if (outputContent.trim() === "") { + core.info("Output file is empty"); + } + core.info(`Raw output content length: ${outputContent.length}`); + let expectedOutputTypes = {}; + if (safeOutputsConfig) { + try { + expectedOutputTypes = JSON.parse(safeOutputsConfig); + core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); + } + } + const lines = outputContent.trim().split("\n"); + const parsedItems = []; + const errors = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line === "") continue; + try { + const item = parseJsonWithRepair(line); + if (item === undefined) { + errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); + continue; + } + if (!item.type) { + errors.push(`Line ${i + 1}: Missing required 'type' field`); + continue; + } + const itemType = item.type; + if (!expectedOutputTypes[itemType]) { + errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); + continue; + } + const typeCount = parsedItems.filter(existing => existing.type === itemType).length; + const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); + if (typeCount >= maxAllowed) { + errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); + continue; + } + core.info(`Line ${i + 1}: type '${itemType}'`); + switch (itemType) { + case "create-issue": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); continue; - try { - const item = parseJsonWithRepair(line); - if (item === undefined) { - errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); - continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-comment": + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); + continue; + } + const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); + if (!issueNumValidation.isValid) { + if (issueNumValidation.error) errors.push(issueNumValidation.error); + continue; + } + item.body = sanitizeContent(item.body); + break; + case "create-pull-request": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); + continue; + } + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + item.branch = sanitizeContent(item.branch); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-labels": + if (!item.labels || !Array.isArray(item.labels)) { + errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); + continue; + } + if (item.labels.some(label => typeof label !== "string")) { + errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); + continue; + } + const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); + if (!labelsIssueNumValidation.isValid) { + if (labelsIssueNumValidation.error) errors.push(labelsIssueNumValidation.error); + continue; + } + item.labels = item.labels.map(label => sanitizeContent(label)); + break; + case "update-issue": + const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; + if (!hasValidField) { + errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); + continue; + } + if (item.status !== undefined) { + if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { + errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); + continue; } - if (!item.type) { - errors.push(`Line ${i + 1}: Missing required 'type' field`); - continue; + } + if (item.title !== undefined) { + if (typeof item.title !== "string") { + errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); + continue; } - const itemType = item.type; - if (!expectedOutputTypes[itemType]) { - errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); - continue; + item.title = sanitizeContent(item.title); + } + if (item.body !== undefined) { + if (typeof item.body !== "string") { + errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); + continue; } - const typeCount = parsedItems.filter(existing => existing.type === itemType).length; - const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); - if (typeCount >= maxAllowed) { - errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); - continue; + item.body = sanitizeContent(item.body); + } + const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); + if (!updateIssueNumValidation.isValid) { + if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); + continue; + } + break; + case "push-to-pull-request-branch": + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); + continue; + } + item.branch = sanitizeContent(item.branch); + item.message = sanitizeContent(item.message); + const pushPRNumValidation = validateIssueOrPRNumber( + item.pull_request_number, + "push-to-pull-request-branch 'pull_request_number'", + i + 1 + ); + if (!pushPRNumValidation.isValid) { + if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); + continue; + } + break; + case "create-pull-request-review-comment": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); + continue; + } + const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); + if (!lineValidation.isValid) { + if (lineValidation.error) errors.push(lineValidation.error); + continue; + } + const lineNumber = lineValidation.normalizedValue; + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); + continue; + } + item.body = sanitizeContent(item.body); + const startLineValidation = validateOptionalPositiveInteger( + item.start_line, + "create-pull-request-review-comment 'start_line'", + i + 1 + ); + if (!startLineValidation.isValid) { + if (startLineValidation.error) errors.push(startLineValidation.error); + continue; + } + if ( + startLineValidation.normalizedValue !== undefined && + lineNumber !== undefined && + startLineValidation.normalizedValue > lineNumber + ) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); + continue; + } + if (item.side !== undefined) { + if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); + continue; } - core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create-issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); - if (!issueNumValidation.isValid) { - if (issueNumValidation.error) - errors.push(issueNumValidation.error); - continue; - } - item.body = sanitizeContent(item.body); - break; - case "create-pull-request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - item.branch = sanitizeContent(item.branch); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some((label) => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); - if (!labelsIssueNumValidation.isValid) { - if (labelsIssueNumValidation.error) - errors.push(labelsIssueNumValidation.error); - continue; - } - item.labels = item.labels.map((label) => sanitizeContent(label)); - break; - case "update-issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) - errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "push-to-pull-request-branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch); - item.message = sanitizeContent(item.message); - const pushPRNumValidation = validateIssueOrPRNumber(item.pull_request_number, "push-to-pull-request-branch 'pull_request_number'", i + 1); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) - errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create-pull-request-review-comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) - errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body); - const startLineValidation = validateOptionalPositiveInteger(item.start_line, "create-pull-request-review-comment 'start_line'", i + 1); - if (!startLineValidation.isValid) { - if (startLineValidation.error) - errors.push(startLineValidation.error); - continue; - } - if (startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create-discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category); - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - break; - case "missing-tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool); - item.reason = sanitizeContent(item.reason); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives); - } - break; - case "upload-asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "create-code-scanning-alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}`); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) - errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores`); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file); - item.severity = sanitizeContent(item.severity); - item.message = sanitizeContent(item.message); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); - continue; - } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; + } + break; + case "create-discussion": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); + continue; + } + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); + continue; } - core.info(`Line ${i + 1}: Valid ${itemType} item`); - parsedItems.push(item); - } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); - } - } - if (errors.length > 0) { - core.warning("Validation errors found:"); - errors.forEach(error => core.warning(` - ${error}`)); - if (parsedItems.length === 0) { - core.setFailed(errors.map(e => ` - ${e}`).join("\n")); - return; - } - } - for (const itemType of Object.keys(expectedOutputTypes)) { - const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); - if (minRequired > 0) { - const actualCount = parsedItems.filter(item => item.type === itemType).length; - if (actualCount < minRequired) { - errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); + item.category = sanitizeContent(item.category); + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + break; + case "missing-tool": + if (!item.tool || typeof item.tool !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); + continue; + } + if (!item.reason || typeof item.reason !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); + continue; + } + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); + continue; } - } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "upload-asset": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); + continue; + } + break; + case "create-code-scanning-alert": + if (!item.file || typeof item.file !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); + continue; + } + const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); + if (!alertLineValidation.isValid) { + if (alertLineValidation.error) { + errors.push(alertLineValidation.error); + } + continue; + } + if (!item.severity || typeof item.severity !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); + continue; + } + const allowedSeverities = ["error", "warning", "info", "note"]; + if (!allowedSeverities.includes(item.severity.toLowerCase())) { + errors.push( + `Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` + ); + continue; + } + const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); + if (!columnValidation.isValid) { + if (columnValidation.error) errors.push(columnValidation.error); + continue; + } + if (item.ruleIdSuffix !== undefined) { + if (typeof item.ruleIdSuffix !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); + continue; + } + if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { + errors.push( + `Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` + ); + continue; + } + } + item.severity = item.severity.toLowerCase(); + item.file = sanitizeContent(item.file); + item.severity = sanitizeContent(item.severity); + item.message = sanitizeContent(item.message); + if (item.ruleIdSuffix) { + item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); + } + break; + default: + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); + continue; + } + Object.assign(item, validation.normalizedItem); + } + break; + } + core.info(`Line ${i + 1}: Valid ${itemType} item`); + parsedItems.push(item); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); } - core.info(`Successfully parsed ${parsedItems.length} valid output items`); - const validatedOutput = { - items: parsedItems, - errors: errors, - }; - const agentOutputFile = "/tmp/agent_output.json"; - const validatedOutputJson = JSON.stringify(validatedOutput); - try { - fs.mkdirSync("/tmp", { recursive: true }); - fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); - core.info(`Stored validated output to: ${agentOutputFile}`); - core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); - } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.error(`Failed to write agent output file: ${errorMsg}`); - } - core.setOutput("output", JSON.stringify(validatedOutput)); - core.setOutput("raw_output", outputContent); - const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); - core.info(`output_types: ${outputTypes.join(", ")}`); - core.setOutput("output_types", outputTypes.join(",")); - try { - await core.summary - .addRaw("## Processed Output\n\n") - .addRaw("```json\n") - .addRaw(JSON.stringify(validatedOutput)) - .addRaw("\n```\n") - .write(); - core.info("Successfully wrote processed output to step summary"); + } + if (errors.length > 0) { + core.warning("Validation errors found:"); + errors.forEach(error => core.warning(` - ${error}`)); + if (parsedItems.length === 0) { + core.setFailed(errors.map(e => ` - ${e}`).join("\n")); + return; } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.warning(`Failed to write to step summary: ${errorMsg}`); + } + for (const itemType of Object.keys(expectedOutputTypes)) { + const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); + if (minRequired > 0) { + const actualCount = parsedItems.filter(item => item.type === itemType).length; + if (actualCount < minRequired) { + errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); + } } + } + core.info(`Successfully parsed ${parsedItems.length} valid output items`); + const validatedOutput = { + items: parsedItems, + errors: errors, + }; + const agentOutputFile = "/tmp/agent_output.json"; + const validatedOutputJson = JSON.stringify(validatedOutput); + try { + fs.mkdirSync("/tmp", { recursive: true }); + fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); + core.info(`Stored validated output to: ${agentOutputFile}`); + core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.error(`Failed to write agent output file: ${errorMsg}`); + } + core.setOutput("output", JSON.stringify(validatedOutput)); + core.setOutput("raw_output", outputContent); + const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); + core.info(`output_types: ${outputTypes.join(", ")}`); + core.setOutput("output_types", outputTypes.join(",")); + try { + await core.summary + .addRaw("## Processed Output\n\n") + .addRaw("```json\n") + .addRaw(JSON.stringify(validatedOutput)) + .addRaw("\n```\n") + .write(); + core.info("Successfully wrote processed output to step summary"); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.warning(`Failed to write to step summary: ${errorMsg}`); + } } await main(); - name: Upload sanitized agent output diff --git a/pkg/workflow/js/add_labels.js b/pkg/workflow/js/add_labels.js index b4c6e1d1062..3bb1f76a682 100644 --- a/pkg/workflow/js/add_labels.js +++ b/pkg/workflow/js/add_labels.js @@ -1,203 +1,200 @@ function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); + if (!content || typeof content !== "string") { + return ""; + } + let sanitized = content.trim(); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); + sanitized = sanitized.replace(/[<>&'"]/g, ""); + return sanitized.trim(); } async function main() { - const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; - if (!outputContent) { - core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); - return; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return; - } - core.debug(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } - catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.warning("No valid items found in agent output"); - return; - } - const labelsItem = validatedOutput.items.find(item => item.type === "add-labels"); - if (!labelsItem) { - core.warning("No add-labels item found in agent output"); - return; - } - core.debug(`Found add-labels item with ${labelsItem.labels.length} labels`); - if (process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Add Labels Preview\n\n"; - summaryContent += "The following labels would be added if staged mode was disabled:\n\n"; - if (labelsItem.issue_number) { - summaryContent += `**Target Issue:** #${labelsItem.issue_number}\n\n`; - } - else { - summaryContent += `**Target:** Current issue/PR\n\n`; - } - if (labelsItem.labels && labelsItem.labels.length > 0) { - summaryContent += `**Labels to add:** ${labelsItem.labels.join(", ")}\n\n`; - } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Label addition preview written to step summary"); - return; - } - const allowedLabelsEnv = process.env.GITHUB_AW_LABELS_ALLOWED?.trim(); - const allowedLabels = allowedLabelsEnv - ? allowedLabelsEnv - .split(",") - .map(label => label.trim()) - .filter(label => label) - : undefined; - if (allowedLabels) { - core.debug(`Allowed labels: ${JSON.stringify(allowedLabels)}`); - } - else { - core.debug("No label restrictions - any labels are allowed"); - } - const maxCountEnv = process.env.GITHUB_AW_LABELS_MAX_COUNT; - const maxCount = maxCountEnv ? parseInt(maxCountEnv, 10) : 3; - if (isNaN(maxCount) || maxCount < 1) { - core.setFailed(`Invalid max value: ${maxCountEnv}. Must be a positive integer`); - return; - } - core.debug(`Max count: ${maxCount}`); - const labelsTarget = process.env.GITHUB_AW_LABELS_TARGET || "triggering"; - core.info(`Labels target configuration: ${labelsTarget}`); - const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; - const isPRContext = context.eventName === "pull_request" || - context.eventName === "pull_request_review" || - context.eventName === "pull_request_review_comment"; - if (labelsTarget === "triggering" && !isIssueContext && !isPRContext) { - core.info('Target is "triggering" but not running in issue or pull request context, skipping label addition'); + const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; + if (!outputContent) { + core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); + return; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return; + } + core.debug(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); + return; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.warning("No valid items found in agent output"); + return; + } + const labelsItem = validatedOutput.items.find(item => item.type === "add-labels"); + if (!labelsItem) { + core.warning("No add-labels item found in agent output"); + return; + } + core.debug(`Found add-labels item with ${labelsItem.labels.length} labels`); + if (process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true") { + let summaryContent = "## 🎭 Staged Mode: Add Labels Preview\n\n"; + summaryContent += "The following labels would be added if staged mode was disabled:\n\n"; + if (labelsItem.issue_number) { + summaryContent += `**Target Issue:** #${labelsItem.issue_number}\n\n`; + } else { + summaryContent += `**Target:** Current issue/PR\n\n`; + } + if (labelsItem.labels && labelsItem.labels.length > 0) { + summaryContent += `**Labels to add:** ${labelsItem.labels.join(", ")}\n\n`; + } + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Label addition preview written to step summary"); + return; + } + const allowedLabelsEnv = process.env.GITHUB_AW_LABELS_ALLOWED?.trim(); + const allowedLabels = allowedLabelsEnv + ? allowedLabelsEnv + .split(",") + .map(label => label.trim()) + .filter(label => label) + : undefined; + if (allowedLabels) { + core.debug(`Allowed labels: ${JSON.stringify(allowedLabels)}`); + } else { + core.debug("No label restrictions - any labels are allowed"); + } + const maxCountEnv = process.env.GITHUB_AW_LABELS_MAX_COUNT; + const maxCount = maxCountEnv ? parseInt(maxCountEnv, 10) : 3; + if (isNaN(maxCount) || maxCount < 1) { + core.setFailed(`Invalid max value: ${maxCountEnv}. Must be a positive integer`); + return; + } + core.debug(`Max count: ${maxCount}`); + const labelsTarget = process.env.GITHUB_AW_LABELS_TARGET || "triggering"; + core.info(`Labels target configuration: ${labelsTarget}`); + const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; + const isPRContext = + context.eventName === "pull_request" || + context.eventName === "pull_request_review" || + context.eventName === "pull_request_review_comment"; + if (labelsTarget === "triggering" && !isIssueContext && !isPRContext) { + core.info('Target is "triggering" but not running in issue or pull request context, skipping label addition'); + return; + } + let issueNumber; + let contextType; + if (labelsTarget === "*") { + if (labelsItem.issue_number) { + issueNumber = typeof labelsItem.issue_number === "number" ? labelsItem.issue_number : parseInt(String(labelsItem.issue_number), 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + core.setFailed(`Invalid issue number specified: ${labelsItem.issue_number}`); return; - } - let issueNumber; - let contextType; - if (labelsTarget === "*") { - if (labelsItem.issue_number) { - issueNumber = typeof labelsItem.issue_number === "number" ? labelsItem.issue_number : parseInt(String(labelsItem.issue_number), 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - core.setFailed(`Invalid issue number specified: ${labelsItem.issue_number}`); - return; - } - contextType = "issue"; - } - else { - core.setFailed('Target is "*" but no issue_number specified in labels item'); - return; - } - } - else if (labelsTarget && labelsTarget !== "triggering") { - issueNumber = parseInt(labelsTarget, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - core.setFailed(`Invalid issue number in target configuration: ${labelsTarget}`); - return; - } + } + contextType = "issue"; + } else { + core.setFailed('Target is "*" but no issue_number specified in labels item'); + return; + } + } else if (labelsTarget && labelsTarget !== "triggering") { + issueNumber = parseInt(labelsTarget, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + core.setFailed(`Invalid issue number in target configuration: ${labelsTarget}`); + return; + } + contextType = "issue"; + } else { + if (isIssueContext) { + if (context.payload.issue) { + issueNumber = context.payload.issue.number; contextType = "issue"; - } - else { - if (isIssueContext) { - if (context.payload.issue) { - issueNumber = context.payload.issue.number; - contextType = "issue"; - } - else { - core.setFailed("Issue context detected but no issue found in payload"); - return; - } - } - else if (isPRContext) { - if (context.payload.pull_request) { - issueNumber = context.payload.pull_request.number; - contextType = "pull request"; - } - else { - core.setFailed("Pull request context detected but no pull request found in payload"); - return; - } - } - } - if (!issueNumber) { - core.setFailed("Could not determine issue or pull request number"); + } else { + core.setFailed("Issue context detected but no issue found in payload"); return; - } - const requestedLabels = labelsItem.labels || []; - core.debug(`Requested labels: ${JSON.stringify(requestedLabels)}`); - for (const label of requestedLabels) { - if (label && typeof label === "string" && label.startsWith("-")) { - core.setFailed(`Label removal is not permitted. Found line starting with '-': ${label}`); - return; - } - } - let validLabels; - if (allowedLabels) { - validLabels = requestedLabels.filter(label => allowedLabels.includes(label)); - } - else { - validLabels = requestedLabels; - } - let uniqueLabels = validLabels - .filter(label => label != null && label !== false && label !== 0) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - if (uniqueLabels.length > maxCount) { - core.debug(`too many labels, keep ${maxCount}`); - uniqueLabels = uniqueLabels.slice(0, maxCount); - } - if (uniqueLabels.length === 0) { - core.info("No labels to add"); - core.setOutput("labels_added", ""); - await core.summary - .addRaw(` + } + } else if (isPRContext) { + if (context.payload.pull_request) { + issueNumber = context.payload.pull_request.number; + contextType = "pull request"; + } else { + core.setFailed("Pull request context detected but no pull request found in payload"); + return; + } + } + } + if (!issueNumber) { + core.setFailed("Could not determine issue or pull request number"); + return; + } + const requestedLabels = labelsItem.labels || []; + core.debug(`Requested labels: ${JSON.stringify(requestedLabels)}`); + for (const label of requestedLabels) { + if (label && typeof label === "string" && label.startsWith("-")) { + core.setFailed(`Label removal is not permitted. Found line starting with '-': ${label}`); + return; + } + } + let validLabels; + if (allowedLabels) { + validLabels = requestedLabels.filter(label => allowedLabels.includes(label)); + } else { + validLabels = requestedLabels; + } + let uniqueLabels = validLabels + .filter(label => label != null && label !== false && label !== 0) + .map(label => String(label).trim()) + .filter(label => label) + .map(label => sanitizeLabelContent(label)) + .filter(label => label) + .map(label => (label.length > 64 ? label.substring(0, 64) : label)) + .filter((label, index, arr) => arr.indexOf(label) === index); + if (uniqueLabels.length > maxCount) { + core.debug(`too many labels, keep ${maxCount}`); + uniqueLabels = uniqueLabels.slice(0, maxCount); + } + if (uniqueLabels.length === 0) { + core.info("No labels to add"); + core.setOutput("labels_added", ""); + await core.summary + .addRaw( + ` ## Label Addition No labels were added (no valid labels found in agent output). -`) - .write(); - return; - } - core.info(`Adding ${uniqueLabels.length} labels to ${contextType} #${issueNumber}: ${JSON.stringify(uniqueLabels)}`); - try { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - labels: uniqueLabels, - }); - core.info(`Successfully added ${uniqueLabels.length} labels to ${contextType} #${issueNumber}`); - core.setOutput("labels_added", uniqueLabels.join("\n")); - const labelsListMarkdown = uniqueLabels.map(label => `- \`${label}\``).join("\n"); - await core.summary - .addRaw(` +` + ) + .write(); + return; + } + core.info(`Adding ${uniqueLabels.length} labels to ${contextType} #${issueNumber}: ${JSON.stringify(uniqueLabels)}`); + try { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + labels: uniqueLabels, + }); + core.info(`Successfully added ${uniqueLabels.length} labels to ${contextType} #${issueNumber}`); + core.setOutput("labels_added", uniqueLabels.join("\n")); + const labelsListMarkdown = uniqueLabels.map(label => `- \`${label}\``).join("\n"); + await core.summary + .addRaw( + ` ## Label Addition Successfully added ${uniqueLabels.length} label(s) to ${contextType} #${issueNumber}: ${labelsListMarkdown} -`) - .write(); - } - catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - core.error(`Failed to add labels: ${errorMessage}`); - core.setFailed(`Failed to add labels: ${errorMessage}`); - } +` + ) + .write(); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + core.error(`Failed to add labels: ${errorMessage}`); + core.setFailed(`Failed to add labels: ${errorMessage}`); + } } await main(); diff --git a/pkg/workflow/js/collect_ndjson_output.js b/pkg/workflow/js/collect_ndjson_output.js index 19e898c5d13..7f090702b0a 100644 --- a/pkg/workflow/js/collect_ndjson_output.js +++ b/pkg/workflow/js/collect_ndjson_output.js @@ -1,739 +1,739 @@ async function main() { - const fs = require("fs"); - function sanitizeContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; - const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; - const allowedDomains = allowedDomainsEnv - ? allowedDomainsEnv - .split(",") - .map(d => d.trim()) - .filter(d => d) - : defaultAllowedDomains; - let sanitized = content; - sanitized = neutralizeMentions(sanitized); - sanitized = removeXmlComments(sanitized); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitizeUrlProtocols(sanitized); - sanitized = sanitizeUrlDomains(sanitized); - const maxLength = 524288; - if (sanitized.length > maxLength) { - sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; - } - const lines = sanitized.split("\n"); - const maxLines = 65000; - if (lines.length > maxLines) { - sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; - } - sanitized = neutralizeBotTriggers(sanitized); - return sanitized.trim(); - function sanitizeUrlDomains(s) { - return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { - const urlAfterProtocol = match.slice(8); - const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); - const isAllowed = allowedDomains.some(allowedDomain => { - const normalizedAllowed = allowedDomain.toLowerCase(); - return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); - }); - return isAllowed ? match : "(redacted)"; - }); - } - function sanitizeUrlProtocols(s) { - return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { - return protocol.toLowerCase() === "https" ? match : "(redacted)"; - }); - } - function neutralizeMentions(s) { - return s.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - } - function removeXmlComments(s) { - return s.replace(//g, "").replace(//g, ""); - } - function neutralizeBotTriggers(s) { - return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); - } + const fs = require("fs"); + function sanitizeContent(content) { + if (!content || typeof content !== "string") { + return ""; } - function getMaxAllowedForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { - return itemConfig.max; - } - switch (itemType) { - case "create-issue": - return 1; - case "add-comment": - return 1; - case "create-pull-request": - return 1; - case "create-pull-request-review-comment": - return 1; - case "add-labels": - return 5; - case "update-issue": - return 1; - case "push-to-pull-request-branch": - return 1; - case "create-discussion": - return 1; - case "missing-tool": - return 1000; - case "create-code-scanning-alert": - return 1000; - case "upload-asset": - return 10; - default: - return 1; - } + const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; + const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; + const allowedDomains = allowedDomainsEnv + ? allowedDomainsEnv + .split(",") + .map(d => d.trim()) + .filter(d => d) + : defaultAllowedDomains; + let sanitized = content; + sanitized = neutralizeMentions(sanitized); + sanitized = removeXmlComments(sanitized); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitizeUrlProtocols(sanitized); + sanitized = sanitizeUrlDomains(sanitized); + const maxLength = 524288; + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; } - function getMinRequiredForType(itemType, config) { - const itemConfig = config?.[itemType]; - if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { - return itemConfig.min; - } - return 0; + const lines = sanitized.split("\n"); + const maxLines = 65000; + if (lines.length > maxLines) { + sanitized = lines.slice(0, maxLines).join("\n") + "\n[Content truncated due to line count]"; } - function repairJson(jsonStr) { - let repaired = jsonStr.trim(); - const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; - repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { - const c = ch.charCodeAt(0); - return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); - }); - repaired = repaired.replace(/'/g, '"'); - repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); - repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { - if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { - const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); - return `"${escaped}"`; - } - return match; + sanitized = neutralizeBotTriggers(sanitized); + return sanitized.trim(); + function sanitizeUrlDomains(s) { + return s.replace(/\bhttps:\/\/[^\s\])}'"<>&\x00-\x1f,;]+/gi, match => { + const urlAfterProtocol = match.slice(8); + const hostname = urlAfterProtocol.split(/[\/:\?#]/)[0].toLowerCase(); + const isAllowed = allowedDomains.some(allowedDomain => { + const normalizedAllowed = allowedDomain.toLowerCase(); + return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed); }); - repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); - repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); - const openBraces = (repaired.match(/\{/g) || []).length; - const closeBraces = (repaired.match(/\}/g) || []).length; - if (openBraces > closeBraces) { - repaired += "}".repeat(openBraces - closeBraces); - } - else if (closeBraces > openBraces) { - repaired = "{".repeat(closeBraces - openBraces) + repaired; - } - const openBrackets = (repaired.match(/\[/g) || []).length; - const closeBrackets = (repaired.match(/\]/g) || []).length; - if (openBrackets > closeBrackets) { - repaired += "]".repeat(openBrackets - closeBrackets); - } - else if (closeBrackets > openBrackets) { - repaired = "[".repeat(closeBrackets - openBrackets) + repaired; - } - repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); - return repaired; + return isAllowed ? match : "(redacted)"; + }); } - function validatePositiveInteger(value, fieldName, lineNum) { - if (value === undefined || value === null) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-code-scanning-alert 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, - }; - } - if (fieldName.includes("create-pull-request-review-comment 'line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; - } - return { isValid: true, normalizedValue: parsed }; + function sanitizeUrlProtocols(s) { + return s.replace(/\b(\w+):\/\/[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { + return protocol.toLowerCase() === "https" ? match : "(redacted)"; + }); } - function validateOptionalPositiveInteger(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - const parsed = typeof value === "string" ? parseInt(value, 10) : value; - if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { - if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, - }; - } - if (fieldName.includes("create-code-scanning-alert 'column'")) { - return { - isValid: false, - error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, - }; - } - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, - }; - } - return { isValid: true, normalizedValue: parsed }; + function neutralizeMentions(s) { + return s.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); } - function validateIssueOrPRNumber(value, fieldName, lineNum) { - if (value === undefined) { - return { isValid: true }; - } - if (typeof value !== "number" && typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number or string`, - }; - } - return { isValid: true }; + function removeXmlComments(s) { + return s.replace(//g, "").replace(//g, ""); } - function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { - if (inputSchema.required && (value === undefined || value === null)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} is required`, - }; - } - if (value === undefined || value === null) { - return { - isValid: true, - normalizedValue: inputSchema.default || undefined, - }; - } - const inputType = inputSchema.type || "string"; - let normalizedValue = value; - switch (inputType) { - case "string": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string`, - }; - } - normalizedValue = sanitizeContent(value); - break; - case "boolean": - if (typeof value !== "boolean") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a boolean`, - }; - } - break; - case "number": - if (typeof value !== "number") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a number`, - }; - } - break; - case "choice": - if (typeof value !== "string") { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, - }; - } - if (inputSchema.options && !inputSchema.options.includes(value)) { - return { - isValid: false, - error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, - }; - } - normalizedValue = sanitizeContent(value); - break; - default: - if (typeof value === "string") { - normalizedValue = sanitizeContent(value); - } - break; - } + function neutralizeBotTriggers(s) { + return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); + } + } + function getMaxAllowedForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "max" in itemConfig && itemConfig.max) { + return itemConfig.max; + } + switch (itemType) { + case "create-issue": + return 1; + case "add-comment": + return 1; + case "create-pull-request": + return 1; + case "create-pull-request-review-comment": + return 1; + case "add-labels": + return 5; + case "update-issue": + return 1; + case "push-to-pull-request-branch": + return 1; + case "create-discussion": + return 1; + case "missing-tool": + return 1000; + case "create-code-scanning-alert": + return 1000; + case "upload-asset": + return 10; + default: + return 1; + } + } + function getMinRequiredForType(itemType, config) { + const itemConfig = config?.[itemType]; + if (itemConfig && typeof itemConfig === "object" && "min" in itemConfig && itemConfig.min) { + return itemConfig.min; + } + return 0; + } + function repairJson(jsonStr) { + let repaired = jsonStr.trim(); + const _ctrl = { 8: "\\b", 9: "\\t", 10: "\\n", 12: "\\f", 13: "\\r" }; + repaired = repaired.replace(/[\u0000-\u001F]/g, ch => { + const c = ch.charCodeAt(0); + return _ctrl[c] || "\\u" + c.toString(16).padStart(4, "0"); + }); + repaired = repaired.replace(/'/g, '"'); + repaired = repaired.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":'); + repaired = repaired.replace(/"([^"\\]*)"/g, (match, content) => { + if (content.includes("\n") || content.includes("\r") || content.includes("\t")) { + const escaped = content.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"); + return `"${escaped}"`; + } + return match; + }); + repaired = repaired.replace(/"([^"]*)"([^":,}\]]*)"([^"]*)"(\s*[,:}\]])/g, (match, p1, p2, p3, p4) => `"${p1}\\"${p2}\\"${p3}"${p4}`); + repaired = repaired.replace(/(\[\s*(?:"[^"]*"(?:\s*,\s*"[^"]*")*\s*),?)\s*}/g, "$1]"); + const openBraces = (repaired.match(/\{/g) || []).length; + const closeBraces = (repaired.match(/\}/g) || []).length; + if (openBraces > closeBraces) { + repaired += "}".repeat(openBraces - closeBraces); + } else if (closeBraces > openBraces) { + repaired = "{".repeat(closeBraces - openBraces) + repaired; + } + const openBrackets = (repaired.match(/\[/g) || []).length; + const closeBrackets = (repaired.match(/\]/g) || []).length; + if (openBrackets > closeBrackets) { + repaired += "]".repeat(openBrackets - closeBrackets); + } else if (closeBrackets > openBrackets) { + repaired = "[".repeat(closeBrackets - openBrackets) + repaired; + } + repaired = repaired.replace(/,(\s*[}\]])/g, "$1"); + return repaired; + } + function validatePositiveInteger(value, fieldName, lineNum) { + if (value === undefined || value === null) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { return { - isValid: true, - normalizedValue, + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number`, }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; } - function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { - const errors = []; - const normalizedItem = { ...item }; - if (!jobConfig.inputs) { - return { - isValid: true, - errors: [], - normalizedItem: item, - }; - } - for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { - const fieldValue = item[fieldName]; - const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); - if (!validation.isValid && validation.error) { - errors.push(validation.error); - } - else if (validation.normalizedValue !== undefined) { - normalizedItem[fieldName] = validation.normalizedValue; - } - } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert requires a 'line' field (number or string)`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { return { - isValid: errors.length === 0, - errors, - normalizedItem, + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment requires a 'line' number or string field`, }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; } - function parseJsonWithRepair(jsonStr) { - try { - return JSON.parse(jsonStr); - } - catch (originalError) { - try { - const repairedJson = repairJson(jsonStr); - return JSON.parse(repairedJson); - } - catch (repairError) { - core.info(`invalid input json: ${jsonStr}`); - const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); - const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); - throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); - } - } + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-code-scanning-alert 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'line' must be a valid positive integer (got: ${value})`, + }; + } + if (fieldName.includes("create-pull-request-review-comment 'line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'line' must be a positive integer`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; + } + return { isValid: true, normalizedValue: parsed }; + } + function validateOptionalPositiveInteger(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; + } + if (typeof value !== "number" && typeof value !== "string") { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a number or string`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a number or string`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; } - const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; - const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; - if (!outputFile) { - core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); - core.setOutput("output", ""); - return; + const parsed = typeof value === "string" ? parseInt(value, 10) : value; + if (isNaN(parsed) || parsed <= 0 || !Number.isInteger(parsed)) { + if (fieldName.includes("create-pull-request-review-comment 'start_line'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-pull-request-review-comment 'start_line' must be a positive integer`, + }; + } + if (fieldName.includes("create-code-scanning-alert 'column'")) { + return { + isValid: false, + error: `Line ${lineNum}: create-code-scanning-alert 'column' must be a valid positive integer (got: ${value})`, + }; + } + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a positive integer (got: ${value})`, + }; } - if (!fs.existsSync(outputFile)) { - core.info(`Output file does not exist: ${outputFile}`); - core.setOutput("output", ""); - return; + return { isValid: true, normalizedValue: parsed }; + } + function validateIssueOrPRNumber(value, fieldName, lineNum) { + if (value === undefined) { + return { isValid: true }; } - const outputContent = fs.readFileSync(outputFile, "utf8"); - if (outputContent.trim() === "") { - core.info("Output file is empty"); + if (typeof value !== "number" && typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number or string`, + }; } - core.info(`Raw output content length: ${outputContent.length}`); - let expectedOutputTypes = {}; - if (safeOutputsConfig) { - try { - expectedOutputTypes = JSON.parse(safeOutputsConfig); - core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); - } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); - } + return { isValid: true }; + } + function validateFieldWithInputSchema(value, fieldName, inputSchema, lineNum) { + if (inputSchema.required && (value === undefined || value === null)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} is required`, + }; } - const lines = outputContent.trim().split("\n"); - const parsedItems = []; - const errors = []; - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); - if (line === "") - continue; - try { - const item = parseJsonWithRepair(line); - if (item === undefined) { - errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); - continue; - } - if (!item.type) { - errors.push(`Line ${i + 1}: Missing required 'type' field`); - continue; - } - const itemType = item.type; - if (!expectedOutputTypes[itemType]) { - errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); - continue; - } - const typeCount = parsedItems.filter(existing => existing.type === itemType).length; - const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); - if (typeCount >= maxAllowed) { - errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); - continue; - } - core.info(`Line ${i + 1}: type '${itemType}'`); - switch (itemType) { - case "create-issue": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-comment": - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); - continue; - } - const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); - if (!issueNumValidation.isValid) { - if (issueNumValidation.error) - errors.push(issueNumValidation.error); - continue; - } - item.body = sanitizeContent(item.body); - break; - case "create-pull-request": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); - continue; - } - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); - continue; - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - item.branch = sanitizeContent(item.branch); - if (item.labels && Array.isArray(item.labels)) { - item.labels = item.labels.map((label) => (typeof label === "string" ? sanitizeContent(label) : label)); - } - break; - case "add-labels": - if (!item.labels || !Array.isArray(item.labels)) { - errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); - continue; - } - if (item.labels.some((label) => typeof label !== "string")) { - errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); - continue; - } - const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); - if (!labelsIssueNumValidation.isValid) { - if (labelsIssueNumValidation.error) - errors.push(labelsIssueNumValidation.error); - continue; - } - item.labels = item.labels.map((label) => sanitizeContent(label)); - break; - case "update-issue": - const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; - if (!hasValidField) { - errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); - continue; - } - if (item.status !== undefined) { - if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { - errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); - continue; - } - } - if (item.title !== undefined) { - if (typeof item.title !== "string") { - errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); - continue; - } - item.title = sanitizeContent(item.title); - } - if (item.body !== undefined) { - if (typeof item.body !== "string") { - errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); - continue; - } - item.body = sanitizeContent(item.body); - } - const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); - if (!updateIssueNumValidation.isValid) { - if (updateIssueNumValidation.error) - errors.push(updateIssueNumValidation.error); - continue; - } - break; - case "push-to-pull-request-branch": - if (!item.branch || typeof item.branch !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); - continue; - } - item.branch = sanitizeContent(item.branch); - item.message = sanitizeContent(item.message); - const pushPRNumValidation = validateIssueOrPRNumber(item.pull_request_number, "push-to-pull-request-branch 'pull_request_number'", i + 1); - if (!pushPRNumValidation.isValid) { - if (pushPRNumValidation.error) - errors.push(pushPRNumValidation.error); - continue; - } - break; - case "create-pull-request-review-comment": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); - continue; - } - const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); - if (!lineValidation.isValid) { - if (lineValidation.error) - errors.push(lineValidation.error); - continue; - } - const lineNumber = lineValidation.normalizedValue; - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); - continue; - } - item.body = sanitizeContent(item.body); - const startLineValidation = validateOptionalPositiveInteger(item.start_line, "create-pull-request-review-comment 'start_line'", i + 1); - if (!startLineValidation.isValid) { - if (startLineValidation.error) - errors.push(startLineValidation.error); - continue; - } - if (startLineValidation.normalizedValue !== undefined && - lineNumber !== undefined && - startLineValidation.normalizedValue > lineNumber) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); - continue; - } - if (item.side !== undefined) { - if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { - errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); - continue; - } - } - break; - case "create-discussion": - if (!item.title || typeof item.title !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); - continue; - } - if (!item.body || typeof item.body !== "string") { - errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); - continue; - } - if (item.category !== undefined) { - if (typeof item.category !== "string") { - errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); - continue; - } - item.category = sanitizeContent(item.category); - } - item.title = sanitizeContent(item.title); - item.body = sanitizeContent(item.body); - break; - case "missing-tool": - if (!item.tool || typeof item.tool !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); - continue; - } - if (!item.reason || typeof item.reason !== "string") { - errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); - continue; - } - item.tool = sanitizeContent(item.tool); - item.reason = sanitizeContent(item.reason); - if (item.alternatives !== undefined) { - if (typeof item.alternatives !== "string") { - errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); - continue; - } - item.alternatives = sanitizeContent(item.alternatives); - } - break; - case "upload-asset": - if (!item.path || typeof item.path !== "string") { - errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); - continue; - } - break; - case "create-code-scanning-alert": - if (!item.file || typeof item.file !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); - continue; - } - const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); - if (!alertLineValidation.isValid) { - if (alertLineValidation.error) { - errors.push(alertLineValidation.error); - } - continue; - } - if (!item.severity || typeof item.severity !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); - continue; - } - if (!item.message || typeof item.message !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); - continue; - } - const allowedSeverities = ["error", "warning", "info", "note"]; - if (!allowedSeverities.includes(item.severity.toLowerCase())) { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}`); - continue; - } - const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); - if (!columnValidation.isValid) { - if (columnValidation.error) - errors.push(columnValidation.error); - continue; - } - if (item.ruleIdSuffix !== undefined) { - if (typeof item.ruleIdSuffix !== "string") { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { - errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores`); - continue; - } - } - item.severity = item.severity.toLowerCase(); - item.file = sanitizeContent(item.file); - item.severity = sanitizeContent(item.severity); - item.message = sanitizeContent(item.message); - if (item.ruleIdSuffix) { - item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); - } - break; - default: - const jobOutputType = expectedOutputTypes[itemType]; - if (!jobOutputType) { - errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); - continue; - } - const safeJobConfig = jobOutputType; - if (safeJobConfig && safeJobConfig.inputs) { - const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); - if (!validation.isValid) { - errors.push(...validation.errors); - continue; - } - Object.assign(item, validation.normalizedItem); - } - break; - } - core.info(`Line ${i + 1}: Valid ${itemType} item`); - parsedItems.push(item); - } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); - } + if (value === undefined || value === null) { + return { + isValid: true, + normalizedValue: inputSchema.default || undefined, + }; } - if (errors.length > 0) { - core.warning("Validation errors found:"); - errors.forEach(error => core.warning(` - ${error}`)); - if (parsedItems.length === 0) { - core.setFailed(errors.map(e => ` - ${e}`).join("\n")); - return; - } + const inputType = inputSchema.type || "string"; + let normalizedValue = value; + switch (inputType) { + case "string": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string`, + }; + } + normalizedValue = sanitizeContent(value); + break; + case "boolean": + if (typeof value !== "boolean") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a boolean`, + }; + } + break; + case "number": + if (typeof value !== "number") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a number`, + }; + } + break; + case "choice": + if (typeof value !== "string") { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be a string for choice type`, + }; + } + if (inputSchema.options && !inputSchema.options.includes(value)) { + return { + isValid: false, + error: `Line ${lineNum}: ${fieldName} must be one of: ${inputSchema.options.join(", ")}`, + }; + } + normalizedValue = sanitizeContent(value); + break; + default: + if (typeof value === "string") { + normalizedValue = sanitizeContent(value); + } + break; } - for (const itemType of Object.keys(expectedOutputTypes)) { - const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); - if (minRequired > 0) { - const actualCount = parsedItems.filter(item => item.type === itemType).length; - if (actualCount < minRequired) { - errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); - } - } + return { + isValid: true, + normalizedValue, + }; + } + function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { + const errors = []; + const normalizedItem = { ...item }; + if (!jobConfig.inputs) { + return { + isValid: true, + errors: [], + normalizedItem: item, + }; + } + for (const [fieldName, inputSchema] of Object.entries(jobConfig.inputs)) { + const fieldValue = item[fieldName]; + const validation = validateFieldWithInputSchema(fieldValue, fieldName, inputSchema, lineNum); + if (!validation.isValid && validation.error) { + errors.push(validation.error); + } else if (validation.normalizedValue !== undefined) { + normalizedItem[fieldName] = validation.normalizedValue; + } } - core.info(`Successfully parsed ${parsedItems.length} valid output items`); - const validatedOutput = { - items: parsedItems, - errors: errors, + return { + isValid: errors.length === 0, + errors, + normalizedItem, }; - const agentOutputFile = "/tmp/agent_output.json"; - const validatedOutputJson = JSON.stringify(validatedOutput); + } + function parseJsonWithRepair(jsonStr) { try { - fs.mkdirSync("/tmp", { recursive: true }); - fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); - core.info(`Stored validated output to: ${agentOutputFile}`); - core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); + return JSON.parse(jsonStr); + } catch (originalError) { + try { + const repairedJson = repairJson(jsonStr); + return JSON.parse(repairedJson); + } catch (repairError) { + core.info(`invalid input json: ${jsonStr}`); + const originalMsg = originalError instanceof Error ? originalError.message : String(originalError); + const repairMsg = repairError instanceof Error ? repairError.message : String(repairError); + throw new Error(`JSON parsing failed. Original: ${originalMsg}. After attempted repair: ${repairMsg}`); + } } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.error(`Failed to write agent output file: ${errorMsg}`); + } + const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; + const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; + if (!outputFile) { + core.info("GITHUB_AW_SAFE_OUTPUTS not set, no output to collect"); + core.setOutput("output", ""); + return; + } + if (!fs.existsSync(outputFile)) { + core.info(`Output file does not exist: ${outputFile}`); + core.setOutput("output", ""); + return; + } + const outputContent = fs.readFileSync(outputFile, "utf8"); + if (outputContent.trim() === "") { + core.info("Output file is empty"); + } + core.info(`Raw output content length: ${outputContent.length}`); + let expectedOutputTypes = {}; + if (safeOutputsConfig) { + try { + expectedOutputTypes = JSON.parse(safeOutputsConfig); + core.info(`Expected output types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.info(`Warning: Could not parse safe-outputs config: ${errorMsg}`); } - core.setOutput("output", JSON.stringify(validatedOutput)); - core.setOutput("raw_output", outputContent); - const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); - core.info(`output_types: ${outputTypes.join(", ")}`); - core.setOutput("output_types", outputTypes.join(",")); + } + const lines = outputContent.trim().split("\n"); + const parsedItems = []; + const errors = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line === "") continue; try { - await core.summary - .addRaw("## Processed Output\n\n") - .addRaw("```json\n") - .addRaw(JSON.stringify(validatedOutput)) - .addRaw("\n```\n") - .write(); - core.info("Successfully wrote processed output to step summary"); + const item = parseJsonWithRepair(line); + if (item === undefined) { + errors.push(`Line ${i + 1}: Invalid JSON - JSON parsing failed`); + continue; + } + if (!item.type) { + errors.push(`Line ${i + 1}: Missing required 'type' field`); + continue; + } + const itemType = item.type; + if (!expectedOutputTypes[itemType]) { + errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); + continue; + } + const typeCount = parsedItems.filter(existing => existing.type === itemType).length; + const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); + if (typeCount >= maxAllowed) { + errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); + continue; + } + core.info(`Line ${i + 1}: type '${itemType}'`); + switch (itemType) { + case "create-issue": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_issue requires a 'body' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-comment": + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: add_comment requires a 'body' string field`); + continue; + } + const issueNumValidation = validateIssueOrPRNumber(item.issue_number, "add_comment 'issue_number'", i + 1); + if (!issueNumValidation.isValid) { + if (issueNumValidation.error) errors.push(issueNumValidation.error); + continue; + } + item.body = sanitizeContent(item.body); + break; + case "create-pull-request": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'body' string field`); + continue; + } + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: create_pull_request requires a 'branch' string field`); + continue; + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + item.branch = sanitizeContent(item.branch); + if (item.labels && Array.isArray(item.labels)) { + item.labels = item.labels.map(label => (typeof label === "string" ? sanitizeContent(label) : label)); + } + break; + case "add-labels": + if (!item.labels || !Array.isArray(item.labels)) { + errors.push(`Line ${i + 1}: add_labels requires a 'labels' array field`); + continue; + } + if (item.labels.some(label => typeof label !== "string")) { + errors.push(`Line ${i + 1}: add_labels labels array must contain only strings`); + continue; + } + const labelsIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "add-labels 'issue_number'", i + 1); + if (!labelsIssueNumValidation.isValid) { + if (labelsIssueNumValidation.error) errors.push(labelsIssueNumValidation.error); + continue; + } + item.labels = item.labels.map(label => sanitizeContent(label)); + break; + case "update-issue": + const hasValidField = item.status !== undefined || item.title !== undefined || item.body !== undefined; + if (!hasValidField) { + errors.push(`Line ${i + 1}: update_issue requires at least one of: 'status', 'title', or 'body' fields`); + continue; + } + if (item.status !== undefined) { + if (typeof item.status !== "string" || (item.status !== "open" && item.status !== "closed")) { + errors.push(`Line ${i + 1}: update_issue 'status' must be 'open' or 'closed'`); + continue; + } + } + if (item.title !== undefined) { + if (typeof item.title !== "string") { + errors.push(`Line ${i + 1}: update-issue 'title' must be a string`); + continue; + } + item.title = sanitizeContent(item.title); + } + if (item.body !== undefined) { + if (typeof item.body !== "string") { + errors.push(`Line ${i + 1}: update-issue 'body' must be a string`); + continue; + } + item.body = sanitizeContent(item.body); + } + const updateIssueNumValidation = validateIssueOrPRNumber(item.issue_number, "update-issue 'issue_number'", i + 1); + if (!updateIssueNumValidation.isValid) { + if (updateIssueNumValidation.error) errors.push(updateIssueNumValidation.error); + continue; + } + break; + case "push-to-pull-request-branch": + if (!item.branch || typeof item.branch !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'branch' string field`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: push_to_pull_request_branch requires a 'message' string field`); + continue; + } + item.branch = sanitizeContent(item.branch); + item.message = sanitizeContent(item.message); + const pushPRNumValidation = validateIssueOrPRNumber( + item.pull_request_number, + "push-to-pull-request-branch 'pull_request_number'", + i + 1 + ); + if (!pushPRNumValidation.isValid) { + if (pushPRNumValidation.error) errors.push(pushPRNumValidation.error); + continue; + } + break; + case "create-pull-request-review-comment": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'path' string field`); + continue; + } + const lineValidation = validatePositiveInteger(item.line, "create-pull-request-review-comment 'line'", i + 1); + if (!lineValidation.isValid) { + if (lineValidation.error) errors.push(lineValidation.error); + continue; + } + const lineNumber = lineValidation.normalizedValue; + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create-pull-request-review-comment requires a 'body' string field`); + continue; + } + item.body = sanitizeContent(item.body); + const startLineValidation = validateOptionalPositiveInteger( + item.start_line, + "create-pull-request-review-comment 'start_line'", + i + 1 + ); + if (!startLineValidation.isValid) { + if (startLineValidation.error) errors.push(startLineValidation.error); + continue; + } + if ( + startLineValidation.normalizedValue !== undefined && + lineNumber !== undefined && + startLineValidation.normalizedValue > lineNumber + ) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'start_line' must be less than or equal to 'line'`); + continue; + } + if (item.side !== undefined) { + if (typeof item.side !== "string" || (item.side !== "LEFT" && item.side !== "RIGHT")) { + errors.push(`Line ${i + 1}: create-pull-request-review-comment 'side' must be 'LEFT' or 'RIGHT'`); + continue; + } + } + break; + case "create-discussion": + if (!item.title || typeof item.title !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'title' string field`); + continue; + } + if (!item.body || typeof item.body !== "string") { + errors.push(`Line ${i + 1}: create_discussion requires a 'body' string field`); + continue; + } + if (item.category !== undefined) { + if (typeof item.category !== "string") { + errors.push(`Line ${i + 1}: create_discussion 'category' must be a string`); + continue; + } + item.category = sanitizeContent(item.category); + } + item.title = sanitizeContent(item.title); + item.body = sanitizeContent(item.body); + break; + case "missing-tool": + if (!item.tool || typeof item.tool !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'tool' string field`); + continue; + } + if (!item.reason || typeof item.reason !== "string") { + errors.push(`Line ${i + 1}: missing_tool requires a 'reason' string field`); + continue; + } + item.tool = sanitizeContent(item.tool); + item.reason = sanitizeContent(item.reason); + if (item.alternatives !== undefined) { + if (typeof item.alternatives !== "string") { + errors.push(`Line ${i + 1}: missing-tool 'alternatives' must be a string`); + continue; + } + item.alternatives = sanitizeContent(item.alternatives); + } + break; + case "upload-asset": + if (!item.path || typeof item.path !== "string") { + errors.push(`Line ${i + 1}: upload_asset requires a 'path' string field`); + continue; + } + break; + case "create-code-scanning-alert": + if (!item.file || typeof item.file !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'file' field (string)`); + continue; + } + const alertLineValidation = validatePositiveInteger(item.line, "create-code-scanning-alert 'line'", i + 1); + if (!alertLineValidation.isValid) { + if (alertLineValidation.error) { + errors.push(alertLineValidation.error); + } + continue; + } + if (!item.severity || typeof item.severity !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'severity' field (string)`); + continue; + } + if (!item.message || typeof item.message !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert requires a 'message' field (string)`); + continue; + } + const allowedSeverities = ["error", "warning", "info", "note"]; + if (!allowedSeverities.includes(item.severity.toLowerCase())) { + errors.push( + `Line ${i + 1}: create-code-scanning-alert 'severity' must be one of: ${allowedSeverities.join(", ")}, got ${item.severity.toLowerCase()}` + ); + continue; + } + const columnValidation = validateOptionalPositiveInteger(item.column, "create-code-scanning-alert 'column'", i + 1); + if (!columnValidation.isValid) { + if (columnValidation.error) errors.push(columnValidation.error); + continue; + } + if (item.ruleIdSuffix !== undefined) { + if (typeof item.ruleIdSuffix !== "string") { + errors.push(`Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must be a string`); + continue; + } + if (!/^[a-zA-Z0-9_-]+$/.test(item.ruleIdSuffix.trim())) { + errors.push( + `Line ${i + 1}: create-code-scanning-alert 'ruleIdSuffix' must contain only alphanumeric characters, hyphens, and underscores` + ); + continue; + } + } + item.severity = item.severity.toLowerCase(); + item.file = sanitizeContent(item.file); + item.severity = sanitizeContent(item.severity); + item.message = sanitizeContent(item.message); + if (item.ruleIdSuffix) { + item.ruleIdSuffix = sanitizeContent(item.ruleIdSuffix); + } + break; + default: + const jobOutputType = expectedOutputTypes[itemType]; + if (!jobOutputType) { + errors.push(`Line ${i + 1}: Unknown output type '${itemType}'`); + continue; + } + const safeJobConfig = jobOutputType; + if (safeJobConfig && safeJobConfig.inputs) { + const validation = validateItemWithSafeJobConfig(item, safeJobConfig, i + 1); + if (!validation.isValid) { + errors.push(...validation.errors); + continue; + } + Object.assign(item, validation.normalizedItem); + } + break; + } + core.info(`Line ${i + 1}: Valid ${itemType} item`); + parsedItems.push(item); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + errors.push(`Line ${i + 1}: Invalid JSON - ${errorMsg}`); + } + } + if (errors.length > 0) { + core.warning("Validation errors found:"); + errors.forEach(error => core.warning(` - ${error}`)); + if (parsedItems.length === 0) { + core.setFailed(errors.map(e => ` - ${e}`).join("\n")); + return; } - catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - core.warning(`Failed to write to step summary: ${errorMsg}`); + } + for (const itemType of Object.keys(expectedOutputTypes)) { + const minRequired = getMinRequiredForType(itemType, expectedOutputTypes); + if (minRequired > 0) { + const actualCount = parsedItems.filter(item => item.type === itemType).length; + if (actualCount < minRequired) { + errors.push(`Too few items of type '${itemType}'. Minimum required: ${minRequired}, found: ${actualCount}.`); + } } + } + core.info(`Successfully parsed ${parsedItems.length} valid output items`); + const validatedOutput = { + items: parsedItems, + errors: errors, + }; + const agentOutputFile = "/tmp/agent_output.json"; + const validatedOutputJson = JSON.stringify(validatedOutput); + try { + fs.mkdirSync("/tmp", { recursive: true }); + fs.writeFileSync(agentOutputFile, validatedOutputJson, "utf8"); + core.info(`Stored validated output to: ${agentOutputFile}`); + core.exportVariable("GITHUB_AW_AGENT_OUTPUT", agentOutputFile); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.error(`Failed to write agent output file: ${errorMsg}`); + } + core.setOutput("output", JSON.stringify(validatedOutput)); + core.setOutput("raw_output", outputContent); + const outputTypes = Array.from(new Set(parsedItems.map(item => item.type))); + core.info(`output_types: ${outputTypes.join(", ")}`); + core.setOutput("output_types", outputTypes.join(",")); + try { + await core.summary + .addRaw("## Processed Output\n\n") + .addRaw("```json\n") + .addRaw(JSON.stringify(validatedOutput)) + .addRaw("\n```\n") + .write(); + core.info("Successfully wrote processed output to step summary"); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + core.warning(`Failed to write to step summary: ${errorMsg}`); + } } await main(); diff --git a/pkg/workflow/js/create_discussion.js b/pkg/workflow/js/create_discussion.js index 519ae0ee5f8..26779de007b 100644 --- a/pkg/workflow/js/create_discussion.js +++ b/pkg/workflow/js/create_discussion.js @@ -1,55 +1,54 @@ async function main() { - const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; - if (!outputContent) { - core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); - return; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return; - } - core.debug(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } - catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.warning("No valid items found in agent output"); - return; - } - const createDiscussionItems = validatedOutput.items.filter(item => item.type === "create-discussion"); - if (createDiscussionItems.length === 0) { - core.warning("No create-discussion items found in agent output"); - return; - } - core.debug(`Found ${createDiscussionItems.length} create-discussion item(s)`); - if (process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Create Discussions Preview\n\n"; - summaryContent += "The following discussions would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createDiscussionItems.length; i++) { - const item = createDiscussionItems[i]; - summaryContent += `### Discussion ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; - } - if (item.category_id) { - summaryContent += `**Category ID:** ${item.category_id}\n\n`; - } - summaryContent += "---\n\n"; - } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Discussion creation preview written to step summary"); - return; + const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; + if (!outputContent) { + core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); + return; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return; + } + core.debug(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); + return; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.warning("No valid items found in agent output"); + return; + } + const createDiscussionItems = validatedOutput.items.filter(item => item.type === "create-discussion"); + if (createDiscussionItems.length === 0) { + core.warning("No create-discussion items found in agent output"); + return; + } + core.debug(`Found ${createDiscussionItems.length} create-discussion item(s)`); + if (process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true") { + let summaryContent = "## 🎭 Staged Mode: Create Discussions Preview\n\n"; + summaryContent += "The following discussions would be created if staged mode was disabled:\n\n"; + for (let i = 0; i < createDiscussionItems.length; i++) { + const item = createDiscussionItems[i]; + summaryContent += `### Discussion ${i + 1}\n`; + summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; + if (item.body) { + summaryContent += `**Body:**\n${item.body}\n\n`; + } + if (item.category_id) { + summaryContent += `**Category ID:** ${item.category_id}\n\n`; + } + summaryContent += "---\n\n"; } - let discussionCategories = []; - let repositoryId = undefined; - try { - const repositoryQuery = ` + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Discussion creation preview written to step summary"); + return; + } + let discussionCategories = []; + let repositoryId = undefined; + try { + const repositoryQuery = ` query($owner: String!, $repo: String!) { repository(owner: $owner, name: $repo) { id @@ -64,66 +63,68 @@ async function main() { } } `; - const queryResult = await github.graphql(repositoryQuery, { - owner: context.repo.owner, - repo: context.repo.repo, - }); - if (!queryResult || !queryResult.repository) - throw new Error("Failed to fetch repository information via GraphQL"); - repositoryId = queryResult.repository.id; - discussionCategories = queryResult.repository.discussionCategories.nodes || []; - core.info(`Available categories: ${JSON.stringify(discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`); + const queryResult = await github.graphql(repositoryQuery, { + owner: context.repo.owner, + repo: context.repo.repo, + }); + if (!queryResult || !queryResult.repository) throw new Error("Failed to fetch repository information via GraphQL"); + repositoryId = queryResult.repository.id; + discussionCategories = queryResult.repository.discussionCategories.nodes || []; + core.info(`Available categories: ${JSON.stringify(discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + if ( + errorMessage.includes("Not Found") || + errorMessage.includes("not found") || + errorMessage.includes("Could not resolve to a Repository") + ) { + core.info("⚠ Cannot create discussions: Discussions are not enabled for this repository"); + core.info("Consider enabling discussions in repository settings if you want to create discussions automatically"); + return; } - catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Not Found") || - errorMessage.includes("not found") || - errorMessage.includes("Could not resolve to a Repository")) { - core.info("⚠ Cannot create discussions: Discussions are not enabled for this repository"); - core.info("Consider enabling discussions in repository settings if you want to create discussions automatically"); - return; - } - core.error(`Failed to get discussion categories: ${errorMessage}`); - throw error; - } - let categoryId = process.env.GITHUB_AW_DISCUSSION_CATEGORY_ID; - if (!categoryId && discussionCategories.length > 0) { - categoryId = discussionCategories[0].id; - core.info(`No category-id specified, using default category: ${discussionCategories[0].name} (${categoryId})`); + core.error(`Failed to get discussion categories: ${errorMessage}`); + throw error; + } + let categoryId = process.env.GITHUB_AW_DISCUSSION_CATEGORY_ID; + if (!categoryId && discussionCategories.length > 0) { + categoryId = discussionCategories[0].id; + core.info(`No category-id specified, using default category: ${discussionCategories[0].name} (${categoryId})`); + } + if (!categoryId) { + core.error("No discussion category available and none specified in configuration"); + throw new Error("Discussion category is required but not available"); + } + if (!repositoryId) { + core.error("Repository ID is required for creating discussions"); + throw new Error("Repository ID is required but not available"); + } + const createdDiscussions = []; + for (let i = 0; i < createDiscussionItems.length; i++) { + const createDiscussionItem = createDiscussionItems[i]; + core.info( + `Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body.length}` + ); + let title = createDiscussionItem.title ? createDiscussionItem.title.trim() : ""; + let bodyLines = createDiscussionItem.body.split("\n"); + if (!title) { + title = createDiscussionItem.body || "Agent Output"; } - if (!categoryId) { - core.error("No discussion category available and none specified in configuration"); - throw new Error("Discussion category is required but not available"); + const titlePrefix = process.env.GITHUB_AW_DISCUSSION_TITLE_PREFIX; + if (titlePrefix && !title.startsWith(titlePrefix)) { + title = titlePrefix + title; } - if (!repositoryId) { - core.error("Repository ID is required for creating discussions"); - throw new Error("Repository ID is required but not available"); - } - const createdDiscussions = []; - for (let i = 0; i < createDiscussionItems.length; i++) { - const createDiscussionItem = createDiscussionItems[i]; - core.info(`Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body.length}`); - let title = createDiscussionItem.title ? createDiscussionItem.title.trim() : ""; - let bodyLines = createDiscussionItem.body.split("\n"); - if (!title) { - title = createDiscussionItem.body || "Agent Output"; - } - const titlePrefix = process.env.GITHUB_AW_DISCUSSION_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - const workflowName = process.env.GITHUB_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const runUrl = context.payload.repository - ? `${context.payload.repository.html_url}/actions/runs/${runId}` - : `https://github.com/actions/runs/${runId}`; - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating discussion with title: ${title}`); - core.info(`Category ID: ${categoryId}`); - core.info(`Body length: ${body.length}`); - try { - const createDiscussionMutation = ` + const workflowName = process.env.GITHUB_AW_WORKFLOW_NAME || "Workflow"; + const runId = context.runId; + const runUrl = context.payload.repository + ? `${context.payload.repository.html_url}/actions/runs/${runId}` + : `https://github.com/actions/runs/${runId}`; + bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); + const body = bodyLines.join("\n").trim(); + core.info(`Creating discussion with title: ${title}`); + core.info(`Category ID: ${categoryId}`); + core.info(`Body length: ${body.length}`); + try { + const createDiscussionMutation = ` mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) { createDiscussion(input: { repositoryId: $repositoryId, @@ -140,36 +141,35 @@ async function main() { } } `; - const mutationResult = await github.graphql(createDiscussionMutation, { - repositoryId: repositoryId, - categoryId: categoryId, - title: title, - body: body, - }); - const discussion = mutationResult.createDiscussion.discussion; - if (!discussion) { - core.error("Failed to create discussion: No discussion data returned"); - continue; - } - core.info("Created discussion #" + discussion.number + ": " + discussion.url); - createdDiscussions.push(discussion); - if (i === createDiscussionItems.length - 1) { - core.setOutput("discussion_number", discussion.number); - core.setOutput("discussion_url", discussion.url); - } - } - catch (error) { - core.error(`✗ Failed to create discussion "${title}": ${error instanceof Error ? error.message : String(error)}`); - throw error; - } + const mutationResult = await github.graphql(createDiscussionMutation, { + repositoryId: repositoryId, + categoryId: categoryId, + title: title, + body: body, + }); + const discussion = mutationResult.createDiscussion.discussion; + if (!discussion) { + core.error("Failed to create discussion: No discussion data returned"); + continue; + } + core.info("Created discussion #" + discussion.number + ": " + discussion.url); + createdDiscussions.push(discussion); + if (i === createDiscussionItems.length - 1) { + core.setOutput("discussion_number", discussion.number); + core.setOutput("discussion_url", discussion.url); + } + } catch (error) { + core.error(`✗ Failed to create discussion "${title}": ${error instanceof Error ? error.message : String(error)}`); + throw error; } - if (createdDiscussions.length > 0) { - let summaryContent = "\n\n## GitHub Discussions\n"; - for (const discussion of createdDiscussions) { - summaryContent += `- Discussion #${discussion.number}: [${discussion.title}](${discussion.url})\n`; - } - await core.summary.addRaw(summaryContent).write(); + } + if (createdDiscussions.length > 0) { + let summaryContent = "\n\n## GitHub Discussions\n"; + for (const discussion of createdDiscussions) { + summaryContent += `- Discussion #${discussion.number}: [${discussion.title}](${discussion.url})\n`; } - core.info(`Successfully created ${createdDiscussions.length} discussion(s)`); + await core.summary.addRaw(summaryContent).write(); + } + core.info(`Successfully created ${createdDiscussions.length} discussion(s)`); } await main(); diff --git a/pkg/workflow/js/create_issue.js b/pkg/workflow/js/create_issue.js index ee05670e553..f806c3caf49 100644 --- a/pkg/workflow/js/create_issue.js +++ b/pkg/workflow/js/create_issue.js @@ -1,159 +1,161 @@ function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); + if (!content || typeof content !== "string") { + return ""; + } + let sanitized = content.trim(); + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + sanitized = sanitized.replace( + /(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, + (_m, p1, p2) => `${p1}\`@${p2}\`` + ); + sanitized = sanitized.replace(/[<>&'"]/g, ""); + return sanitized.trim(); } async function main() { - const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; - const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; - if (!outputContent) { - core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); - return; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); + const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; + const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; + if (!outputContent) { + core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); + return; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); + return; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + return; + } + const createIssueItems = validatedOutput.items.filter(item => item.type === "create-issue"); + if (createIssueItems.length === 0) { + core.info("No create-issue items found in agent output"); + return; + } + core.info(`Found ${createIssueItems.length} create-issue item(s)`); + if (isStaged) { + let summaryContent = "## 🎭 Staged Mode: Create Issues Preview\n\n"; + summaryContent += "The following issues would be created if staged mode was disabled:\n\n"; + for (let i = 0; i < createIssueItems.length; i++) { + const item = createIssueItems[i]; + summaryContent += `### Issue ${i + 1}\n`; + summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; + if (item.body) { + summaryContent += `**Body:**\n${item.body}\n\n`; + } + if (item.labels && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } - catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Issue creation preview written to step summary"); + return; + } + const parentIssueNumber = context.payload?.issue?.number; + const labelsEnv = process.env.GITHUB_AW_ISSUE_LABELS; + let envLabels = labelsEnv + ? labelsEnv + .split(",") + .map(label => label.trim()) + .filter(label => label) + : []; + const createdIssues = []; + for (let i = 0; i < createIssueItems.length; i++) { + const createIssueItem = createIssueItems[i]; + core.info( + `Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}` + ); + let labels = [...envLabels]; + if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { + labels = [...labels, ...createIssueItem.labels]; } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - return; + labels = labels + .filter(label => label != null && label !== false && label !== 0) + .map(label => String(label).trim()) + .filter(label => label) + .map(label => sanitizeLabelContent(label)) + .filter(label => label) + .map(label => (label.length > 64 ? label.substring(0, 64) : label)) + .filter((label, index, arr) => arr.indexOf(label) === index); + let title = createIssueItem.title ? createIssueItem.title.trim() : ""; + let bodyLines = createIssueItem.body.split("\n"); + if (!title) { + title = createIssueItem.body || "Agent Output"; } - const createIssueItems = validatedOutput.items.filter(item => item.type === "create-issue"); - if (createIssueItems.length === 0) { - core.info("No create-issue items found in agent output"); - return; + const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; + if (titlePrefix && !title.startsWith(titlePrefix)) { + title = titlePrefix + title; } - core.info(`Found ${createIssueItems.length} create-issue item(s)`); - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Issues Preview\n\n"; - summaryContent += "The following issues would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createIssueItems.length; i++) { - const item = createIssueItems[i]; - summaryContent += `### Issue ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; - } - if (item.labels && item.labels.length > 0) { - summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; - } - summaryContent += "---\n\n"; - } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Issue creation preview written to step summary"); - return; + if (parentIssueNumber) { + core.info("Detected issue context, parent issue #" + parentIssueNumber); + bodyLines.push(`Related to #${parentIssueNumber}`); } - const parentIssueNumber = context.payload?.issue?.number; - const labelsEnv = process.env.GITHUB_AW_ISSUE_LABELS; - let envLabels = labelsEnv - ? labelsEnv - .split(",") - .map((label) => label.trim()) - .filter((label) => label) - : []; - const createdIssues = []; - for (let i = 0; i < createIssueItems.length; i++) { - const createIssueItem = createIssueItems[i]; - core.info(`Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}`); - let labels = [...envLabels]; - if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { - labels = [...labels, ...createIssueItem.labels]; - } - labels = labels - .filter(label => label != null && label !== false && label !== 0) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - let title = createIssueItem.title ? createIssueItem.title.trim() : ""; - let bodyLines = createIssueItem.body.split("\n"); - if (!title) { - title = createIssueItem.body || "Agent Output"; - } - const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - if (parentIssueNumber) { - core.info("Detected issue context, parent issue #" + parentIssueNumber); - bodyLines.push(`Related to #${parentIssueNumber}`); - } - const workflowName = process.env.GITHUB_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const runUrl = context.payload.repository - ? `${context.payload.repository.html_url}/actions/runs/${runId}` - : `https://github.com/actions/runs/${runId}`; - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating issue with title: ${title}`); - core.info(`Labels: ${labels}`); - core.info(`Body length: ${body.length}`); + const workflowName = process.env.GITHUB_AW_WORKFLOW_NAME || "Workflow"; + const runId = context.runId; + const runUrl = context.payload.repository + ? `${context.payload.repository.html_url}/actions/runs/${runId}` + : `https://github.com/actions/runs/${runId}`; + bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); + const body = bodyLines.join("\n").trim(); + core.info(`Creating issue with title: ${title}`); + core.info(`Labels: ${labels}`); + core.info(`Body length: ${body.length}`); + try { + const { data: issue } = await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: title, + body: body, + labels: labels, + }); + core.info("Created issue #" + issue.number + ": " + issue.html_url); + createdIssues.push(issue); + if (parentIssueNumber) { try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: body, - labels: labels, - }); - core.info("Created issue #" + issue.number + ": " + issue.html_url); - createdIssues.push(issue); - if (parentIssueNumber) { - try { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: parentIssueNumber, - body: `Created related issue: #${issue.number}`, - }); - core.info("Added comment to parent issue #" + parentIssueNumber); - } - catch (error) { - core.info(`Warning: Could not add comment to parent issue: ${error instanceof Error ? error.message : String(error)}`); - } - } - if (i === createIssueItems.length - 1) { - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - } - } - catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Issues has been disabled in this repository")) { - core.info(`⚠ Cannot create issue "${title}": Issues are disabled for this repository`); - core.info("Consider enabling issues in repository settings if you want to create issues automatically"); - continue; - } - core.error(`✗ Failed to create issue "${title}": ${errorMessage}`); - throw error; + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: parentIssueNumber, + body: `Created related issue: #${issue.number}`, + }); + core.info("Added comment to parent issue #" + parentIssueNumber); + } catch (error) { + core.info(`Warning: Could not add comment to parent issue: ${error instanceof Error ? error.message : String(error)}`); } + } + if (i === createIssueItems.length - 1) { + core.setOutput("issue_number", issue.number); + core.setOutput("issue_url", issue.html_url); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage.includes("Issues has been disabled in this repository")) { + core.info(`⚠ Cannot create issue "${title}": Issues are disabled for this repository`); + core.info("Consider enabling issues in repository settings if you want to create issues automatically"); + continue; + } + core.error(`✗ Failed to create issue "${title}": ${errorMessage}`); + throw error; } - if (createdIssues.length > 0) { - let summaryContent = "\n\n## GitHub Issues\n"; - for (const issue of createdIssues) { - summaryContent += `- Issue #${issue.number}: [${issue.title}](${issue.html_url})\n`; - } - await core.summary.addRaw(summaryContent).write(); + } + if (createdIssues.length > 0) { + let summaryContent = "\n\n## GitHub Issues\n"; + for (const issue of createdIssues) { + summaryContent += `- Issue #${issue.number}: [${issue.title}](${issue.html_url})\n`; } - core.info(`Successfully created ${createdIssues.length} issue(s)`); + await core.summary.addRaw(summaryContent).write(); + } + core.info(`Successfully created ${createdIssues.length} issue(s)`); } (async () => { - await main(); + await main(); })(); From 10ca852396a4b72f54f776bf64b39a96fed97628 Mon Sep 17 00:00:00 2001 From: Peli de Halleux Date: Tue, 7 Oct 2025 16:31:28 +0000 Subject: [PATCH 8/8] Enhance concurrency handling by adding hasDefaultConcurrency flag and updating related tests --- pkg/workflow/claude_engine.go | 8 +- pkg/workflow/concurrency.go | 2 +- pkg/workflow/concurrency_test.go | 4 +- .../engine_concurrency_integration_test.go | 18 +- pkg/workflow/engine_concurrency_test.go | 166 +++++++++--------- 5 files changed, 99 insertions(+), 99 deletions(-) diff --git a/pkg/workflow/claude_engine.go b/pkg/workflow/claude_engine.go index 51ccc3d5bb4..d440fb42768 100644 --- a/pkg/workflow/claude_engine.go +++ b/pkg/workflow/claude_engine.go @@ -26,10 +26,10 @@ func NewClaudeEngine() *ClaudeEngine { description: "Uses Claude Code with full MCP tool support and allow-listing", experimental: false, supportsToolsAllowlist: true, - supportsHTTPTransport: true, // Claude supports both stdio and HTTP transport - supportsMaxTurns: true, // Claude supports max-turns feature - supportsWebFetch: true, // Claude has built-in WebFetch support - supportsWebSearch: true, // Claude has built-in WebSearch support + supportsHTTPTransport: true, // Claude supports both stdio and HTTP transport + supportsMaxTurns: true, // Claude supports max-turns feature + supportsWebFetch: true, // Claude has built-in WebFetch support + supportsWebSearch: true, // Claude has built-in WebSearch support hasDefaultConcurrency: false, // Claude does NOT have default concurrency enabled }, } diff --git a/pkg/workflow/concurrency.go b/pkg/workflow/concurrency.go index d16d1420760..a8ec1f85451 100644 --- a/pkg/workflow/concurrency.go +++ b/pkg/workflow/concurrency.go @@ -45,7 +45,7 @@ func GenerateJobConcurrencyConfig(workflowData *WorkflowData) string { // Get the engine to check if default concurrency should be applied registry := GetGlobalEngineRegistry() engine, err := registry.GetEngine(engineID) - + // If engine not found or doesn't have default concurrency, return empty string (no concurrency) if err != nil || !engine.HasDefaultConcurrency() { return "" diff --git a/pkg/workflow/concurrency_test.go b/pkg/workflow/concurrency_test.go index 56863424a85..3ee2b3270ce 100644 --- a/pkg/workflow/concurrency_test.go +++ b/pkg/workflow/concurrency_test.go @@ -336,7 +336,7 @@ func TestGenerateJobConcurrencyConfig(t *testing.T) { name: "Custom concurrency string (simple group)", workflowData: &WorkflowData{ EngineConfig: &EngineConfig{ - ID: "claude", + ID: "claude", Concurrency: `concurrency: group: "custom-group-${{ github.ref }}"`, }, @@ -349,7 +349,7 @@ func TestGenerateJobConcurrencyConfig(t *testing.T) { name: "Custom concurrency with cancel-in-progress", workflowData: &WorkflowData{ EngineConfig: &EngineConfig{ - ID: "copilot", + ID: "copilot", Concurrency: `concurrency: group: "custom-group" cancel-in-progress: true`, diff --git a/pkg/workflow/engine_concurrency_integration_test.go b/pkg/workflow/engine_concurrency_integration_test.go index 7ccf6782d1e..b258fd9986d 100644 --- a/pkg/workflow/engine_concurrency_integration_test.go +++ b/pkg/workflow/engine_concurrency_integration_test.go @@ -1,19 +1,19 @@ package workflow import ( -"os" -"path/filepath" -"strings" -"testing" + "os" + "path/filepath" + "strings" + "testing" ) func TestEngineConcurrencyIntegration(t *testing.T) { tests := []struct { - name string - markdown string - expectedInJob string - notExpectedInJob string - description string + name string + markdown string + expectedInJob string + notExpectedInJob string + description string }{ { name: "Copilot engine has default concurrency", diff --git a/pkg/workflow/engine_concurrency_test.go b/pkg/workflow/engine_concurrency_test.go index 876db98e023..c2ced91fcb0 100644 --- a/pkg/workflow/engine_concurrency_test.go +++ b/pkg/workflow/engine_concurrency_test.go @@ -1,94 +1,94 @@ package workflow import ( -"testing" + "testing" ) func TestExtractEngineConcurrencyField(t *testing.T) { -compiler := NewCompiler(false, "", "test") + compiler := NewCompiler(false, "", "test") -tests := []struct { -name string -frontmatter map[string]any -expectedConcurrency string -description string -}{ -{ -name: "Simple string concurrency (just group name)", -frontmatter: map[string]any{ -"engine": map[string]any{ -"id": "claude", -"concurrency": "custom-group-${{ github.ref }}", -}, -}, -expectedConcurrency: "concurrency:\n group: \"custom-group-${{ github.ref }}\"", -description: "String concurrency should be converted to proper YAML format", -}, -{ -name: "Object format concurrency with group only", -frontmatter: map[string]any{ -"engine": map[string]any{ -"id": "claude", -"concurrency": map[string]any{ -"group": "custom-group", -}, -}, -}, -expectedConcurrency: "concurrency:\n group: \"custom-group\"", -description: "Object with group only should generate proper YAML", -}, -{ -name: "Object format concurrency with group and cancel-in-progress", -frontmatter: map[string]any{ -"engine": map[string]any{ -"id": "claude", -"concurrency": map[string]any{ -"group": "custom-group", -"cancel-in-progress": true, -}, -}, -}, -expectedConcurrency: "concurrency:\n group: \"custom-group\"\n cancel-in-progress: true", -description: "Object with cancel-in-progress should include it in YAML", -}, -{ -name: "Object format concurrency with cancel-in-progress false", -frontmatter: map[string]any{ -"engine": map[string]any{ -"id": "claude", -"concurrency": map[string]any{ -"group": "custom-group", -"cancel-in-progress": false, -}, -}, -}, -expectedConcurrency: "concurrency:\n group: \"custom-group\"", -description: "Object with cancel-in-progress false should not include it", -}, -{ -name: "No concurrency field", -frontmatter: map[string]any{ -"engine": map[string]any{ -"id": "claude", -}, -}, -expectedConcurrency: "", -description: "Missing concurrency field should return empty string", -}, -} + tests := []struct { + name string + frontmatter map[string]any + expectedConcurrency string + description string + }{ + { + name: "Simple string concurrency (just group name)", + frontmatter: map[string]any{ + "engine": map[string]any{ + "id": "claude", + "concurrency": "custom-group-${{ github.ref }}", + }, + }, + expectedConcurrency: "concurrency:\n group: \"custom-group-${{ github.ref }}\"", + description: "String concurrency should be converted to proper YAML format", + }, + { + name: "Object format concurrency with group only", + frontmatter: map[string]any{ + "engine": map[string]any{ + "id": "claude", + "concurrency": map[string]any{ + "group": "custom-group", + }, + }, + }, + expectedConcurrency: "concurrency:\n group: \"custom-group\"", + description: "Object with group only should generate proper YAML", + }, + { + name: "Object format concurrency with group and cancel-in-progress", + frontmatter: map[string]any{ + "engine": map[string]any{ + "id": "claude", + "concurrency": map[string]any{ + "group": "custom-group", + "cancel-in-progress": true, + }, + }, + }, + expectedConcurrency: "concurrency:\n group: \"custom-group\"\n cancel-in-progress: true", + description: "Object with cancel-in-progress should include it in YAML", + }, + { + name: "Object format concurrency with cancel-in-progress false", + frontmatter: map[string]any{ + "engine": map[string]any{ + "id": "claude", + "concurrency": map[string]any{ + "group": "custom-group", + "cancel-in-progress": false, + }, + }, + }, + expectedConcurrency: "concurrency:\n group: \"custom-group\"", + description: "Object with cancel-in-progress false should not include it", + }, + { + name: "No concurrency field", + frontmatter: map[string]any{ + "engine": map[string]any{ + "id": "claude", + }, + }, + expectedConcurrency: "", + description: "Missing concurrency field should return empty string", + }, + } -for _, tt := range tests { -t.Run(tt.name, func(t *testing.T) { -_, config := compiler.ExtractEngineConfig(tt.frontmatter) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, config := compiler.ExtractEngineConfig(tt.frontmatter) -if config == nil { -t.Fatalf("Expected config to be non-nil") -} + if config == nil { + t.Fatalf("Expected config to be non-nil") + } -if config.Concurrency != tt.expectedConcurrency { -t.Errorf("ExtractEngineConfig() failed for %s\nExpected:\n%s\nGot:\n%s", -tt.description, tt.expectedConcurrency, config.Concurrency) -} -}) -} + if config.Concurrency != tt.expectedConcurrency { + t.Errorf("ExtractEngineConfig() failed for %s\nExpected:\n%s\nGot:\n%s", + tt.description, tt.expectedConcurrency, config.Concurrency) + } + }) + } }