diff --git a/pkg/workflow/compiler_parse.go b/pkg/workflow/compiler_parse.go index b948b8bb2db..36c31b98ac9 100644 --- a/pkg/workflow/compiler_parse.go +++ b/pkg/workflow/compiler_parse.go @@ -46,7 +46,7 @@ func (c *Compiler) ParseWorkflowFile(markdownPath string) (*WorkflowData, error) } // Preprocess schedule fields to convert human-friendly format to cron expressions - if err := c.preprocessScheduleFields(result.Frontmatter); err != nil { + if err := c.preprocessScheduleFields(result.Frontmatter, markdownPath, string(content)); err != nil { return nil, err } diff --git a/pkg/workflow/frontmatter_extraction.go b/pkg/workflow/frontmatter_extraction.go index 59754c49b98..e1909c13f2c 100644 --- a/pkg/workflow/frontmatter_extraction.go +++ b/pkg/workflow/frontmatter_extraction.go @@ -130,7 +130,7 @@ func (c *Compiler) extractTopLevelYAMLSection(frontmatter map[string]any, key st // Exception: names fields in sections with __gh_aw_native_label_filter__ marker in frontmatter are NOT commented out func (c *Compiler) commentOutProcessedFieldsInOnSection(yamlStr string, frontmatter map[string]any) string { frontmatterLog.Print("Processing 'on' section to comment out processed fields") - + // Check frontmatter for native label filter markers nativeLabelFilterSections := make(map[string]bool) if onValue, exists := frontmatter["on"]; exists { @@ -289,42 +289,42 @@ func (c *Compiler) commentOutProcessedFieldsInOnSection(yamlStr string, frontmat // Look back to see if the previous uncommented line was "names:" // Only do this if NOT using native label filtering for this section if !nativeLabelFilterSections[currentSection] { - if len(result) > 0 { - for i := len(result) - 1; i >= 0; i-- { - prevLine := result[i] - prevTrimmed := strings.TrimSpace(prevLine) - - // Skip empty lines - if prevTrimmed == "" { - continue - } + if len(result) > 0 { + for i := len(result) - 1; i >= 0; i-- { + prevLine := result[i] + prevTrimmed := strings.TrimSpace(prevLine) + + // Skip empty lines + if prevTrimmed == "" { + continue + } - // If we find "names:", and current line is an array item, comment it - if strings.Contains(prevTrimmed, "names:") && strings.Contains(prevTrimmed, "# Label filtering") { - if strings.HasPrefix(trimmedLine, "-") { - shouldComment = true - commentReason = " # Label filtering applied via job conditions" + // If we find "names:", and current line is an array item, comment it + if strings.Contains(prevTrimmed, "names:") && strings.Contains(prevTrimmed, "# Label filtering") { + if strings.HasPrefix(trimmedLine, "-") { + shouldComment = true + commentReason = " # Label filtering applied via job conditions" + } + break } - break - } - // If we find a different field or commented names array item, break - if !strings.HasPrefix(prevTrimmed, "#") || !strings.Contains(prevTrimmed, "Label filtering") { - break - } + // If we find a different field or commented names array item, break + if !strings.HasPrefix(prevTrimmed, "#") || !strings.Contains(prevTrimmed, "Label filtering") { + break + } - // If it's a commented names array item, continue - if strings.HasPrefix(prevTrimmed, "# -") && strings.Contains(prevTrimmed, "Label filtering") { - if strings.HasPrefix(trimmedLine, "-") { - shouldComment = true - commentReason = " # Label filtering applied via job conditions" + // If it's a commented names array item, continue + if strings.HasPrefix(prevTrimmed, "# -") && strings.Contains(prevTrimmed, "Label filtering") { + if strings.HasPrefix(trimmedLine, "-") { + shouldComment = true + commentReason = " # Label filtering applied via job conditions" + } + continue } - continue - } - break + break + } } - } } // Close native filter check } diff --git a/pkg/workflow/label_trigger_integration_test.go b/pkg/workflow/label_trigger_integration_test.go index a9e984a65e0..50e2e2eccbf 100644 --- a/pkg/workflow/label_trigger_integration_test.go +++ b/pkg/workflow/label_trigger_integration_test.go @@ -11,7 +11,7 @@ func TestLabelTriggerIntegrationSimple(t *testing.T) { } compiler := NewCompiler(false, "", "test") - err := compiler.preprocessScheduleFields(frontmatter) + err := compiler.preprocessScheduleFields(frontmatter, "", "") if err != nil { t.Fatalf("preprocessScheduleFields() error = %v", err) } @@ -90,7 +90,7 @@ func TestLabelTriggerIntegrationIssue(t *testing.T) { } compiler := NewCompiler(false, "", "test") - err := compiler.preprocessScheduleFields(frontmatter) + err := compiler.preprocessScheduleFields(frontmatter, "", "") if err != nil { t.Fatalf("preprocessScheduleFields() error = %v", err) } @@ -120,7 +120,7 @@ func TestLabelTriggerIntegrationPullRequest(t *testing.T) { } compiler := NewCompiler(false, "", "test") - err := compiler.preprocessScheduleFields(frontmatter) + err := compiler.preprocessScheduleFields(frontmatter, "", "") if err != nil { t.Fatalf("preprocessScheduleFields() error = %v", err) } @@ -186,7 +186,7 @@ func TestLabelTriggerIntegrationDiscussion(t *testing.T) { } compiler := NewCompiler(false, "", "test") - err := compiler.preprocessScheduleFields(frontmatter) + err := compiler.preprocessScheduleFields(frontmatter, "", "") if err != nil { t.Fatalf("preprocessScheduleFields() error = %v", err) } @@ -249,7 +249,7 @@ func TestLabelTriggerIntegrationError(t *testing.T) { } compiler := NewCompiler(false, "", "test") - err := compiler.preprocessScheduleFields(frontmatter) + err := compiler.preprocessScheduleFields(frontmatter, "", "") if err != nil { t.Fatalf("preprocessScheduleFields() unexpected error = %v", err) } diff --git a/pkg/workflow/label_trigger_parser.go b/pkg/workflow/label_trigger_parser.go index 297d6905e4d..8e30df96f5c 100644 --- a/pkg/workflow/label_trigger_parser.go +++ b/pkg/workflow/label_trigger_parser.go @@ -94,7 +94,7 @@ func expandLabelTriggerShorthand(entityType string, labelNames []string) map[str triggerConfig := map[string]any{ "types": []any{"labeled"}, } - + // Only add names field for issues and pull_request (GitHub Actions supports it) // For discussions, names field is not supported by GitHub Actions if entityType == "issues" || entityType == "pull_request" { diff --git a/pkg/workflow/label_trigger_parser_fuzz_test.go b/pkg/workflow/label_trigger_parser_fuzz_test.go index 3b39306463e..f9760a05ccf 100644 --- a/pkg/workflow/label_trigger_parser_fuzz_test.go +++ b/pkg/workflow/label_trigger_parser_fuzz_test.go @@ -26,23 +26,23 @@ func FuzzParseLabelTriggerShorthand(f *testing.F) { f.Add("issue labeled bug enhancement") f.Add("ISSUE LABELED BUG") f.Add("Issue Labeled Bug") - + f.Fuzz(func(t *testing.T, input string) { // The function should never panic regardless of input entityType, labelNames, isLabelTrigger, err := parseLabelTriggerShorthand(input) - + // Validate the output is consistent if isLabelTrigger { // If it's recognized as a label trigger, must have entity type if entityType == "" { t.Errorf("isLabelTrigger=true but entityType is empty for input: %q", input) } - + // If no error, must have at least one label name if err == nil && len(labelNames) == 0 { t.Errorf("isLabelTrigger=true and err=nil but no label names for input: %q", input) } - + // If error is present, label names should be nil or empty if err != nil && len(labelNames) > 0 { t.Errorf("isLabelTrigger=true with error but labelNames is not empty for input: %q", input) @@ -52,23 +52,23 @@ func FuzzParseLabelTriggerShorthand(f *testing.F) { if entityType != "" { t.Errorf("isLabelTrigger=false but entityType=%q for input: %q", entityType, input) } - + // If not a label trigger, label names should be nil if labelNames != nil { t.Errorf("isLabelTrigger=false but labelNames=%v for input: %q", labelNames, input) } - + // If not a label trigger, should not have an error if err != nil { t.Errorf("isLabelTrigger=false but has error for input: %q, error: %v", input, err) } } - + // Validate entity types are only the expected ones if entityType != "" && entityType != "issues" && entityType != "pull_request" && entityType != "discussion" { t.Errorf("unexpected entityType=%q for input: %q", entityType, input) } - + // Validate label names don't contain empty strings for _, label := range labelNames { if label == "" { @@ -88,7 +88,7 @@ func FuzzExpandLabelTriggerShorthand(f *testing.F) { f.Add("issues", "bug,enhancement,priority-high") f.Add("unknown", "test") f.Add("", "bug") - + f.Fuzz(func(t *testing.T, entityType string, labelsStr string) { // Parse labels string into array var labelNames []string @@ -99,32 +99,32 @@ func FuzzExpandLabelTriggerShorthand(f *testing.F) { } } } - + if len(labelNames) == 0 { // Skip if no labels return } - + // The function should never panic result := expandLabelTriggerShorthand(entityType, labelNames) - + // Validate result structure if result == nil { t.Errorf("expandLabelTriggerShorthand returned nil for entityType=%q, labels=%v", entityType, labelNames) return } - + // Check for workflow_dispatch if _, hasDispatch := result["workflow_dispatch"]; !hasDispatch { t.Errorf("result missing workflow_dispatch for entityType=%q", entityType) } - + // Check for trigger key (issues, pull_request, or discussion) hasTrigger := false for key := range result { if key == "issues" || key == "pull_request" || key == "discussion" { hasTrigger = true - + // Validate trigger structure if triggerMap, ok := result[key].(map[string]any); ok { // Check for types field @@ -135,7 +135,7 @@ func FuzzExpandLabelTriggerShorthand(f *testing.F) { } else if len(typeArray) == 0 { t.Errorf("types array is empty for entityType=%q", entityType) } - + // Check for names field if names, hasNames := triggerMap["names"]; !hasNames { t.Errorf("trigger missing names field for entityType=%q", entityType) @@ -147,7 +147,7 @@ func FuzzExpandLabelTriggerShorthand(f *testing.F) { } } } - + if !hasTrigger { t.Errorf("result missing trigger key (issues/pull_request/discussion) for entityType=%q", entityType) } diff --git a/pkg/workflow/schedule_preprocessing.go b/pkg/workflow/schedule_preprocessing.go index 34c592f0f6a..f39cd91746b 100644 --- a/pkg/workflow/schedule_preprocessing.go +++ b/pkg/workflow/schedule_preprocessing.go @@ -1,9 +1,11 @@ package workflow import ( + "errors" "fmt" "strings" + "github.com/githubnext/gh-aw/pkg/console" "github.com/githubnext/gh-aw/pkg/logger" "github.com/githubnext/gh-aw/pkg/parser" ) @@ -16,7 +18,7 @@ var scheduleFriendlyFormats = make(map[string]map[int]string) // preprocessScheduleFields converts human-friendly schedule expressions to cron expressions // in the frontmatter's "on" section. It modifies the frontmatter map in place. -func (c *Compiler) preprocessScheduleFields(frontmatter map[string]any) error { +func (c *Compiler) preprocessScheduleFields(frontmatter map[string]any, markdownPath string, content string) error { schedulePreprocessingLog.Print("Preprocessing schedule fields in frontmatter") // Check if "on" field exists @@ -25,7 +27,7 @@ func (c *Compiler) preprocessScheduleFields(frontmatter map[string]any) error { return nil } - // Check if "on" is a string - might be a schedule expression, slash command shorthand, or label trigger shorthand + // Check if "on" is a string - might be a schedule expression, slash command shorthand, label trigger shorthand, or other trigger shorthand if onStr, ok := onValue.(string); ok { schedulePreprocessingLog.Printf("Processing on field as string: %s", onStr) @@ -59,11 +61,28 @@ func (c *Compiler) preprocessScheduleFields(frontmatter map[string]any) error { return nil } - // Try to parse as a schedule expression + // Try the new unified trigger parser for other trigger shorthands + triggerIR, err := ParseTriggerShorthand(onStr) + if err != nil { + // Wrap the error with source location information + return c.createTriggerParseError(markdownPath, content, onStr, err) + } + if triggerIR != nil { + schedulePreprocessingLog.Printf("Converting shorthand 'on: %s' to structured trigger", onStr) + + // Convert IR to YAML map + onMap := triggerIR.ToYAMLMap() + frontmatter["on"] = onMap + + return nil + } + + // Try to parse as a schedule expression (only if not already recognized as another trigger type) parsedCron, original, err := parser.ParseSchedule(onStr) if err != nil { - // Not a schedule expression, treat as a simple event trigger - schedulePreprocessingLog.Printf("Not a schedule expression: %s", onStr) + // Not a schedule expression either - leave as simple string trigger + // (simple event names like "push", "fork", etc. are valid) + schedulePreprocessingLog.Printf("Not a recognized shorthand or schedule: %s - leaving as-is", onStr) return nil } @@ -323,6 +342,77 @@ func (c *Compiler) preprocessScheduleFields(frontmatter map[string]any) error { return nil } +// createTriggerParseError creates a detailed error for trigger parsing issues with source location +func (c *Compiler) createTriggerParseError(filePath, content, triggerStr string, err error) error { + schedulePreprocessingLog.Printf("Creating trigger parse error for: %s", triggerStr) + + lines := strings.Split(content, "\n") + + // Find the line where "on:" appears in the frontmatter + var onLine int + var onColumn int + inFrontmatter := false + + for i, line := range lines { + lineNum := i + 1 + + // Check for frontmatter delimiter + if strings.TrimSpace(line) == "---" { + if !inFrontmatter { + inFrontmatter = true + } else { + // End of frontmatter + break + } + continue + } + + if inFrontmatter { + // Look for "on:" field + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "on:") { + onLine = lineNum + // Find the column where "on:" starts + onColumn = strings.Index(line, "on:") + 1 + break + } + } + } + + // If we found the line, create a formatted error + if onLine > 0 { + // Create context lines around the error + var context []string + startLine := max(1, onLine-2) + endLine := min(len(lines), onLine+2) + + for i := startLine; i <= endLine; i++ { + if i-1 < len(lines) { + context = append(context, lines[i-1]) + } + } + + compilerErr := console.CompilerError{ + Position: console.ErrorPosition{ + File: filePath, + Line: onLine, + Column: onColumn, + }, + Type: "error", + Message: fmt.Sprintf("trigger syntax error: %s", err.Error()), + Context: context, + } + + // Format and return the error + formattedErr := console.FormatError(compilerErr) + return errors.New(formattedErr) + } + + // Fallback to original error if we can't find the line + schedulePreprocessingLog.Printf("Could not find 'on:' line in frontmatter, using fallback error") + return fmt.Errorf("trigger syntax error: %w", err) +} + // addFriendlyScheduleComments adds comments showing the original friendly format for schedule cron expressions // This function is called after the YAML has been generated from the frontmatter func (c *Compiler) addFriendlyScheduleComments(yamlStr string, frontmatter map[string]any) string { diff --git a/pkg/workflow/schedule_preprocessing_test.go b/pkg/workflow/schedule_preprocessing_test.go index d14485c7e58..293e423b212 100644 --- a/pkg/workflow/schedule_preprocessing_test.go +++ b/pkg/workflow/schedule_preprocessing_test.go @@ -89,7 +89,7 @@ func TestSchedulePreprocessingShorthandOnString(t *testing.T) { // (required for all schedule tests to avoid fuzzy schedule errors) compiler.SetWorkflowIdentifier("test-workflow.md") - err := compiler.preprocessScheduleFields(tt.frontmatter) + err := compiler.preprocessScheduleFields(tt.frontmatter, "", "") if tt.expectedError { if err == nil { @@ -332,7 +332,7 @@ func TestSchedulePreprocessing(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { compiler := NewCompiler(false, "", "test") - err := compiler.preprocessScheduleFields(tt.frontmatter) + err := compiler.preprocessScheduleFields(tt.frontmatter, "", "") if tt.expectedError { if err == nil { @@ -378,7 +378,7 @@ func TestScheduleFriendlyComments(t *testing.T) { compiler := NewCompiler(false, "", "test") // Preprocess to convert and store friendly formats - err := compiler.preprocessScheduleFields(frontmatter) + err := compiler.preprocessScheduleFields(frontmatter, "", "") if err != nil { t.Fatalf("preprocessing failed: %v", err) } @@ -452,7 +452,7 @@ func TestFuzzyScheduleScattering(t *testing.T) { compiler.SetWorkflowIdentifier(tt.workflowIdentifier) } - err := compiler.preprocessScheduleFields(tt.frontmatter) + err := compiler.preprocessScheduleFields(tt.frontmatter, "", "") if tt.expectError { if err == nil { @@ -510,7 +510,7 @@ func TestFuzzyScheduleScatteringDeterministic(t *testing.T) { compiler := NewCompiler(false, "", "test") compiler.SetWorkflowIdentifier(wf) - err := compiler.preprocessScheduleFields(frontmatter) + err := compiler.preprocessScheduleFields(frontmatter, "", "") if err != nil { t.Fatalf("unexpected error for workflow %s: %v", wf, err) } @@ -604,7 +604,7 @@ func TestSchedulePreprocessingWithFuzzyDaily(t *testing.T) { compiler := NewCompiler(false, "", "test") compiler.SetWorkflowIdentifier("test-workflow.md") - err := compiler.preprocessScheduleFields(tt.frontmatter) + err := compiler.preprocessScheduleFields(tt.frontmatter, "", "") if tt.expectError { if err == nil { @@ -669,7 +669,7 @@ func TestSchedulePreprocessingDailyVariations(t *testing.T) { }, } - err := compiler.preprocessScheduleFields(frontmatter) + err := compiler.preprocessScheduleFields(frontmatter, "", "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -760,7 +760,7 @@ func TestSlashCommandShorthand(t *testing.T) { compiler := NewCompiler(false, "", "test") compiler.SetWorkflowIdentifier("test-workflow.md") - err := compiler.preprocessScheduleFields(tt.frontmatter) + err := compiler.preprocessScheduleFields(tt.frontmatter, "", "") if tt.expectedError { if err == nil { diff --git a/pkg/workflow/trigger_parser.go b/pkg/workflow/trigger_parser.go new file mode 100644 index 00000000000..791c06a6566 --- /dev/null +++ b/pkg/workflow/trigger_parser.go @@ -0,0 +1,605 @@ +package workflow + +import ( + "fmt" + "strings" + + "github.com/githubnext/gh-aw/pkg/logger" +) + +var triggerParserLog = logger.New("workflow:trigger_parser") + +// TriggerIR represents the intermediate representation of a parsed trigger +type TriggerIR struct { + // Event is the main GitHub Actions event type (e.g., "push", "pull_request", "issues") + Event string + + // Types contains the activity types for the event (e.g., ["opened", "edited"]) + Types []string + + // Filters contains additional event filters (branches, paths, tags, labels, etc.) + Filters map[string]any + + // Conditions contains job-level conditions for complex filtering + Conditions []string + + // AdditionalEvents contains other events to include (e.g., workflow_dispatch) + AdditionalEvents map[string]any +} + +// ParseTriggerShorthand parses a human-readable trigger shorthand string +// and returns a structured intermediate representation that can be converted to YAML. +// Returns nil if the input is not a recognized trigger shorthand. +func ParseTriggerShorthand(input string) (*TriggerIR, error) { + input = strings.TrimSpace(input) + if input == "" { + return nil, fmt.Errorf("trigger shorthand cannot be empty. Expected format: 'push to ', 'issue opened', 'pull_request merged', etc.") + } + + triggerParserLog.Printf("Parsing trigger shorthand: %s", input) + + // Try parsers in order of specificity: + + // 1. Slash command shorthand (starts with /) + if ir, err := parseSlashCommandTrigger(input); ir != nil || err != nil { + return ir, err + } + + // 2. Label trigger shorthand (entity labeled label1 label2...) + if ir, err := parseLabelTrigger(input); ir != nil || err != nil { + return ir, err + } + + // 3. Source control patterns (push, pull request, etc.) + if ir, err := parseSourceControlTrigger(input); ir != nil || err != nil { + return ir, err + } + + // 4. Issue and discussion patterns + if ir, err := parseIssueDiscussionTrigger(input); ir != nil || err != nil { + return ir, err + } + + // 5. Manual invocation patterns + if ir, err := parseManualTrigger(input); ir != nil || err != nil { + return ir, err + } + + // 6. Comment patterns + if ir, err := parseCommentTrigger(input); ir != nil || err != nil { + return ir, err + } + + // 7. Release and repository patterns + if ir, err := parseReleaseRepositoryTrigger(input); ir != nil || err != nil { + return ir, err + } + + // 8. Security patterns + if ir, err := parseSecurityTrigger(input); ir != nil || err != nil { + return ir, err + } + + // 9. External integration patterns + if ir, err := parseExternalTrigger(input); ir != nil || err != nil { + return ir, err + } + + // Not a recognized trigger shorthand + return nil, nil +} + +// ToYAMLMap converts a TriggerIR to a map structure suitable for YAML generation +func (ir *TriggerIR) ToYAMLMap() map[string]any { + result := make(map[string]any) + + // Add the main event + if ir.Event != "" { + eventConfig := make(map[string]any) + + // Add types if specified + if len(ir.Types) > 0 { + eventConfig["types"] = ir.Types + } + + // Add filters + for key, value := range ir.Filters { + eventConfig[key] = value + } + + // If event config has content, add it; otherwise omit the event entirely for simple triggers + if len(eventConfig) > 0 { + result[ir.Event] = eventConfig + } else { + // For events with no configuration, use an empty map instead of nil + // This ensures proper YAML generation without "null" values + result[ir.Event] = map[string]any{} + } + } + + // Add additional events + for event, config := range ir.AdditionalEvents { + result[event] = config + } + + return result +} + +// parseSlashCommandTrigger parses slash command triggers like "/test" +func parseSlashCommandTrigger(input string) (*TriggerIR, error) { + commandName, isSlashCommand, err := parseSlashCommandShorthand(input) + if err != nil { + return nil, err + } + if !isSlashCommand { + return nil, nil + } + + triggerParserLog.Printf("Parsed slash command trigger: %s", commandName) + + // Note: slash_command is handled specially in the compiler, not as a standard GitHub event + // We return nil here to let the existing slash command processing handle it + return nil, nil +} + +// parseLabelTrigger parses label triggers like "issue labeled bug" or "pull_request labeled needs-review" +func parseLabelTrigger(input string) (*TriggerIR, error) { + entityType, labelNames, isLabelTrigger, err := parseLabelTriggerShorthand(input) + if err != nil { + return nil, err + } + if !isLabelTrigger { + return nil, nil + } + + triggerParserLog.Printf("Parsed label trigger: %s labeled %v", entityType, labelNames) + + // Note: Label triggers are handled specially via expandLabelTriggerShorthand + // We return nil here to let the existing label trigger processing handle it + return nil, nil +} + +// parseSourceControlTrigger parses source control triggers +func parseSourceControlTrigger(input string) (*TriggerIR, error) { + tokens := strings.Fields(input) + if len(tokens) == 0 { + return nil, nil + } + + switch tokens[0] { + case "push": + return parsePushTrigger(tokens) + case "pull", "pull_request": + // Normalize "pull" to "pull_request" + normalizedTokens := append([]string{"pull_request"}, tokens[1:]...) + return parsePullRequestTrigger(normalizedTokens) + default: + return nil, nil + } +} + +// parsePushTrigger parses push-related triggers +func parsePushTrigger(tokens []string) (*TriggerIR, error) { + if len(tokens) == 1 { + // Simple "push" trigger - leave as simple string, don't convert + // GitHub Actions supports simple event names as strings: on: push + return nil, nil + } + + if len(tokens) >= 3 && tokens[1] == "to" { + // "push to " + branch := strings.Join(tokens[2:], " ") + return &TriggerIR{ + Event: "push", + Filters: map[string]any{ + "branches": []string{branch}, + }, + AdditionalEvents: map[string]any{ + "workflow_dispatch": nil, + }, + }, nil + } + + if len(tokens) >= 3 && tokens[1] == "tags" { + // "push tags " + pattern := strings.Join(tokens[2:], " ") + return &TriggerIR{ + Event: "push", + Filters: map[string]any{ + "tags": []string{pattern}, + }, + AdditionalEvents: map[string]any{ + "workflow_dispatch": nil, + }, + }, nil + } + + return nil, fmt.Errorf("invalid push trigger format: '%s'. Expected format: 'push to ' or 'push tags '. Example: 'push to main' or 'push tags v*'", strings.Join(tokens, " ")) +} + +// parsePullRequestTrigger parses pull request triggers +func parsePullRequestTrigger(tokens []string) (*TriggerIR, error) { + if len(tokens) == 1 { + // Simple "pull_request" trigger - leave as simple string + // GitHub Actions supports: on: pull_request + return nil, nil + } + + // Check for activity type: "pull_request opened", "pull_request merged", etc. + activityType := tokens[1] + + // Map common activity types + validTypes := map[string]bool{ + "opened": true, + "edited": true, + "closed": true, + "reopened": true, + "synchronize": true, + "assigned": true, + "unassigned": true, + "labeled": true, + "unlabeled": true, + "review_requested": true, + } + + // Special case: "merged" is not a real type, it's a condition on "closed" + if activityType == "merged" { + return &TriggerIR{ + Event: "pull_request", + Types: []string{"closed"}, + Conditions: []string{"github.event.pull_request.merged == true"}, + AdditionalEvents: map[string]any{ + "workflow_dispatch": nil, + }, + }, nil + } + + if validTypes[activityType] { + ir := &TriggerIR{ + Event: "pull_request", + Types: []string{activityType}, + AdditionalEvents: map[string]any{ + "workflow_dispatch": nil, + }, + } + + // Check for path filter: "pull_request opened affecting " + if len(tokens) >= 4 && tokens[2] == "affecting" { + path := strings.Join(tokens[3:], " ") + ir.Filters = map[string]any{ + "paths": []string{path}, + } + } + + return ir, nil + } + + // Check for "affecting" without activity type: "pull_request affecting " + if activityType == "affecting" && len(tokens) >= 3 { + path := strings.Join(tokens[2:], " ") + return &TriggerIR{ + Event: "pull_request", + Types: []string{"opened", "synchronize", "reopened"}, + Filters: map[string]any{ + "paths": []string{path}, + }, + AdditionalEvents: map[string]any{ + "workflow_dispatch": nil, + }, + }, nil + } + + return nil, fmt.Errorf("invalid pull_request trigger format: '%s'. Expected format: 'pull_request ' or 'pull_request affecting '. Valid types: opened, edited, closed, reopened, synchronize, merged, labeled, unlabeled. Example: 'pull_request opened' or 'pull_request affecting src/**'", strings.Join(tokens, " ")) +} + +// parseIssueDiscussionTrigger parses issue and discussion triggers +func parseIssueDiscussionTrigger(input string) (*TriggerIR, error) { + tokens := strings.Fields(input) + if len(tokens) < 2 { + return nil, nil + } + + switch tokens[0] { + case "issue": + return parseIssueTrigger(tokens) + case "discussion": + return parseDiscussionTrigger(tokens) + default: + return nil, nil + } +} + +// parseIssueTrigger parses issue triggers +func parseIssueTrigger(tokens []string) (*TriggerIR, error) { + if len(tokens) < 2 { + return nil, fmt.Errorf("issue trigger requires an activity type. Expected format: 'issue '. Valid types: opened, edited, closed, reopened, assigned, unassigned, labeled, unlabeled, deleted, transferred. Example: 'issue opened'") + } + + activityType := tokens[1] + + // Map common activity types + validTypes := map[string]bool{ + "opened": true, + "edited": true, + "closed": true, + "reopened": true, + "assigned": true, + "unassigned": true, + "labeled": true, + "unlabeled": true, + "deleted": true, + "transferred": true, + } + + if !validTypes[activityType] { + return nil, fmt.Errorf("invalid issue activity type: '%s'. Valid types: opened, edited, closed, reopened, assigned, unassigned, labeled, unlabeled, deleted, transferred. Example: 'issue opened'", activityType) + } + + ir := &TriggerIR{ + Event: "issues", + Types: []string{activityType}, + AdditionalEvents: map[string]any{ + "workflow_dispatch": nil, + }, + } + + // Check for label filter: "issue opened labeled