diff --git a/pkg/cli/logs.go b/pkg/cli/logs.go index e5bf5892cfa..42700de6326 100644 --- a/pkg/cli/logs.go +++ b/pkg/cli/logs.go @@ -91,7 +91,7 @@ var ErrNoArtifacts = errors.New("no artifacts found for this run") // fetchJobStatuses gets job information for a workflow run and counts failed jobs func fetchJobStatuses(runID int64, verbose bool) (int, error) { args := []string{"api", fmt.Sprintf("repos/{owner}/{repo}/actions/runs/%d/jobs", runID), "--jq", ".jobs[] | {name: .name, status: .status, conclusion: .conclusion}"} - + if verbose { fmt.Println(console.FormatVerboseMessage(fmt.Sprintf("Fetching job statuses for run %d", runID))) } @@ -113,7 +113,7 @@ func fetchJobStatuses(runID int64, verbose bool) (int, error) { if strings.TrimSpace(line) == "" { continue } - + var job JobInfo if err := json.Unmarshal([]byte(line), &job); err != nil { if verbose { @@ -121,7 +121,7 @@ func fetchJobStatuses(runID int64, verbose bool) (int, error) { } continue } - + // Count jobs with failure conclusions as errors if job.Conclusion == "failure" || job.Conclusion == "cancelled" || job.Conclusion == "timed_out" { failedJobs++ @@ -130,7 +130,7 @@ func fetchJobStatuses(runID int64, verbose bool) (int, error) { } } } - + return failedJobs, nil } diff --git a/pkg/cli/workflows/test-copilot-add-issue-comment.lock.yml b/pkg/cli/workflows/test-copilot-add-issue-comment.lock.yml index 50a64cf9d6d..9e3916ce6d1 100644 --- a/pkg/cli/workflows/test-copilot-add-issue-comment.lock.yml +++ b/pkg/cli/workflows/test-copilot-add-issue-comment.lock.yml @@ -126,8 +126,8 @@ jobs: env: GITHUB_STEP_SUMMARY: ${{ env.GITHUB_STEP_SUMMARY }} GITHUB_TOKEN: ${{ secrets.COPILOT_CLI_TOKEN }} - XDG_CONFIG_HOME: /tmp/.copilot/ - XDG_STATE_HOME: /tmp/.copilot/ + XDG_CONFIG_HOME: /tmp/.copilot + XDG_STATE_HOME: /tmp/.copilot - name: Ensure log file exists if: always() run: | diff --git a/pkg/cli/workflows/test-copilot-add-issue-labels.lock.yml b/pkg/cli/workflows/test-copilot-add-issue-labels.lock.yml index 847f56c4d16..b93855581dc 100644 --- a/pkg/cli/workflows/test-copilot-add-issue-labels.lock.yml +++ b/pkg/cli/workflows/test-copilot-add-issue-labels.lock.yml @@ -126,8 +126,8 @@ jobs: env: GITHUB_STEP_SUMMARY: ${{ env.GITHUB_STEP_SUMMARY }} GITHUB_TOKEN: ${{ secrets.COPILOT_CLI_TOKEN }} - XDG_CONFIG_HOME: /tmp/.copilot/ - XDG_STATE_HOME: /tmp/.copilot/ + XDG_CONFIG_HOME: /tmp/.copilot + XDG_STATE_HOME: /tmp/.copilot - name: Ensure log file exists if: always() run: | diff --git a/pkg/cli/workflows/test-copilot-cache-memory.lock.yml b/pkg/cli/workflows/test-copilot-cache-memory.lock.yml index 9ee7ee39cc1..c38f4117803 100644 --- a/pkg/cli/workflows/test-copilot-cache-memory.lock.yml +++ b/pkg/cli/workflows/test-copilot-cache-memory.lock.yml @@ -211,8 +211,8 @@ jobs: env: GITHUB_STEP_SUMMARY: ${{ env.GITHUB_STEP_SUMMARY }} GITHUB_TOKEN: ${{ secrets.COPILOT_CLI_TOKEN }} - XDG_CONFIG_HOME: /tmp/.copilot/ - XDG_STATE_HOME: /tmp/.copilot/ + XDG_CONFIG_HOME: /tmp/.copilot + XDG_STATE_HOME: /tmp/.copilot - name: Ensure log file exists if: always() run: | diff --git a/pkg/cli/workflows/test-copilot-command.lock.yml b/pkg/cli/workflows/test-copilot-command.lock.yml index 6bc09aaff00..44dee0d843b 100644 --- a/pkg/cli/workflows/test-copilot-command.lock.yml +++ b/pkg/cli/workflows/test-copilot-command.lock.yml @@ -128,8 +128,8 @@ jobs: env: GITHUB_STEP_SUMMARY: ${{ env.GITHUB_STEP_SUMMARY }} GITHUB_TOKEN: ${{ secrets.COPILOT_CLI_TOKEN }} - XDG_CONFIG_HOME: /tmp/.copilot/ - XDG_STATE_HOME: /tmp/.copilot/ + XDG_CONFIG_HOME: /tmp/.copilot + XDG_STATE_HOME: /tmp/.copilot - name: Ensure log file exists if: always() run: | diff --git a/pkg/cli/workflows/test-copilot-create-issue.lock.yml b/pkg/cli/workflows/test-copilot-create-issue.lock.yml index 5fd07fb59c2..966fac42c02 100644 --- a/pkg/cli/workflows/test-copilot-create-issue.lock.yml +++ b/pkg/cli/workflows/test-copilot-create-issue.lock.yml @@ -126,8 +126,8 @@ jobs: env: GITHUB_STEP_SUMMARY: ${{ env.GITHUB_STEP_SUMMARY }} GITHUB_TOKEN: ${{ secrets.COPILOT_CLI_TOKEN }} - XDG_CONFIG_HOME: /tmp/.copilot/ - XDG_STATE_HOME: /tmp/.copilot/ + XDG_CONFIG_HOME: /tmp/.copilot + XDG_STATE_HOME: /tmp/.copilot - name: Ensure log file exists if: always() run: | diff --git a/pkg/cli/workflows/test-copilot-create-pull-request-review-comment.lock.yml b/pkg/cli/workflows/test-copilot-create-pull-request-review-comment.lock.yml index 931cd1169ab..a2841da8a8c 100644 --- a/pkg/cli/workflows/test-copilot-create-pull-request-review-comment.lock.yml +++ b/pkg/cli/workflows/test-copilot-create-pull-request-review-comment.lock.yml @@ -126,8 +126,8 @@ jobs: env: GITHUB_STEP_SUMMARY: ${{ env.GITHUB_STEP_SUMMARY }} GITHUB_TOKEN: ${{ secrets.COPILOT_CLI_TOKEN }} - XDG_CONFIG_HOME: /tmp/.copilot/ - XDG_STATE_HOME: /tmp/.copilot/ + XDG_CONFIG_HOME: /tmp/.copilot + XDG_STATE_HOME: /tmp/.copilot - name: Ensure log file exists if: always() run: | diff --git a/pkg/cli/workflows/test-copilot-create-pull-request.lock.yml b/pkg/cli/workflows/test-copilot-create-pull-request.lock.yml index 05de3fa4cad..98c486e23e5 100644 --- a/pkg/cli/workflows/test-copilot-create-pull-request.lock.yml +++ b/pkg/cli/workflows/test-copilot-create-pull-request.lock.yml @@ -133,8 +133,8 @@ jobs: env: GITHUB_STEP_SUMMARY: ${{ env.GITHUB_STEP_SUMMARY }} GITHUB_TOKEN: ${{ secrets.COPILOT_CLI_TOKEN }} - XDG_CONFIG_HOME: /tmp/.copilot/ - XDG_STATE_HOME: /tmp/.copilot/ + XDG_CONFIG_HOME: /tmp/.copilot + XDG_STATE_HOME: /tmp/.copilot - name: Ensure log file exists if: always() run: | diff --git a/pkg/cli/workflows/test-copilot-create-repository-security-advisory.lock.yml b/pkg/cli/workflows/test-copilot-create-repository-security-advisory.lock.yml index c5781762933..117bb17d332 100644 --- a/pkg/cli/workflows/test-copilot-create-repository-security-advisory.lock.yml +++ b/pkg/cli/workflows/test-copilot-create-repository-security-advisory.lock.yml @@ -129,8 +129,8 @@ jobs: env: GITHUB_STEP_SUMMARY: ${{ env.GITHUB_STEP_SUMMARY }} GITHUB_TOKEN: ${{ secrets.COPILOT_CLI_TOKEN }} - XDG_CONFIG_HOME: /tmp/.copilot/ - XDG_STATE_HOME: /tmp/.copilot/ + XDG_CONFIG_HOME: /tmp/.copilot + XDG_STATE_HOME: /tmp/.copilot - name: Ensure log file exists if: always() run: | diff --git a/pkg/cli/workflows/test-copilot-markitdown-mcp.lock.yml b/pkg/cli/workflows/test-copilot-markitdown-mcp.lock.yml index e03a3c57d01..dd5bb96c810 100644 --- a/pkg/cli/workflows/test-copilot-markitdown-mcp.lock.yml +++ b/pkg/cli/workflows/test-copilot-markitdown-mcp.lock.yml @@ -142,8 +142,8 @@ jobs: env: GITHUB_STEP_SUMMARY: ${{ env.GITHUB_STEP_SUMMARY }} GITHUB_TOKEN: ${{ secrets.COPILOT_CLI_TOKEN }} - XDG_CONFIG_HOME: /tmp/.copilot/ - XDG_STATE_HOME: /tmp/.copilot/ + XDG_CONFIG_HOME: /tmp/.copilot + XDG_STATE_HOME: /tmp/.copilot - name: Ensure log file exists if: always() run: | diff --git a/pkg/cli/workflows/test-copilot-max-patch-size.lock.yml b/pkg/cli/workflows/test-copilot-max-patch-size.lock.yml index 7362887d108..a153ff77348 100644 --- a/pkg/cli/workflows/test-copilot-max-patch-size.lock.yml +++ b/pkg/cli/workflows/test-copilot-max-patch-size.lock.yml @@ -771,8 +771,8 @@ jobs: GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} GITHUB_STEP_SUMMARY: ${{ env.GITHUB_STEP_SUMMARY }} GITHUB_TOKEN: ${{ secrets.COPILOT_CLI_TOKEN }} - XDG_CONFIG_HOME: /tmp/.copilot/ - XDG_STATE_HOME: /tmp/.copilot/ + XDG_CONFIG_HOME: /tmp/.copilot + XDG_STATE_HOME: /tmp/.copilot - name: Ensure log file exists if: always() run: | diff --git a/pkg/cli/workflows/test-copilot-mcp.lock.yml b/pkg/cli/workflows/test-copilot-mcp.lock.yml index f8f47c85d21..212d973b536 100644 --- a/pkg/cli/workflows/test-copilot-mcp.lock.yml +++ b/pkg/cli/workflows/test-copilot-mcp.lock.yml @@ -131,8 +131,8 @@ jobs: env: GITHUB_STEP_SUMMARY: ${{ env.GITHUB_STEP_SUMMARY }} GITHUB_TOKEN: ${{ secrets.COPILOT_CLI_TOKEN }} - XDG_CONFIG_HOME: /tmp/.copilot/ - XDG_STATE_HOME: /tmp/.copilot/ + XDG_CONFIG_HOME: /tmp/.copilot + XDG_STATE_HOME: /tmp/.copilot - name: Ensure log file exists if: always() run: | diff --git a/pkg/cli/workflows/test-copilot-missing-tool.lock.yml b/pkg/cli/workflows/test-copilot-missing-tool.lock.yml index 73d989f97e6..c3415dfe529 100644 --- a/pkg/cli/workflows/test-copilot-missing-tool.lock.yml +++ b/pkg/cli/workflows/test-copilot-missing-tool.lock.yml @@ -828,8 +828,8 @@ jobs: GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} GITHUB_STEP_SUMMARY: ${{ env.GITHUB_STEP_SUMMARY }} GITHUB_TOKEN: ${{ secrets.COPILOT_CLI_TOKEN }} - XDG_CONFIG_HOME: /tmp/.copilot/ - XDG_STATE_HOME: /tmp/.copilot/ + XDG_CONFIG_HOME: /tmp/.copilot + XDG_STATE_HOME: /tmp/.copilot - name: Ensure log file exists if: always() run: | diff --git a/pkg/cli/workflows/test-copilot-patch-size-exceeded.lock.yml b/pkg/cli/workflows/test-copilot-patch-size-exceeded.lock.yml index e6a5a81885d..b7c5771e28a 100644 --- a/pkg/cli/workflows/test-copilot-patch-size-exceeded.lock.yml +++ b/pkg/cli/workflows/test-copilot-patch-size-exceeded.lock.yml @@ -773,8 +773,8 @@ jobs: GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} GITHUB_STEP_SUMMARY: ${{ env.GITHUB_STEP_SUMMARY }} GITHUB_TOKEN: ${{ secrets.COPILOT_CLI_TOKEN }} - XDG_CONFIG_HOME: /tmp/.copilot/ - XDG_STATE_HOME: /tmp/.copilot/ + XDG_CONFIG_HOME: /tmp/.copilot + XDG_STATE_HOME: /tmp/.copilot - name: Ensure log file exists if: always() run: | diff --git a/pkg/cli/workflows/test-copilot-push-to-pull-request-branch.lock.yml b/pkg/cli/workflows/test-copilot-push-to-pull-request-branch.lock.yml index f11f5c874c8..ec1f1a08f13 100644 --- a/pkg/cli/workflows/test-copilot-push-to-pull-request-branch.lock.yml +++ b/pkg/cli/workflows/test-copilot-push-to-pull-request-branch.lock.yml @@ -133,8 +133,8 @@ jobs: env: GITHUB_STEP_SUMMARY: ${{ env.GITHUB_STEP_SUMMARY }} GITHUB_TOKEN: ${{ secrets.COPILOT_CLI_TOKEN }} - XDG_CONFIG_HOME: /tmp/.copilot/ - XDG_STATE_HOME: /tmp/.copilot/ + XDG_CONFIG_HOME: /tmp/.copilot + XDG_STATE_HOME: /tmp/.copilot - name: Ensure log file exists if: always() run: | diff --git a/pkg/cli/workflows/test-copilot-update-issue.lock.yml b/pkg/cli/workflows/test-copilot-update-issue.lock.yml index 6dec92ad46b..3678d74d63c 100644 --- a/pkg/cli/workflows/test-copilot-update-issue.lock.yml +++ b/pkg/cli/workflows/test-copilot-update-issue.lock.yml @@ -129,8 +129,8 @@ jobs: env: GITHUB_STEP_SUMMARY: ${{ env.GITHUB_STEP_SUMMARY }} GITHUB_TOKEN: ${{ secrets.COPILOT_CLI_TOKEN }} - XDG_CONFIG_HOME: /tmp/.copilot/ - XDG_STATE_HOME: /tmp/.copilot/ + XDG_CONFIG_HOME: /tmp/.copilot + XDG_STATE_HOME: /tmp/.copilot - name: Ensure log file exists if: always() run: | diff --git a/pkg/parser/schema.go b/pkg/parser/schema.go index 218fd477707..954a11de986 100644 --- a/pkg/parser/schema.go +++ b/pkg/parser/schema.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "regexp" + "sort" "strings" "github.com/githubnext/gh-aw/pkg/console" @@ -212,6 +213,12 @@ func validateWithSchemaAndLocation(frontmatter map[string]any, schemaJSON, conte // Rewrite "additional properties not allowed" errors to be more friendly message := rewriteAdditionalPropertiesError(primaryPath.Message) + // Add schema-based suggestions + suggestions := generateSchemaBasedSuggestions(schemaJSON, primaryPath.Message, primaryPath.Path) + if suggestions != "" { + message = message + ". " + suggestions + } + // Create a compiler error with precise location information compilerErr := console.CompilerError{ Position: console.ErrorPosition{ @@ -234,6 +241,12 @@ func validateWithSchemaAndLocation(frontmatter map[string]any, schemaJSON, conte // Rewrite "additional properties not allowed" errors to be more friendly message := rewriteAdditionalPropertiesError(errorMsg) + // Add schema-based suggestions for fallback case + suggestions := generateSchemaBasedSuggestions(schemaJSON, errorMsg, "") + if suggestions != "" { + message = message + ". " + suggestions + } + // Fallback: Create a compiler error with basic location information compilerErr := console.CompilerError{ Position: console.ErrorPosition{ @@ -380,6 +393,374 @@ func findFrontmatterBounds(lines []string) (startIdx int, endIdx int, frontmatte return startIdx, endIdx, frontmatterContent } +// Constants for suggestion limits and field generation +const ( + maxClosestMatches = 3 // Maximum number of closest matches to find + maxSuggestions = 5 // Maximum number of suggestions to show + maxAcceptedFields = 10 // Maximum number of accepted fields to display + maxExampleFields = 3 // Maximum number of fields to include in example JSON +) + +// generateSchemaBasedSuggestions generates helpful suggestions based on the schema and error type +func generateSchemaBasedSuggestions(schemaJSON, errorMessage, jsonPath string) string { + // Parse the schema to extract information for suggestions + var schemaDoc any + if err := json.Unmarshal([]byte(schemaJSON), &schemaDoc); err != nil { + return "" // Can't parse schema, no suggestions + } + + // Check if this is an additional properties error + if strings.Contains(strings.ToLower(errorMessage), "additional propert") && strings.Contains(strings.ToLower(errorMessage), "not allowed") { + invalidProps := extractAdditionalPropertyNames(errorMessage) + acceptedFields := extractAcceptedFieldsFromSchema(schemaDoc, jsonPath) + + if len(acceptedFields) > 0 { + return generateFieldSuggestions(invalidProps, acceptedFields) + } + } + + // Check if this is a type error + if strings.Contains(strings.ToLower(errorMessage), "got ") && strings.Contains(strings.ToLower(errorMessage), "want ") { + example := generateExampleJSONForPath(schemaDoc, jsonPath) + if example != "" { + return fmt.Sprintf("Expected format: %s", example) + } + } + + return "" +} + +// extractAcceptedFieldsFromSchema extracts the list of accepted fields from a schema at a given JSON path +func extractAcceptedFieldsFromSchema(schemaDoc any, jsonPath string) []string { + schemaMap, ok := schemaDoc.(map[string]any) + if !ok { + return nil + } + + // Navigate to the schema section for the given path + targetSchema := navigateToSchemaPath(schemaMap, jsonPath) + if targetSchema == nil { + return nil + } + + // Extract properties from the target schema + if properties, ok := targetSchema["properties"].(map[string]any); ok { + var fields []string + for fieldName := range properties { + fields = append(fields, fieldName) + } + sort.Strings(fields) // Sort for consistent output + return fields + } + + return nil +} + +// navigateToSchemaPath navigates to the appropriate schema section for a given JSON path +func navigateToSchemaPath(schema map[string]any, jsonPath string) map[string]any { + if jsonPath == "" { + return schema // Root level + } + + // Parse the JSON path and navigate through the schema + pathSegments := parseJSONPath(jsonPath) + current := schema + + for _, segment := range pathSegments { + if segment.Type == "key" { + // Navigate to properties -> key + if properties, ok := current["properties"].(map[string]any); ok { + if keySchema, ok := properties[segment.Value].(map[string]any); ok { + current = resolveSchemaWithOneOf(keySchema) + } else { + return nil // Path not found in schema + } + } else { + return nil // No properties in current schema + } + } else if segment.Type == "index" { + // For array indices, navigate to items schema + if items, ok := current["items"].(map[string]any); ok { + current = items + } else { + return nil // No items schema for array + } + } + } + + return current +} + +// resolveSchemaWithOneOf resolves a schema that may contain oneOf, choosing the object variant for suggestions +func resolveSchemaWithOneOf(schema map[string]any) map[string]any { + // Check if this schema has oneOf + if oneOf, ok := schema["oneOf"].([]any); ok { + // Look for the first object type in oneOf that has properties + for _, variant := range oneOf { + if variantMap, ok := variant.(map[string]any); ok { + if schemaType, ok := variantMap["type"].(string); ok && schemaType == "object" { + if _, hasProperties := variantMap["properties"]; hasProperties { + return variantMap + } + } + } + } + // If no object with properties found, return the first variant + if len(oneOf) > 0 { + if firstVariant, ok := oneOf[0].(map[string]any); ok { + return firstVariant + } + } + } + + return schema +} + +// generateFieldSuggestions creates a helpful suggestion message for invalid field names +func generateFieldSuggestions(invalidProps, acceptedFields []string) string { + if len(acceptedFields) == 0 || len(invalidProps) == 0 { + return "" + } + + var suggestion strings.Builder + + if len(invalidProps) == 1 { + suggestion.WriteString("Did you mean one of: ") + } else { + suggestion.WriteString("Valid fields are: ") + } + + // Find closest matches using simple string distance + var suggestions []string + for _, invalidProp := range invalidProps { + closest := findClosestMatches(invalidProp, acceptedFields, maxClosestMatches) + suggestions = append(suggestions, closest...) + } + + // If we have specific suggestions, show them first + if len(suggestions) > 0 { + // Remove duplicates + uniqueSuggestions := removeDuplicates(suggestions) + if len(uniqueSuggestions) <= maxSuggestions { + suggestion.WriteString(strings.Join(uniqueSuggestions, ", ")) + } else { + suggestion.WriteString(strings.Join(uniqueSuggestions[:maxSuggestions], ", ")) + suggestion.WriteString(", ...") + } + } else { + // Show all accepted fields if no close matches + if len(acceptedFields) <= maxAcceptedFields { + suggestion.WriteString(strings.Join(acceptedFields, ", ")) + } else { + suggestion.WriteString(strings.Join(acceptedFields[:maxAcceptedFields], ", ")) + suggestion.WriteString(", ...") + } + } + + return suggestion.String() +} + +// findClosestMatches finds the closest matching strings using simple edit distance heuristics +func findClosestMatches(target string, candidates []string, maxResults int) []string { + type match struct { + value string + score int + } + + var matches []match + targetLower := strings.ToLower(target) + + for _, candidate := range candidates { + candidateLower := strings.ToLower(candidate) + score := calculateSimilarityScore(targetLower, candidateLower) + + // Only include if there's some similarity + if score > 0 { + matches = append(matches, match{value: candidate, score: score}) + } + } + + // Sort by score (higher is better) + sort.Slice(matches, func(i, j int) bool { + return matches[i].score > matches[j].score + }) + + // Return top matches + var results []string + for i := 0; i < len(matches) && i < maxResults; i++ { + results = append(results, matches[i].value) + } + + return results +} + +// calculateSimilarityScore calculates a simple similarity score between two strings +func calculateSimilarityScore(a, b string) int { + // Early exit for obviously poor matches (length difference > 2x shorter string length) + minLen := len(a) + if len(b) < minLen { + minLen = len(b) + } + lengthDiff := abs(len(a) - len(b)) + if lengthDiff > minLen*2 && minLen > 0 { + return 0 + } + + // Simple heuristics for string similarity + score := 0 + + // Bonus for substring matches + if strings.Contains(b, a) || strings.Contains(a, b) { + score += 10 + } + + // Bonus for common prefixes + commonPrefix := 0 + for i := 0; i < len(a) && i < len(b) && a[i] == b[i]; i++ { + commonPrefix++ + } + score += commonPrefix * 2 + + // Bonus for common suffixes + commonSuffix := 0 + for i := 0; i < len(a) && i < len(b) && a[len(a)-1-i] == b[len(b)-1-i]; i++ { + commonSuffix++ + } + score += commonSuffix * 2 + + // Penalty for length difference + score -= lengthDiff + + return score +} + +// generateExampleJSONForPath generates an example JSON object for a specific schema path +func generateExampleJSONForPath(schemaDoc any, jsonPath string) string { + schemaMap, ok := schemaDoc.(map[string]any) + if !ok { + return "" + } + + // Navigate to the target schema + targetSchema := navigateToSchemaPath(schemaMap, jsonPath) + if targetSchema == nil { + return "" + } + + // Generate example based on schema type + example := generateExampleFromSchema(targetSchema) + if example == nil { + return "" + } + + // Convert to JSON string + exampleJSON, err := json.Marshal(example) + if err != nil { + return "" + } + + return string(exampleJSON) +} + +// generateExampleFromSchema generates an example value based on a JSON schema +func generateExampleFromSchema(schema map[string]any) any { + schemaType, ok := schema["type"].(string) + if !ok { + // Try to infer from other properties + if _, hasProperties := schema["properties"]; hasProperties { + schemaType = "object" + } else if _, hasItems := schema["items"]; hasItems { + schemaType = "array" + } else { + return nil + } + } + + switch schemaType { + case "string": + if enum, ok := schema["enum"].([]any); ok && len(enum) > 0 { + if str, ok := enum[0].(string); ok { + return str + } + } + return "string" + case "number", "integer": + return 42 + case "boolean": + return true + case "array": + if items, ok := schema["items"].(map[string]any); ok { + itemExample := generateExampleFromSchema(items) + if itemExample != nil { + return []any{itemExample} + } + } + return []any{} + case "object": + result := make(map[string]any) + if properties, ok := schema["properties"].(map[string]any); ok { + // Add required properties first + requiredFields := make(map[string]bool) + if required, ok := schema["required"].([]any); ok { + for _, field := range required { + if fieldName, ok := field.(string); ok { + requiredFields[fieldName] = true + } + } + } + + // Add a few example properties (prioritize required ones) + count := 0 + + // First, add required fields + for propName, propSchema := range properties { + if requiredFields[propName] && count < maxExampleFields { + if propSchemaMap, ok := propSchema.(map[string]any); ok { + result[propName] = generateExampleFromSchema(propSchemaMap) + count++ + } + } + } + + // Then add some optional fields if we have room + for propName, propSchema := range properties { + if !requiredFields[propName] && count < maxExampleFields { + if propSchemaMap, ok := propSchema.(map[string]any); ok { + result[propName] = generateExampleFromSchema(propSchemaMap) + count++ + } + } + } + } + return result + } + + return nil +} + +// removeDuplicates removes duplicate strings from a slice +func removeDuplicates(strings []string) []string { + seen := make(map[string]bool) + var result []string + + for _, str := range strings { + if !seen[str] { + seen[str] = true + result = append(result, str) + } + } + + return result +} + +// abs returns the absolute value of an integer +func abs(x int) int { + if x < 0 { + return -x + } + return x +} + // rewriteAdditionalPropertiesError rewrites "additional properties not allowed" errors to be more user-friendly func rewriteAdditionalPropertiesError(message string) string { // Check if this is an "additional properties not allowed" error diff --git a/pkg/parser/schema_suggestions_test.go b/pkg/parser/schema_suggestions_test.go new file mode 100644 index 00000000000..a24cbc3c9b6 --- /dev/null +++ b/pkg/parser/schema_suggestions_test.go @@ -0,0 +1,320 @@ +package parser + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestGenerateSchemaBasedSuggestions(t *testing.T) { + // Sample schema JSON for testing + schemaJSON := `{ + "type": "object", + "properties": { + "name": {"type": "string"}, + "age": {"type": "integer"}, + "email": {"type": "string"}, + "permissions": { + "type": "object", + "properties": { + "contents": {"type": "string"}, + "issues": {"type": "string"}, + "pull-requests": {"type": "string"} + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }` + + tests := []struct { + name string + errorMessage string + jsonPath string + wantContains []string + wantEmpty bool + }{ + { + name: "additional property error at root level", + errorMessage: "additional property 'invalid_prop' not allowed", + jsonPath: "", + wantContains: []string{"Did you mean one of:", "name", "age", "email"}, + }, + { + name: "additional property error in nested object", + errorMessage: "additional property 'unknown_permission' not allowed", + jsonPath: "/permissions", + wantContains: []string{"Did you mean one of:", "contents", "issues", "pull-requests"}, + }, + { + name: "type error with integer expected", + errorMessage: "got string, want integer", + jsonPath: "/age", + wantContains: []string{"Expected format:", "42"}, + }, + { + name: "multiple additional properties", + errorMessage: "additional properties 'prop1', 'prop2' not allowed", + jsonPath: "", + wantContains: []string{"Valid fields are:", "name", "age"}, + }, + { + name: "non-validation error", + errorMessage: "some other error", + jsonPath: "", + wantEmpty: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := generateSchemaBasedSuggestions(schemaJSON, tt.errorMessage, tt.jsonPath) + + if tt.wantEmpty { + if result != "" { + t.Errorf("Expected empty result, got: %s", result) + } + return + } + + for _, want := range tt.wantContains { + if !strings.Contains(result, want) { + t.Errorf("Expected result to contain '%s', got: %s", want, result) + } + } + }) + } +} + +func TestExtractAcceptedFieldsFromSchema(t *testing.T) { + schemaJSON := `{ + "type": "object", + "properties": { + "name": {"type": "string"}, + "age": {"type": "integer"}, + "permissions": { + "type": "object", + "properties": { + "contents": {"type": "string"}, + "issues": {"type": "string"} + } + } + } + }` + + var schemaDoc any + if err := json.Unmarshal([]byte(schemaJSON), &schemaDoc); err != nil { + t.Fatalf("Failed to unmarshal schema: %v", err) + } + + tests := []struct { + name string + jsonPath string + want []string + }{ + { + name: "root level fields", + jsonPath: "", + want: []string{"age", "name", "permissions"}, // sorted + }, + { + name: "nested object fields", + jsonPath: "/permissions", + want: []string{"contents", "issues"}, // sorted + }, + { + name: "non-existent path", + jsonPath: "/nonexistent", + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := extractAcceptedFieldsFromSchema(schemaDoc, tt.jsonPath) + + if len(result) != len(tt.want) { + t.Errorf("Expected %d fields, got %d: %v", len(tt.want), len(result), result) + return + } + + for i, field := range tt.want { + if i >= len(result) || result[i] != field { + t.Errorf("Expected field[%d] = %s, got %v", i, field, result) + } + } + }) + } +} + +func TestGenerateFieldSuggestions(t *testing.T) { + tests := []struct { + name string + invalidProps []string + acceptedFields []string + wantContains []string + }{ + { + name: "single invalid property with close match", + invalidProps: []string{"contnt"}, + acceptedFields: []string{"content", "contents", "name"}, + wantContains: []string{"Did you mean one of:", "content"}, + }, + { + name: "multiple invalid properties", + invalidProps: []string{"prop1", "prop2"}, + acceptedFields: []string{"name", "age", "email"}, + wantContains: []string{"Valid fields are:", "name", "age", "email"}, + }, + { + name: "no accepted fields", + invalidProps: []string{"invalid"}, + acceptedFields: []string{}, + wantContains: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := generateFieldSuggestions(tt.invalidProps, tt.acceptedFields) + + if len(tt.wantContains) == 0 { + if result != "" { + t.Errorf("Expected empty result, got: %s", result) + } + return + } + + for _, want := range tt.wantContains { + if !strings.Contains(result, want) { + t.Errorf("Expected result to contain '%s', got: %s", want, result) + } + } + }) + } +} + +func TestFindClosestMatches(t *testing.T) { + candidates := []string{"content", "contents", "name", "age", "permissions", "timeout"} + + tests := []struct { + name string + target string + maxResults int + wantFirst string // First result should be this + }{ + { + name: "exact substring match", + target: "content", + maxResults: 3, + wantFirst: "content", + }, + { + name: "partial match", + target: "contnt", + maxResults: 2, + wantFirst: "content", + }, + { + name: "prefix match", + target: "time", + maxResults: 1, + wantFirst: "timeout", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := findClosestMatches(tt.target, candidates, tt.maxResults) + + if len(result) == 0 { + t.Errorf("Expected at least one match, got none") + return + } + + if len(result) > tt.maxResults { + t.Errorf("Expected at most %d results, got %d", tt.maxResults, len(result)) + } + + if result[0] != tt.wantFirst { + t.Errorf("Expected first result to be '%s', got '%s'", tt.wantFirst, result[0]) + } + }) + } +} + +func TestGenerateExampleJSONForPath(t *testing.T) { + schemaJSON := `{ + "type": "object", + "properties": { + "timeout_minutes": {"type": "integer"}, + "name": {"type": "string"}, + "active": {"type": "boolean"}, + "tags": { + "type": "array", + "items": {"type": "string"} + }, + "config": { + "type": "object", + "properties": { + "enabled": {"type": "boolean"}, + "count": {"type": "integer"} + } + } + } + }` + + var schemaDoc any + if err := json.Unmarshal([]byte(schemaJSON), &schemaDoc); err != nil { + t.Fatalf("Failed to unmarshal schema: %v", err) + } + + tests := []struct { + name string + jsonPath string + wantContains []string + }{ + { + name: "integer field", + jsonPath: "/timeout_minutes", + wantContains: []string{"42"}, + }, + { + name: "string field", + jsonPath: "/name", + wantContains: []string{`"string"`}, + }, + { + name: "boolean field", + jsonPath: "/active", + wantContains: []string{"true"}, + }, + { + name: "array field", + jsonPath: "/tags", + wantContains: []string{"[", `"string"`, "]"}, + }, + { + name: "object field", + jsonPath: "/config", + wantContains: []string{"{", "}", "enabled", "count"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := generateExampleJSONForPath(schemaDoc, tt.jsonPath) + + if result == "" { + t.Errorf("Expected non-empty result for path %s", tt.jsonPath) + return + } + + for _, want := range tt.wantContains { + if !strings.Contains(result, want) { + t.Errorf("Expected result to contain '%s', got: %s", want, result) + } + } + }) + } +} diff --git a/pkg/workflow/js/add_labels.js b/pkg/workflow/js/add_labels.js index 635b4afed1d..13a254697ec 100644 --- a/pkg/workflow/js/add_labels.js +++ b/pkg/workflow/js/add_labels.js @@ -1,160 +1,156 @@ 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 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 (!isIssueContext && !isPRContext) { - core.setFailed("Not running in issue or pull request context, skipping label addition"); - return; - } - let issueNumber; - let contextType; - 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.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 = [...new Set(validLabels)]; - 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(` + 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 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 (!isIssueContext && !isPRContext) { + core.setFailed("Not running in issue or pull request context, skipping label addition"); + return; + } + let issueNumber; + let contextType; + 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.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 = [...new Set(validLabels)]; + 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 45c721fa43a..2e0b1b1da63 100644 --- a/pkg/workflow/js/collect_ndjson_output.js +++ b/pkg/workflow/js/collect_ndjson_output.js @@ -1,723 +1,722 @@ 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 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; + 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 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})`, + }; } - 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"); - 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})`, + }; } - 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: 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`, + }; } - const lines = outputContent.trim().split("\n"); - const parsedItems = []; + 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: true, + normalizedValue, + }; + } + function validateItemWithSafeJobConfig(item, jobConfig, lineNum) { 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}`); - } + const normalizedItem = { ...item }; + if (!jobConfig.inputs) { + return { + isValid: true, + errors: [], + normalizedItem: item, + }; } - 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 [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.setOutput("output", ""); + return; + } + 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 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; } + } + 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); + 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 50586387251..67b2609eb35 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,65 +63,67 @@ 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 runId = context.runId; - const runUrl = context.payload.repository - ? `${context.payload.repository.html_url}/actions/runs/${runId}` - : `https://github.com/actions/runs/${runId}`; - bodyLines.push(``, ``, `> Generated by Agentic Workflow [Run](${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 runId = context.runId; + const runUrl = context.payload.repository + ? `${context.payload.repository.html_url}/actions/runs/${runId}` + : `https://github.com/actions/runs/${runId}`; + bodyLines.push(``, ``, `> Generated by Agentic Workflow [Run](${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, @@ -139,37 +140,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 8b221cadb7b..34c3c17b4ec 100644 --- a/pkg/workflow/js/create_issue.js +++ b/pkg/workflow/js/create_issue.js @@ -1,140 +1,138 @@ 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; - try { - validatedOutput = JSON.parse(outputContent); + 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].filter(Boolean); } - catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; + let title = createIssueItem.title ? createIssueItem.title.trim() : ""; + let bodyLines = createIssueItem.body.split("\n"); + if (!title) { + title = createIssueItem.body || "Agent Output"; } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - return; + const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; + if (titlePrefix && !title.startsWith(titlePrefix)) { + title = titlePrefix + title; } - 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; + if (parentIssueNumber) { + core.info("Detected issue context, parent issue #" + parentIssueNumber); + bodyLines.push(`Related to #${parentIssueNumber}`); } - 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].filter(Boolean); - } - 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 runId = context.runId; - const runUrl = context.payload.repository - ? `${context.payload.repository.html_url}/actions/runs/${runId}` - : `https://github.com/actions/runs/${runId}`; - bodyLines.push(``, ``, `> Generated by Agentic Workflow [Run](${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 runId = context.runId; + const runUrl = context.payload.repository + ? `${context.payload.repository.html_url}/actions/runs/${runId}` + : `https://github.com/actions/runs/${runId}`; + bodyLines.push(``, ``, `> Generated by Agentic Workflow [Run](${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(); })(); - diff --git a/pkg/workflow/metrics.go b/pkg/workflow/metrics.go index bc3123d5871..1ed92e87dff 100644 --- a/pkg/workflow/metrics.go +++ b/pkg/workflow/metrics.go @@ -264,25 +264,25 @@ type ErrorWarningCounts struct { // This is more accurate than simple string matching and uses the same logic as validate_errors.cjs func CountErrorsAndWarningsWithPatterns(logContent string, patterns []ErrorPattern) ErrorWarningCounts { counts := ErrorWarningCounts{} - + if len(patterns) == 0 { return counts } - + lines := strings.Split(logContent, "\n") - + for _, pattern := range patterns { regex, err := regexp.Compile(pattern.Pattern) if err != nil { // Skip invalid patterns continue } - + for _, line := range lines { matches := regex.FindAllStringSubmatch(line, -1) for _, match := range matches { level := extractLevelFromMatch(match, pattern) - + if strings.ToLower(level) == "error" { counts.ErrorCount++ } else if strings.ToLower(level) == "warning" || strings.ToLower(level) == "warn" { @@ -291,7 +291,7 @@ func CountErrorsAndWarningsWithPatterns(logContent string, patterns []ErrorPatte } } } - + return counts } @@ -301,8 +301,8 @@ func extractLevelFromMatch(match []string, pattern ErrorPattern) string { if pattern.LevelGroup > 0 && pattern.LevelGroup < len(match) && match[pattern.LevelGroup] != "" { levelText := strings.ToLower(match[pattern.LevelGroup]) // Normalize common error/warning keywords - if strings.Contains(levelText, "err") || strings.Contains(levelText, "error") || - strings.Contains(levelText, "fail") || strings.Contains(levelText, "fatal") { + if strings.Contains(levelText, "err") || strings.Contains(levelText, "error") || + strings.Contains(levelText, "fail") || strings.Contains(levelText, "fatal") { return "error" } else if strings.Contains(levelText, "warn") || strings.Contains(levelText, "warning") { return "warning" @@ -310,17 +310,17 @@ func extractLevelFromMatch(match []string, pattern ErrorPattern) string { // Return the original level text if it doesn't match common patterns return match[pattern.LevelGroup] } - + // Try to infer level from the full match content if len(match) > 0 { fullMatch := strings.ToLower(match[0]) if strings.Contains(fullMatch, "error") || strings.Contains(fullMatch, "err") || - strings.Contains(fullMatch, "fail") || strings.Contains(fullMatch, "fatal") { + strings.Contains(fullMatch, "fail") || strings.Contains(fullMatch, "fatal") { return "error" } else if strings.Contains(fullMatch, "warn") || strings.Contains(fullMatch, "warning") { return "warning" } } - + return "unknown" } diff --git a/pkg/workflow/pattern_error_counting_test.go b/pkg/workflow/pattern_error_counting_test.go index aa0f2540ed2..89a7ee3593f 100644 --- a/pkg/workflow/pattern_error_counting_test.go +++ b/pkg/workflow/pattern_error_counting_test.go @@ -116,7 +116,7 @@ Another warning message`, }, }, expectedErrors: 2, // npm ERR! + random error - expectedWarns: 2, // npm WARN + warning message + expectedWarns: 2, // npm WARN + warning message }, { name: "invalid regex pattern", @@ -150,11 +150,11 @@ warning: be careful`, for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { counts := CountErrorsAndWarningsWithPatterns(tt.logContent, tt.patterns) - + if counts.ErrorCount != tt.expectedErrors { t.Errorf("Expected %d errors, got %d", tt.expectedErrors, counts.ErrorCount) } - + if counts.WarningCount != tt.expectedWarns { t.Errorf("Expected %d warnings, got %d", tt.expectedWarns, counts.WarningCount) } @@ -215,4 +215,4 @@ func TestExtractLevelFromMatch(t *testing.T) { } }) } -} \ No newline at end of file +}