From dba05628d4043c4eea1af7687721d2cb5cd7265b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 21 Dec 2025 19:01:20 +0000 Subject: [PATCH 1/7] Initial plan From 25b5038729ee37d6ba4c1d4f6f7b029339d6b09d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 21 Dec 2025 19:14:12 +0000 Subject: [PATCH 2/7] Implement trigger shorthand syntax parser with comprehensive pattern support - Create unified trigger parser with TriggerIR intermediate representation - Add support for all trigger pattern categories from spec: - Source control (push, pull request with filters) - Issue and discussion events - Manual invocation (manual, workflow completed) - Comment patterns - Release and repository lifecycle - Security patterns (dependabot, security alerts) - External integration (api dispatch) - Integrate with existing schedule preprocessing - Maintain backward compatibility with existing parsers - Add comprehensive unit and integration tests - All tests passing Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/schedule_preprocessing.go | 17 +- pkg/workflow/trigger_parser.go | 615 ++++++++++++++++++ pkg/workflow/trigger_parser_test.go | 560 ++++++++++++++++ .../trigger_shorthand_integration_test.go | 309 +++++++++ 4 files changed, 1500 insertions(+), 1 deletion(-) create mode 100644 pkg/workflow/trigger_parser.go create mode 100644 pkg/workflow/trigger_parser_test.go create mode 100644 pkg/workflow/trigger_shorthand_integration_test.go diff --git a/pkg/workflow/schedule_preprocessing.go b/pkg/workflow/schedule_preprocessing.go index 34c592f0f6..600d9e2a41 100644 --- a/pkg/workflow/schedule_preprocessing.go +++ b/pkg/workflow/schedule_preprocessing.go @@ -25,7 +25,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,6 +59,21 @@ func (c *Compiler) preprocessScheduleFields(frontmatter map[string]any) error { return nil } + // Try the new unified trigger parser for other trigger shorthands + triggerIR, err := ParseTriggerShorthand(onStr) + if err != nil { + return 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 parsedCron, original, err := parser.ParseSchedule(onStr) if err != nil { diff --git a/pkg/workflow/trigger_parser.go b/pkg/workflow/trigger_parser.go new file mode 100644 index 0000000000..0349cb43e0 --- /dev/null +++ b/pkg/workflow/trigger_parser.go @@ -0,0 +1,615 @@ +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") + } + + 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 - need to provide an empty map, not nil + return &TriggerIR{ + Event: "push", + Filters: map[string]any{}, // Empty map to avoid null in YAML + AdditionalEvents: map[string]any{ + "workflow_dispatch": 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", strings.Join(tokens, " ")) +} + +// parsePullRequestTrigger parses pull request triggers +func parsePullRequestTrigger(tokens []string) (*TriggerIR, error) { + if len(tokens) == 1 { + // Simple "pull_request" trigger - use common types + return &TriggerIR{ + Event: "pull_request", + Types: []string{"opened", "synchronize", "reopened"}, + AdditionalEvents: map[string]any{ + "workflow_dispatch": 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", 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") + } + + 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", activityType) + } + + ir := &TriggerIR{ + Event: "issues", + Types: []string{activityType}, + AdditionalEvents: map[string]any{ + "workflow_dispatch": nil, + }, + } + + // Check for label filter: "issue opened labeled