diff --git a/pkg/slack/actions.go b/pkg/slack/actions.go index 58731f51d..a0569afde 100644 --- a/pkg/slack/actions.go +++ b/pkg/slack/actions.go @@ -51,6 +51,252 @@ func LaunchCluster(client *slack.Client, jobManager manager.JobManager, event *s return msg } +func ValidateCommand(client *slack.Client, jobManager manager.JobManager, event *slackevents.MessageEvent, properties *parser.Properties) string { + command := strings.TrimSpace(properties.StringParam("command", "")) + if command == "" { + return "Error: Please specify a command to validate. Example: `validate launch 4.19 aws,compact`" + } + + // Parse the command to determine its type + parts := strings.Fields(command) + if len(parts) == 0 { + return "Error: Invalid command format" + } + + commandType := strings.ToLower(parts[0]) + + switch commandType { + case "launch": + if len(parts) < 2 { + return "Error: launch command requires at least an image/version. Example: `validate launch 4.19 aws`" + } + return validateLaunchCommand(jobManager, command, parts[1:], event.User) + case "test": + if len(parts) < 3 { + return "Error: test command requires test name and image/version. Example: `validate test e2e 4.19 aws`" + } + return validateTestCommand(jobManager, command, parts[1:], event.User) + case "build": + if len(parts) < 2 { + return "Error: build command requires PR. Example: `validate build openshift/installer#123`" + } + return validateBuildCommand(jobManager, command, parts[1:], event.User) + default: + return fmt.Sprintf("Error: Validation is not yet supported for '%s' command. Currently supported: launch, test, build", commandType) + } +} + +func validateLaunchCommand(jobManager manager.JobManager, originalCommand string, args []string, userID string) string { + // Parse the launch command similar to LaunchCluster + from, err := ParseImageInput(args[0]) + if err != nil { + return fmt.Sprintf("❌ Invalid input: %v", err) + } + + var inputs [][]string + if len(from) > 0 { + inputs = [][]string{from} + } + + options := "" + if len(args) > 1 { + options = strings.Join(args[1:], " ") + } + + platform, architecture, params, err := ParseOptions(options, inputs, manager.JobTypeInstall) + if err != nil { + return fmt.Sprintf("❌ Invalid options: %v", err) + } + + // Create a job request for validation + jobRequest := &manager.JobRequest{ + OriginalMessage: originalCommand, + User: userID, + UserName: "validation-user", + Inputs: inputs, + Type: manager.JobTypeInstall, + Platform: platform, + JobParams: params, + Architecture: architecture, + } + + // Validate the configuration + err = jobManager.CheckValidJobConfiguration(jobRequest) + if err != nil { + return fmt.Sprintf("❌ Configuration error: %v", err) + } + + // Success message with details + msg := "✅ **Valid launch configuration**\n\n" + msg += fmt.Sprintf("**Would launch:** OpenShift cluster\n") + msg += fmt.Sprintf("**Input:** %s\n", strings.Join(from, ", ")) + msg += fmt.Sprintf("**Platform:** %s\n", platform) + msg += fmt.Sprintf("**Architecture:** %s\n", architecture) + if len(params) > 0 { + var paramsList []string + for key, value := range params { + if value == "true" { + paramsList = append(paramsList, key) + } else { + paramsList = append(paramsList, fmt.Sprintf("%s=%s", key, value)) + } + } + msg += fmt.Sprintf("**Parameters:** %s\n", strings.Join(paramsList, ", ")) + } + msg += "\n*This command would create a cluster with the above configuration.*" + + return msg +} + +func validateTestCommand(jobManager manager.JobManager, originalCommand string, args []string, userID string) string { + if strings.ToLower(args[0]) == "upgrade" { + // Handle test upgrade + if len(args) < 3 { + return "❌ test upgrade requires from and to versions. Example: `validate test upgrade 4.18 4.19 aws`" + } + return validateTestUpgradeCommand(jobManager, originalCommand, args[1:], userID) + } + + // Regular test command + testName := args[0] + from, err := ParseImageInput(args[1]) + if err != nil { + return fmt.Sprintf("❌ Invalid input: %v", err) + } + + options := "" + if len(args) > 2 { + options = strings.Join(args[2:], " ") + } + + platform, architecture, params, err := ParseOptions(options, [][]string{from}, manager.JobTypeTest) + if err != nil { + return fmt.Sprintf("❌ Invalid options: %v", err) + } + + params["test"] = testName + if strings.Contains(params["test"], "-upgrade") { + return "❌ Upgrade type tests require the 'test upgrade' command" + } + + jobRequest := &manager.JobRequest{ + OriginalMessage: originalCommand, + User: userID, + UserName: "validation-user", + Inputs: [][]string{from}, + Type: manager.JobTypeTest, + Platform: platform, + JobParams: params, + Architecture: architecture, + } + + err = jobManager.CheckValidJobConfiguration(jobRequest) + if err != nil { + return fmt.Sprintf("❌ Configuration error: %v", err) + } + + msg := "✅ **Valid test configuration**\n\n" + msg += fmt.Sprintf("**Would run:** %s test suite\n", testName) + msg += fmt.Sprintf("**Input:** %s\n", strings.Join(from, ", ")) + msg += fmt.Sprintf("**Platform:** %s (%s)\n", platform, architecture) + msg += "\n*This command would run the specified test suite.*" + + return msg +} + +func validateTestUpgradeCommand(jobManager manager.JobManager, originalCommand string, args []string, userID string) string { + fromInput, err := ParseImageInput(args[0]) + if err != nil { + return fmt.Sprintf("❌ Invalid from version: %v", err) + } + + toInput, err := ParseImageInput(args[1]) + if err != nil { + return fmt.Sprintf("❌ Invalid to version: %v", err) + } + + options := "" + if len(args) > 2 { + options = strings.Join(args[2:], " ") + } + + platform, architecture, params, err := ParseOptions(options, [][]string{fromInput, toInput}, manager.JobTypeUpgrade) + if err != nil { + return fmt.Sprintf("❌ Invalid options: %v", err) + } + + if len(params["test"]) == 0 { + params["test"] = "e2e-upgrade" + } + if !strings.Contains(params["test"], "-upgrade") { + return "❌ Only upgrade type tests may be run from this command" + } + + jobRequest := &manager.JobRequest{ + OriginalMessage: originalCommand, + User: userID, + UserName: "validation-user", + Inputs: [][]string{fromInput, toInput}, + Type: manager.JobTypeUpgrade, + Platform: platform, + JobParams: params, + Architecture: architecture, + } + + err = jobManager.CheckValidJobConfiguration(jobRequest) + if err != nil { + return fmt.Sprintf("❌ Configuration error: %v", err) + } + + msg := "✅ **Valid upgrade test configuration**\n\n" + msg += fmt.Sprintf("**Would test:** Upgrade from %s to %s\n", strings.Join(fromInput, ", "), strings.Join(toInput, ", ")) + msg += fmt.Sprintf("**Test type:** %s\n", params["test"]) + msg += fmt.Sprintf("**Platform:** %s (%s)\n", platform, architecture) + msg += "\n*This command would test the specified upgrade path.*" + + return msg +} + +func validateBuildCommand(jobManager manager.JobManager, originalCommand string, args []string, userID string) string { + from, err := ParseImageInput(args[0]) + if err != nil { + return fmt.Sprintf("❌ Invalid PR input: %v", err) + } + + options := "" + if len(args) > 1 { + options = strings.Join(args[1:], " ") + } + + platform, architecture, params, err := ParseOptions(options, [][]string{from}, manager.JobTypeBuild) + if err != nil { + return fmt.Sprintf("❌ Invalid options: %v", err) + } + + jobRequest := &manager.JobRequest{ + OriginalMessage: originalCommand, + User: userID, + UserName: "validation-user", + Inputs: [][]string{from}, + Type: manager.JobTypeBuild, + Platform: platform, + JobParams: params, + Architecture: architecture, + } + + err = jobManager.CheckValidJobConfiguration(jobRequest) + if err != nil { + return fmt.Sprintf("❌ Configuration error: %v", err) + } + + msg := "✅ **Valid build configuration**\n\n" + msg += fmt.Sprintf("**Would build:** Release image from %s\n", strings.Join(from, ", ")) + msg += fmt.Sprintf("**Platform:** %s (%s)\n", platform, architecture) + msg += "\n*This command would create a custom release image from the specified PR(s).*" + + return msg +} + func Lookup(client *slack.Client, jobManager manager.JobManager, event *slackevents.MessageEvent, properties *parser.Properties) string { from, err := ParseImageInput(properties.StringParam("image_or_version_or_prs", "")) if err != nil { diff --git a/pkg/slack/events/messages/message_handler.go b/pkg/slack/events/messages/message_handler.go index e7f038941..4ceefeca5 100644 --- a/pkg/slack/events/messages/message_handler.go +++ b/pkg/slack/events/messages/message_handler.go @@ -16,6 +16,29 @@ import ( "k8s.io/klog" ) +const ( + // Slack message size limits + SlackMessageLimit = 3000 + SlackMessageTruncateLimit = 2900 + + // Help categories + HelpCategoryLaunch = "launch" + HelpCategoryRosa = "rosa" + HelpCategoryTest = "test" + HelpCategoryBuild = "build" + HelpCategoryManage = "manage" + HelpCategoryMce = "mce" +) + +// HelpCategories defines the standard help categories (excluding MCE which is private) +var HelpCategories = []string{ + HelpCategoryLaunch, + HelpCategoryRosa, + HelpCategoryTest, + HelpCategoryBuild, + HelpCategoryManage, +} + func Handle(client *slack.Client, manager manager.JobManager, botCommands []parser.BotCommand) events.PartialHandler { return events.PartialHandlerFunc("direct-message", func(callback *slackevents.EventsAPIEvent, logger *logrus.Entry) (handled bool, err error) { @@ -37,8 +60,14 @@ func Handle(client *slack.Client, manager manager.JobManager, botCommands []pars } } mceConfig.Mutex.RUnlock() - if strings.TrimSpace(event.Text) == "help" { - help(client, event, botCommands, allowed) + text := strings.TrimSpace(event.Text) + if text == "help" || strings.HasPrefix(text, "help ") { + parts := strings.Split(text, " ") + if len(parts) == 1 { + HelpOverview(client, event, botCommands, allowed) + } else { + HelpSpecific(client, event, parts[1], botCommands, allowed) + } return true, nil } // do not respond to bots @@ -97,16 +126,430 @@ func postResponse(client *slack.Client, event *slackevents.MessageEvent, respons return nil } -func help(client *slack.Client, event *slackevents.MessageEvent, botCommands []parser.BotCommand, allowPrivate bool) { - helpMessage := "" - for _, command := range botCommands { +// GenerateHelpOverviewMessage creates the help overview message content +func GenerateHelpOverviewMessage(allowPrivate bool) string { + helpMessage := "*🤖 Cluster Bot - Quick Start*\n\n" + + // Common commands + helpMessage += "*Most Used Commands:*\n" + helpMessage += "• `help launch` - Launch OpenShift clusters\n" + helpMessage += "• `help rosa` - ROSA (Red Hat OpenShift Service on AWS)\n" + helpMessage += "• `list` - See active clusters\n" + helpMessage += "• `done` - Terminate your cluster\n" + helpMessage += "• `auth` - Get cluster credentials\n\n" + + // All available commands with detailed usage + helpMessage += "\n*All Commands:*\n" + + helpMessage += "\n*Cluster Launching:*\n" + helpMessage += "• `launch ` - Launch OpenShift clusters using images, versions, or PRs\n" + helpMessage += "• `workflow-launch ` - Launch using custom workflows\n" + + helpMessage += "\n*ROSA Clusters:*\n" + helpMessage += "• `rosa create ` - Create ROSA clusters with automatic teardown\n" + helpMessage += "• `rosa lookup ` - Find supported ROSA versions by prefix\n" + helpMessage += "• `rosa describe ` - Display details of ROSA cluster\n" + + helpMessage += "\n*Cluster Management:*\n" + helpMessage += "• `list` - See who is using all the clusters\n" + helpMessage += "• `done` - Terminate your running cluster\n" + helpMessage += "• `auth` - Get credentials for your most recent cluster\n" + helpMessage += "• `refresh` - Retry fetching credentials if cluster marked as failed\n" + + helpMessage += "\n*Testing:*\n" + helpMessage += "• `test ` - Run test suites from images or PRs\n" + helpMessage += "• `test upgrade ` - Run upgrade tests between release images\n" + helpMessage += "• `workflow-test ` - Test using custom workflows\n" + helpMessage += "• `workflow-upgrade ` - Custom upgrade workflows\n" + + helpMessage += "\n*Building:*\n" + helpMessage += "• `build ` - Create release image from PRs (preserved 12h)\n" + helpMessage += "• `catalog build ` - Create operator catalog from PR\n" + + helpMessage += "\n*Information:*\n" + helpMessage += "• `version` - Report the bot version\n" + helpMessage += "• `lookup ` - Get version info\n" + helpMessage += "• `validate ` - Check command syntax without executing\n" + + if allowPrivate { + helpMessage += "\n*MCE Clusters (Private):*\n" + helpMessage += "• `mce create ` - Create clusters using Hive and MCE\n" + helpMessage += "• `mce auth ` - Get kubeconfig and kubeadmin password for MCE cluster\n" + helpMessage += "• `mce delete ` - Delete MCE cluster\n" + helpMessage += "• `mce list ` - List active MCE clusters\n" + helpMessage += "• `mce lookup` - List available MCE versions\n" + } + + helpMessage += "\n*Category Help:*\n" + helpMessage += "• `help launch` - Cluster launching\n" + helpMessage += "• `help rosa` - ROSA clusters\n" + helpMessage += "• `help test` - Testing & workflows\n" + helpMessage += "• `help build` - Build images\n" + helpMessage += "• `help manage` - Cluster management\n" + + if allowPrivate { + helpMessage += "• `help mce` - MCE clusters (private)\n" + } + + helpMessage += "\n*Examples:*\n" + helpMessage += "• `launch 4.19 aws` - Launch OpenShift 4.19 on AWS\n" + helpMessage += "• `rosa create 4.19 3h` - Create ROSA cluster for 3 hours\n" + helpMessage += "• `validate launch 4.19 aws,compact` - Check launch command\n" + helpMessage += "• `help launch` - See all launch options\n\n" + + helpMessage += "*Additional Links*\n" + helpMessage += "Please check out our for more information.\n" + helpMessage += "You can also reach out to us in for more information.\n" + + return helpMessage +} + +// GenerateLaunchHelpMessage creates the comprehensive launch help message +func GenerateLaunchHelpMessage() string { + helpMessage := "*🚀 Cluster Launching*\n\n" + + helpMessage += "*launch*\n" + helpMessage += "```\nlaunch \n```\n" + helpMessage += "Launch an OpenShift cluster using a known image, version, or PR(s).\n\n" + + helpMessage += "*Input Formats for :*\n" + helpMessage += "• `nightly` - Latest OCP nightly build\n" + helpMessage += "• `ci` - Latest CI build\n" + helpMessage += "• `4.19` - Major.minor for next stable from nightly\n" + helpMessage += "• `4.19.0-0.nightly` - Specific stream name\n" + helpMessage += "• `openshift/installer#123` - Pull request(s)\n" + helpMessage += "• Direct image pull spec from releases page\n\n" + + helpMessage += "*Quick Reference - Common Configurations:*\n" + helpMessage += "• Basic AWS: `launch 4.19 aws`\n" + helpMessage += "• Compact cluster: `launch 4.19 aws,compact`\n" + helpMessage += "• ARM on GCP: `launch 4.19 gcp,arm64`\n" + helpMessage += "• Secure cluster: `launch 4.19 aws,fips,private`\n" + helpMessage += "• Test environment: `launch 4.19 metal,compact,techpreview`\n\n" + + helpMessage += "*Important Notes:*\n" + helpMessage += "• Must contain an OpenShift version\n" + helpMessage += "• Options can be omitted (defaults: aws,amd64)\n" + helpMessage += "• Options is a comma-delimited list including platform, architecture, and variants\n" + helpMessage += "• Order doesn't matter except for readability\n\n" + + helpMessage += "*Platform (choose one):*\n" + helpMessage += "• Most common: `aws`, `gcp`, `azure`, `vsphere`, `metal`\n" + helpMessage += "• Cloud: `alibaba`, `nutanix`, `openstack`\n" + helpMessage += "• Specialized: `ovirt`, `hypershift-hosted`, `hypershift-hosted-powervs`, `azure-stackhub`\n\n" + + helpMessage += "*Architecture (choose one, optional):*\n" + helpMessage += "• `amd64` (default), `arm64`, `multi`\n\n" + + helpMessage += "*Networking (choose one primary, optional):*\n" + helpMessage += "• Primary: `ovn` (default), `sdn`, `kuryr`\n" + helpMessage += "• Modifiers: `ovn-hybrid`, `proxy`\n" + helpMessage += "• IP versions: `ipv4` (default), `ipv6`, `dualstack`, `dualstack-primaryv6`\n\n" + + helpMessage += "*Cluster Size & Configuration (combine as needed):*\n" + helpMessage += "• Size: `compact` (3-node), `single-node`, `large`, `xlarge`\n" + helpMessage += "• Zones: `multi-zone`, `multi-zone-techpreview`\n\n" + + helpMessage += "*Security & Compliance (combine as needed):*\n" + helpMessage += "• `fips`, `private`, `rt`, `no-capabilities`\n\n" + + helpMessage += "*Advanced Options (combine as needed):*\n" + helpMessage += "• Installation: `upi`, `preserve-bootstrap`\n" + helpMessage += "• Runtime: `cgroupsv2`, `crun`, `techpreview`\n" + helpMessage += "• Infrastructure: `mirror`, `shared-vpc`, `no-spot`, `virtualization-support`\n" + helpMessage += "• Special: `test`, `bundle`, `nfv`\n\n" + + helpMessage += "*Option Guidelines:*\n" + helpMessage += "• Start with platform (aws, gcp, etc.)\n" + helpMessage += "• Add architecture if not default (arm64, multi)\n" + helpMessage += "• Add networking if needed (sdn, kuryr, ipv6)\n" + helpMessage += "• Add size/features last (compact, fips, techpreview)\n" + helpMessage += "• Bot will validate combinations and inform about conflicts\n\n" + + helpMessage += "*Examples (Simple to Complex):*\n" + helpMessage += "• `launch 4.19` - Default: latest 4.19 on AWS amd64\n" + helpMessage += "• `launch nightly aws` - Latest nightly build\n" + helpMessage += "• `launch 4.19 gcp,arm64` - Different platform + architecture\n" + helpMessage += "• `launch ci azure,compact,ovn` - CI build + size + networking\n" + helpMessage += "• `launch 4.19 aws,arm64,fips,private` - Security-focused cluster\n" + helpMessage += "• `launch 4.19.0-0.nightly metal,single-node,techpreview` - Advanced config\n" + helpMessage += "• `launch openshift/installer#123 vsphere,multi,cgroupsv2` - PR testing\n" + helpMessage += "• `launch 4.19,openshift/installer#123,openshift/mco#456 aws,multi-zone` - Multi-PR\n" + + return helpMessage +} + +// GenerateRosaHelpMessage creates the comprehensive ROSA help message +func GenerateRosaHelpMessage() string { + helpMessage := "*☁️ ROSA (Red Hat OpenShift Service on AWS)*\n\n" + + helpMessage += "*rosa create*\n" + helpMessage += "```\nrosa create \n```\n" + helpMessage += "Create a ROSA cluster on AWS with automatic teardown.\n\n" + + helpMessage += "*rosa describe*\n" + helpMessage += "```\nrosa describe \n```\n" + helpMessage += "Get detailed information about a ROSA cluster.\n\n" + + helpMessage += "*Common Options:*\n" + helpMessage += "• Duration: `1h`, `3h`, `24h`, `48h`\n" + helpMessage += "• Versions: Latest stable releases\n" + helpMessage += "• Automatic cleanup after expiration\n\n" + + helpMessage += "*Examples:*\n" + helpMessage += "• `rosa create 4.19 3h` - Create 4.19 cluster for 3 hours\n" + helpMessage += "• `rosa create 4.18 24h` - Create 4.18 cluster for 24 hours\n" + helpMessage += "• `rosa describe my-cluster` - Get cluster details\n" + + return helpMessage +} + +// GenerateTestHelpMessage creates the comprehensive testing help message +func GenerateTestHelpMessage() string { + helpMessage := "*🧪 Testing & Workflows*\n\n" + + helpMessage += "*test*\n" + helpMessage += "```\ntest \n```\n" + helpMessage += "Run the requested test suite from an image, release, or built PRs.\n\n" + + helpMessage += "*Available Test Suites:*\n" + helpMessage += "• `e2e` - End-to-end conformance tests\n" + helpMessage += "• `e2e-serial` - Serial end-to-end tests\n" + helpMessage += "• `e2e-all` - All end-to-end tests\n" + helpMessage += "• `e2e-disruptive` - Disruptive tests\n" + helpMessage += "• `e2e-disruptive-all` - All disruptive tests\n" + helpMessage += "• `e2e-builds` - Build-related tests\n" + helpMessage += "• `e2e-image-ecosystem` - Image ecosystem tests\n" + helpMessage += "• `e2e-image-registry` - Image registry tests\n" + helpMessage += "• `e2e-network-stress` - Network stress tests\n\n" + + helpMessage += "*test upgrade*\n" + helpMessage += "```\ntest upgrade \n```\n" + helpMessage += "Run upgrade tests between two release images.\n\n" + + helpMessage += "*Upgrade Test Options:*\n" + helpMessage += "• `e2e-upgrade` - Standard upgrade test (default)\n" + helpMessage += "• `e2e-upgrade-all` - All upgrade tests\n" + helpMessage += "• `e2e-upgrade-partial` - Partial upgrade test\n" + helpMessage += "• `e2e-upgrade-rollback` - Upgrade rollback test\n" + helpMessage += "Pass as `test=NAME` in options (e.g., `test=e2e-upgrade-all`)\n\n" + + helpMessage += "*workflow-test*\n" + helpMessage += "```\nworkflow-test \n```\n" + helpMessage += "Start test using the requested workflow.\n\n" + + helpMessage += "*workflow-upgrade*\n" + helpMessage += "```\nworkflow-upgrade \n```\n" + helpMessage += "Run custom upgrade using the requested workflow.\n\n" + + helpMessage += "*Examples:*\n" + helpMessage += "• `test e2e 4.19 aws` - Run e2e tests on AWS\n" + helpMessage += "• `test e2e-serial 4.19 gcp` - Run serial tests\n" + helpMessage += "• `test upgrade 4.17 4.19 aws` - Test upgrade path\n" + helpMessage += "• `test upgrade 4.17 4.19 aws,test=e2e-upgrade-all` - All upgrade tests\n" + helpMessage += "• `workflow-test openshift-e2e-gcp 4.19` - Run GCP workflow\n" + helpMessage += "• `workflow-upgrade openshift-upgrade-azure-ovn 4.17 4.19 azure` - Custom upgrade\n" + + return helpMessage +} + +// GenerateBuildHelpMessage creates the comprehensive build help message +func GenerateBuildHelpMessage() string { + helpMessage := "*🔨 Building Images*\n\n" + + helpMessage += "*build*\n" + helpMessage += "```\nbuild \n```\n" + helpMessage += "Build custom images from pull requests.\n\n" + + helpMessage += "*catalog build*\n" + helpMessage += "```\ncatalog build \n```\n" + helpMessage += "Build operator catalog images.\n\n" + + helpMessage += "*Build Targets:*\n" + helpMessage += "• `installer` - Build installer images\n" + helpMessage += "• `release` - Build release payload\n" + helpMessage += "• `operator` - Build operator images\n\n" + + helpMessage += "*Examples:*\n" + helpMessage += "• `build openshift/installer#123 installer` - Build installer from PR\n" + helpMessage += "• `catalog build my-operator v1.0` - Build operator catalog\n" + helpMessage += "• `build machine-config-operator#456 release` - Build MCO changes\n" + + return helpMessage +} + +// GenerateManageHelpMessage creates the comprehensive management help message +func GenerateManageHelpMessage() string { + helpMessage := "*⚙️ Cluster Management*\n\n" + + helpMessage += "*list*\n" + helpMessage += "```\nlist [user]\n```\n" + helpMessage += "Show active clusters (all or for specific user).\n\n" + + helpMessage += "*done*\n" + helpMessage += "```\ndone [cluster_name]\n```\n" + helpMessage += "Terminate and cleanup clusters.\n\n" + + helpMessage += "*auth*\n" + helpMessage += "```\nauth \n```\n" + helpMessage += "Get cluster credentials and connection info.\n\n" + + helpMessage += "*refresh*\n" + helpMessage += "```\nrefresh \n```\n" + helpMessage += "Refresh cluster status and extend lifetime.\n\n" + + helpMessage += "*lookup*\n" + helpMessage += "```\nlookup \n```\n" + helpMessage += "Find cluster information by job ID.\n\n" + + helpMessage += "*version*\n" + helpMessage += "```\nversion\n```\n" + helpMessage += "Show cluster-bot version information.\n\n" + + helpMessage += "*Examples:*\n" + helpMessage += "• `list` - Show all your clusters\n" + helpMessage += "• `done my-cluster` - Terminate specific cluster\n" + helpMessage += "• `auth test-cluster` - Get kubeconfig credentials\n" + helpMessage += "• `refresh cluster-123` - Extend cluster lifetime\n" + + return helpMessage +} + +// GenerateMceHelpMessage creates the comprehensive MCE help message +func GenerateMceHelpMessage() string { + helpMessage := "*🏢 MCE (Multi-Cluster Engine) - Private Commands*\n\n" + + helpMessage += "*mce create*\n" + helpMessage += "```\nmce create \n```\n" + helpMessage += "Create MCE hub with managed clusters.\n\n" + + helpMessage += "*mce describe*\n" + helpMessage += "```\nmce describe \n```\n" + helpMessage += "Get detailed MCE hub information.\n\n" + + helpMessage += "*MCE Features:*\n" + helpMessage += "• Multi-cluster management\n" + helpMessage += "• Cluster lifecycle automation\n" + helpMessage += "• Policy and governance\n" + helpMessage += "• Application deployment\n\n" + + helpMessage += "*Examples:*\n" + helpMessage += "• `mce create 2.6 3 aws` - Create hub with 3 managed clusters\n" + helpMessage += "• `mce describe my-hub` - Get hub cluster details\n" + + helpMessage += "\n*Note: MCE commands require special authorization.*\n" + + return helpMessage +} + +// HelpOverview displays a categorized overview of available commands instead of overwhelming users with all commands at once +func HelpOverview(client *slack.Client, event *slackevents.MessageEvent, botCommands []parser.BotCommand, allowPrivate bool) { + helpMessage := GenerateHelpOverviewMessage(allowPrivate) + if err := postResponse(client, event, helpMessage); err != nil { + klog.Errorf("failed to post help overview: %v", err) + } +} + +// HelpSpecific shows detailed help for a specific command category (e.g., "launch", "rosa") with usage examples +func HelpSpecific(client *slack.Client, event *slackevents.MessageEvent, category string, botCommands []parser.BotCommand, allowPrivate bool) { + category = strings.ToLower(category) + + // Use dedicated help functions for comprehensive help + switch category { + case HelpCategoryLaunch, "cluster": + helpMessage := GenerateLaunchHelpMessage() + if err := postResponse(client, event, helpMessage); err != nil { + klog.Errorf("failed to post launch help: %v", err) + } + return + case HelpCategoryRosa: + helpMessage := GenerateRosaHelpMessage() + if err := postResponse(client, event, helpMessage); err != nil { + klog.Errorf("failed to post rosa help: %v", err) + } + return + case HelpCategoryTest, "testing": + helpMessage := GenerateTestHelpMessage() + if err := postResponse(client, event, helpMessage); err != nil { + klog.Errorf("failed to post test help: %v", err) + } + return + case HelpCategoryBuild, "building": + helpMessage := GenerateBuildHelpMessage() + if err := postResponse(client, event, helpMessage); err != nil { + klog.Errorf("failed to post build help: %v", err) + } + return + case HelpCategoryManage, "management": + helpMessage := GenerateManageHelpMessage() + if err := postResponse(client, event, helpMessage); err != nil { + klog.Errorf("failed to post manage help: %v", err) + } + return + case HelpCategoryMce: + if allowPrivate { + helpMessage := GenerateMceHelpMessage() + if err := postResponse(client, event, helpMessage); err != nil { + klog.Errorf("failed to post mce help: %v", err) + } + return + } else { + if err := postResponse(client, event, "MCE commands are not available to you. Please contact an administrator."); err != nil { + klog.Errorf("failed to post mce access error: %v", err) + } + return + } + } + + // Try to find a specific command for unknown categories + var relevantCommands []parser.BotCommand + var categoryTitle string + + // Try to find a specific command + for _, cmd := range botCommands { + if cmd.IsPrivate() && !allowPrivate { + continue + } + tokens := cmd.Tokenize() + if len(tokens) > 0 && strings.ToLower(tokens[0].Word) == category { + relevantCommands = append(relevantCommands, cmd) + categoryTitle = fmt.Sprintf("Command: %s", tokens[0].Word) + break + } + } + + if len(relevantCommands) == 0 { + suggestion := findCommandSuggestion(category, botCommands, allowPrivate) + helpMessage := fmt.Sprintf("❓ Unknown help topic: '%s'\n", category) + if suggestion != "" { + helpMessage += fmt.Sprintf("Did you mean: `help %s`?\n\n", suggestion) + } + helpMessage += "Available help topics:\n" + helpMessage += "• `help launch` - Cluster launching\n" + helpMessage += "• `help rosa` - ROSA clusters\n" + helpMessage += "• `help test` - Testing commands\n" + helpMessage += "• `help build` - Build commands\n" + helpMessage += "• `help manage` - Management commands\n" + if allowPrivate { + helpMessage += "• `help mce` - MCE commands\n" + } + if err := postResponse(client, event, helpMessage); err != nil { + klog.Errorf("failed to post unknown help topic message: %v", err) + } + return + } + + helpMessage := fmt.Sprintf("*%s*\n\n", categoryTitle) + + for _, command := range relevantCommands { if command.IsPrivate() && !allowPrivate { continue } + tokens := command.Tokenize() - // # - helpMessage += "> *" + // Command name + helpMessage += "*" for _, token := range tokens { if !token.IsParameter() { helpMessage += token.Word + " " @@ -114,42 +557,63 @@ func help(client *slack.Client, event *slackevents.MessageEvent, botCommands []p } helpMessage += "*\n" - // ## Usage - // ``` - // usage - // ``` - helpMessage += "*Usage*\n" + // Usage helpMessage += "```\n" for _, token := range tokens { helpMessage += token.Word + " " } helpMessage += "```\n" - // ## Description - // description... + // Description if len(command.Definition().Description) > 0 { - helpMessage += "*Description*\n" - helpMessage += command.Definition().Description - helpMessage += "\n" + helpMessage += command.Definition().Description + "\n" } - // ## Example - // ``` - // example - // ``` + // Example if len(command.Definition().Example) > 0 { - helpMessage += "*Example*\n" - helpMessage += "```\n" - helpMessage += command.Definition().Example - helpMessage += "```\n" + helpMessage += "Example: `" + command.Definition().Example + "`\n" } + + helpMessage += "\n" } - // Adding pointer to our FAQ... - helpMessage += "*Additional Links*\n" - helpMessage += "Please check out our for more information.\n" - helpMessage += "You can also reach out to us in for more information.\n" - _, _, err := client.PostMessage(event.Channel, slack.MsgOptionText(helpMessage, false)) - if err != nil { - klog.Warningf("Failed to post the help message") + + if len(helpMessage) > SlackMessageLimit { + helpMessage = helpMessage[:SlackMessageTruncateLimit] + "...\n\n_Message truncated - try a more specific help topic_" + } + + if err := postResponse(client, event, helpMessage); err != nil { + klog.Errorf("failed to post specific help: %v", err) + } +} + +func findCommandSuggestion(input string, botCommands []parser.BotCommand, allowPrivate bool) string { + input = strings.ToLower(input) + categories := make([]string, len(HelpCategories)) + copy(categories, HelpCategories) + if allowPrivate { + categories = append(categories, HelpCategoryMce) + } + + // Check categories first + for _, cat := range categories { + if strings.Contains(cat, input) || strings.Contains(input, cat) { + return cat + } + } + + // Check individual commands + for _, cmd := range botCommands { + if cmd.IsPrivate() && !allowPrivate { + continue + } + tokens := cmd.Tokenize() + if len(tokens) > 0 { + cmdName := strings.ToLower(tokens[0].Word) + if strings.Contains(cmdName, input) || strings.Contains(input, cmdName) { + return tokens[0].Word + } + } } + + return "" } diff --git a/pkg/slack/events/messages/message_handler_test.go b/pkg/slack/events/messages/message_handler_test.go new file mode 100644 index 000000000..c7ea2d698 --- /dev/null +++ b/pkg/slack/events/messages/message_handler_test.go @@ -0,0 +1,290 @@ +package messages + +import ( + "strings" + "testing" + + "github.com/openshift/ci-chat-bot/pkg/manager" + "github.com/openshift/ci-chat-bot/pkg/slack/parser" + "github.com/slack-go/slack" + "github.com/slack-go/slack/slackevents" +) + +// Mock bot commands for testing +var mockBotCommands = []parser.BotCommand{ + parser.NewBotCommand("launch ", &parser.CommandDefinition{ + Description: "Launch an OpenShift cluster", + Example: "launch 4.19 aws", + Handler: mockHandler, + }, false), + parser.NewBotCommand("rosa create ", &parser.CommandDefinition{ + Description: "Create a ROSA cluster", + Example: "rosa create 4.19 3h", + Handler: mockHandler, + }, false), + parser.NewBotCommand("list", &parser.CommandDefinition{ + Description: "List active clusters", + Handler: mockHandler, + }, false), + parser.NewBotCommand("test ", &parser.CommandDefinition{ + Description: "Run test suite", + Example: "test e2e 4.19 aws", + Handler: mockHandler, + }, false), + parser.NewBotCommand("build ", &parser.CommandDefinition{ + Description: "Build image from PR", + Example: "build openshift/installer#123", + Handler: mockHandler, + }, false), + parser.NewBotCommand("mce create ", &parser.CommandDefinition{ + Description: "Create MCE cluster", + Example: "mce create 4.19 6h aws", + Handler: mockHandler, + }, true), // private command +} + +func mockHandler(client *slack.Client, manager manager.JobManager, event *slackevents.MessageEvent, properties *parser.Properties) string { + return "mock response" +} + +func TestFindCommandSuggestion(t *testing.T) { + testCases := []struct { + name string + input string + allowPrivate bool + expected string + }{ + { + name: "Exact category match", + input: "launch", + expected: "launch", + }, + { + name: "Partial category match", + input: "laun", + expected: "launch", + }, + { + name: "Rosa category", + input: "ros", + expected: "rosa", + }, + { + name: "Test category", + input: "test", + expected: "test", + }, + { + name: "Build category", + input: "buil", + expected: "build", + }, + { + name: "Management category", + input: "manage", + expected: "manage", + }, + { + name: "MCE category with permission", + input: "mce", + allowPrivate: true, + expected: "mce", + }, + { + name: "MCE category without permission", + input: "mce", + allowPrivate: false, + expected: "", + }, + { + name: "Individual command match", + input: "lis", + expected: "list", + }, + { + name: "No match", + input: "xyz", + expected: "", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := findCommandSuggestion(tc.input, mockBotCommands, tc.allowPrivate) + if result != tc.expected { + t.Errorf("Expected '%s', got '%s'", tc.expected, result) + } + }) + } +} + +func TestHelpOverview(t *testing.T) { + // Test the help overview message generation directly + message := GenerateHelpOverviewMessage(false) + + // Check that overview contains expected elements + expectedContent := []string{ + "🤖 Cluster Bot - Quick Start", + "Most Used Commands:", + "help launch", + "help rosa", + "All Commands:", + "Category Help:", + "Examples:", + "launch 4.19 aws", + } + + for _, expected := range expectedContent { + if !strings.Contains(message, expected) { + t.Errorf("Expected message to contain '%s', got: %s", expected, message) + } + } + + // Should not contain MCE when not private + if strings.Contains(message, "help mce") { + t.Errorf("Should not contain MCE help for non-private user") + } +} + +func TestHelpOverviewWithPrivate(t *testing.T) { + // Test the help overview message generation with private commands + message := GenerateHelpOverviewMessage(true) + + // Should contain MCE when private + if !strings.Contains(message, "help mce") { + t.Errorf("Should contain MCE help for private user, got: %s", message) + } +} + +func TestHelpSpecificLaunch(t *testing.T) { + // Test the launch help message generation directly + message := GenerateLaunchHelpMessage() + + // Check launch-specific content + expectedContent := []string{ + "🚀 Cluster Launching", + "launch", + "Launch an OpenShift cluster", + "launch 4.19 aws", + } + + for _, expected := range expectedContent { + if !strings.Contains(message, expected) { + t.Errorf("Expected message to contain '%s', got: %s", expected, message) + } + } + + // Should not contain ROSA commands + if strings.Contains(message, "rosa create") { + t.Errorf("Launch help should not contain ROSA commands") + } +} + +func TestHelpSpecificRosa(t *testing.T) { + // Test the ROSA help message generation directly + message := GenerateRosaHelpMessage() + + // Check ROSA-specific content + expectedContent := []string{ + "☁️ ROSA", + "rosa create", + "Create a ROSA cluster", + "rosa create 4.19 3h", + } + + for _, expected := range expectedContent { + if !strings.Contains(message, expected) { + t.Errorf("Expected message to contain '%s', got: %s", expected, message) + } + } +} + +func TestHelpSpecificUnknownTopic(t *testing.T) { + // Test that findCommandSuggestion returns empty for truly unknown topics + suggestion := findCommandSuggestion("unknown", mockBotCommands, false) + if suggestion != "" { + t.Errorf("Expected no suggestion for 'unknown', got '%s'", suggestion) + } +} + +func TestHelpSpecificWithSuggestion(t *testing.T) { + // Test that findCommandSuggestion works for partial matches + suggestion := findCommandSuggestion("laun", mockBotCommands, false) + if suggestion != "launch" { + t.Errorf("Expected 'launch' suggestion for 'laun', got '%s'", suggestion) + } +} + +func TestGenerateTestHelpMessage(t *testing.T) { + // Test the test help message generation + message := GenerateTestHelpMessage() + + expectedContent := []string{ + "🧪 Testing & Workflows", + "test", + "test upgrade", + "workflow-test", + } + + for _, expected := range expectedContent { + if !strings.Contains(message, expected) { + t.Errorf("Expected message to contain '%s'", expected) + } + } +} + +func TestGenerateBuildHelpMessage(t *testing.T) { + // Test the build help message generation + message := GenerateBuildHelpMessage() + + expectedContent := []string{ + "🔨 Building Images", + "build", + "catalog build", + } + + for _, expected := range expectedContent { + if !strings.Contains(message, expected) { + t.Errorf("Expected message to contain '%s'", expected) + } + } +} + +func TestGenerateManageHelpMessage(t *testing.T) { + // Test the manage help message generation + message := GenerateManageHelpMessage() + + expectedContent := []string{ + "⚙️ Cluster Management", + "list", + "done", + "auth", + "refresh", + "version", + } + + for _, expected := range expectedContent { + if !strings.Contains(message, expected) { + t.Errorf("Expected message to contain '%s'", expected) + } + } +} + +func TestGenerateMceHelpMessage(t *testing.T) { + // Test the MCE help message generation + message := GenerateMceHelpMessage() + + expectedContent := []string{ + "🏢 MCE", + "mce create", + "mce describe", + "Multi-cluster management", + "special authorization", + } + + for _, expected := range expectedContent { + if !strings.Contains(message, expected) { + t.Errorf("Expected message to contain '%s'", expected) + } + } +} diff --git a/pkg/slack/slack.go b/pkg/slack/slack.go index 28bb3f5f8..4bed71c60 100644 --- a/pkg/slack/slack.go +++ b/pkg/slack/slack.go @@ -223,6 +223,11 @@ func (b *Bot) SupportedCommands() []parser.BotCommand { Description: "List available versions for MCE clusters.", Handler: MceImageSets, }, true), + parser.NewBotCommand("validate ", &parser.CommandDefinition{ + Description: "Validate a command without executing it. Shows what would happen and checks for syntax errors.", + Example: "validate launch 4.19 aws,compact", + Handler: ValidateCommand, + }, false), } } @@ -507,14 +512,64 @@ func ParseImageInput(input string) ([]string, error) { } input = utils.StripLinks(input) parts := strings.Split(input, ",") - for i, part := range parts { + var validParts []string + for _, part := range parts { part = strings.TrimSpace(part) - if len(part) == 0 { - return nil, fmt.Errorf("image inputs must not contain empty items") + if len(part) > 0 { + validParts = append(validParts, part) + } + } + if len(validParts) == 0 { + return nil, fmt.Errorf("no valid inputs found. Please provide at least one version, image, or PR") + } + return validParts, nil +} + +// findClosestMatch finds the closest match to input from a list of valid options +func findClosestMatch(input string, validOptions []string) string { + input = strings.ToLower(input) + var bestMatch string + bestScore := 0 + + for _, option := range validOptions { + optionLower := strings.ToLower(option) + score := 0 + + // Exact match + if input == optionLower { + return option + } + + // Prefix match gets high score + if strings.HasPrefix(optionLower, input) { + score = len(input) * 2 + } + + // Contains match gets medium score + if strings.Contains(optionLower, input) { + score = len(input) + } + + // Simple character overlap + for _, char := range input { + if strings.ContainsRune(optionLower, char) { + score++ + } + } + + if score > bestScore { + bestScore = score + bestMatch = option } - parts[i] = part // store trimmed variant } - return parts, nil + + // Only suggest if we have a reasonable match + // Require at least 2/3 of the characters to match, minimum score of 2 + minScore := max(2, (len(input)*2)/3) + if bestScore >= minScore { + return bestMatch + } + return "" } func ParseOptions(options string, inputs [][]string, jobType manager.JobType) (string, string, map[string]string, error) { @@ -542,7 +597,25 @@ func ParseOptions(options string, inputs [][]string, jobType manager.JobType) (s case slices.Contains(manager.SupportedParameters, opt): // do nothing default: - return "", "", nil, fmt.Errorf("unrecognized option: %s", opt) + // Try to find a close match + allOptions := make([]string, 0, len(manager.SupportedPlatforms)+len(manager.SupportedArchitectures)+len(manager.SupportedParameters)) + allOptions = append(allOptions, manager.SupportedPlatforms...) + allOptions = append(allOptions, manager.SupportedArchitectures...) + allOptions = append(allOptions, manager.SupportedParameters...) + + suggestion := findClosestMatch(opt, allOptions) + if suggestion != "" { + return "", "", nil, fmt.Errorf("unrecognized option '%s'. Did you mean '%s'?\n\nValid options:\n- Platforms: %s\n- Architectures: %s\n- Parameters: %s", + opt, suggestion, + strings.Join(manager.SupportedPlatforms, ", "), + strings.Join(manager.SupportedArchitectures, ", "), + strings.Join(manager.SupportedParameters, ", ")) + } + return "", "", nil, fmt.Errorf("unrecognized option '%s'.\n\nValid options:\n- Platforms: %s\n- Architectures: %s\n- Parameters: %s", + opt, + strings.Join(manager.SupportedPlatforms, ", "), + strings.Join(manager.SupportedArchitectures, ", "), + strings.Join(manager.SupportedParameters, ", ")) } } if len(platform) == 0 { diff --git a/pkg/slack/slack_test.go b/pkg/slack/slack_test.go index 1506b47b5..846303b76 100644 --- a/pkg/slack/slack_test.go +++ b/pkg/slack/slack_test.go @@ -2,9 +2,286 @@ package slack import ( "maps" + "strings" "testing" + + "github.com/openshift/ci-chat-bot/pkg/manager" ) +func TestParseImageInput(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + input string + expected []string + expectError bool + errorMsg string + }{ + { + name: "Empty input", + input: "", + expected: nil, + }, + { + name: "Single version", + input: "4.19", + expected: []string{"4.19"}, + }, + { + name: "Version with comma (original problem case)", + input: "4.19, aws", + expected: []string{"4.19", "aws"}, // Should filter empty strings + }, + { + name: "Trailing comma (user's problem)", + input: "4.19,openshift/installer#123,", + expected: []string{"4.19", "openshift/installer#123"}, + }, + { + name: "Leading comma", + input: ",4.19,aws", + expected: []string{"4.19", "aws"}, + }, + { + name: "Multiple spaces", + input: "4.19 , aws , gcp", + expected: []string{"4.19", "aws", "gcp"}, + }, + { + name: "Mixed PRs and versions", + input: "4.19,openshift/installer#7160,openshift/machine-config-operator#3688", + expected: []string{"4.19", "openshift/installer#7160", "openshift/machine-config-operator#3688"}, + }, + { + name: "Only commas and spaces", + input: " , , , ", + expected: nil, + expectError: true, + errorMsg: "no valid inputs found", + }, + { + name: "Version with trailing space and comma", + input: "4.19.0-0.nightly-arm64-2025-09-11-024736, metal", + expected: []string{"4.19.0-0.nightly-arm64-2025-09-11-024736", "metal"}, + }, + { + name: "Input with tabs and newlines", + input: "4.19\t,\n aws\n,\tgcp ", + expected: []string{"4.19", "aws", "gcp"}, + }, + { + name: "Multiple consecutive commas", + input: "4.19,,,,aws,,,gcp", + expected: []string{"4.19", "aws", "gcp"}, + }, + { + name: "Whitespace-only items between commas", + input: "4.19, ,\t,aws, \n ,gcp", + expected: []string{"4.19", "aws", "gcp"}, + }, + { + name: "Stream names with ci and nightly", + input: "4.19.0-0.ci,4.18.0-0.nightly", + expected: []string{"4.19.0-0.ci", "4.18.0-0.nightly"}, + }, + { + name: "Image pull specs", + input: "registry.ci.openshift.org/ocp/release:4.19.0-0.nightly-2025-09-11-024736,quay.io/openshift-release-dev/ocp-release:4.18.0", + expected: []string{"registry.ci.openshift.org/ocp/release:4.19.0-0.nightly-2025-09-11-024736", "quay.io/openshift-release-dev/ocp-release:4.18.0"}, + }, + { + name: "Complex PR references", + input: "openshift/installer#7160,openshift/machine-config-operator#3688,kubernetes/kubernetes#12345", + expected: []string{"openshift/installer#7160", "openshift/machine-config-operator#3688", "kubernetes/kubernetes#12345"}, + }, + { + name: "Mixed formats", + input: "4.19,ci,nightly,openshift/installer#123,registry.ci.openshift.org/ocp/release:latest", + expected: []string{"4.19", "ci", "nightly", "openshift/installer#123", "registry.ci.openshift.org/ocp/release:latest"}, + }, + { + name: "Special characters in PR names", + input: "openshift/cluster-api-provider-aws#123,openshift/machine-config-operator#456", + expected: []string{"openshift/cluster-api-provider-aws#123", "openshift/machine-config-operator#456"}, + }, + { + name: "Single comma only", + input: ",", + expected: nil, + expectError: true, + errorMsg: "no valid inputs found", + }, + { + name: "Multiple commas only", + input: ",,,,", + expected: nil, + expectError: true, + errorMsg: "no valid inputs found", + }, + { + name: "Very long version string", + input: "4.19.0-0.nightly-arm64-2025-09-11-024736-very-long-build-name-with-extra-details", + expected: []string{"4.19.0-0.nightly-arm64-2025-09-11-024736-very-long-build-name-with-extra-details"}, + }, + { + name: "Version with underscores and dots", + input: "4.19_candidate.2024-12-01,4.18.0_rc.1", + expected: []string{"4.19_candidate.2024-12-01", "4.18.0_rc.1"}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result, err := ParseImageInput(tc.input) + + if tc.expectError { + if err == nil { + t.Errorf("Expected error but got none") + } else if tc.errorMsg != "" && !strings.Contains(err.Error(), tc.errorMsg) { + t.Errorf("Expected error containing '%s', got: %s", tc.errorMsg, err.Error()) + } + return + } + + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + if len(result) != len(tc.expected) { + t.Errorf("Expected %d items, got %d: %v", len(tc.expected), len(result), result) + return + } + + for i, expected := range tc.expected { + if result[i] != expected { + t.Errorf("Expected result[%d] = '%s', got '%s'", i, expected, result[i]) + } + } + }) + } +} + +func TestFindClosestMatch(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + input string + options []string + expected string + }{ + { + name: "Exact match", + input: "aws", + options: []string{"aws", "gcp", "azure"}, + expected: "aws", + }, + { + name: "Typo in architecture (user's problem case)", + input: "arm", + options: []string{"amd64", "arm64", "multi"}, + expected: "arm64", + }, + { + name: "Partial platform match", + input: "gc", + options: []string{"aws", "gcp", "azure"}, + expected: "gcp", + }, + { + name: "Typo in platform", + input: "azur", + options: []string{"aws", "gcp", "azure"}, + expected: "azure", + }, + { + name: "No reasonable match", + input: "xyz", + options: []string{"aws", "gcp", "azure"}, + expected: "", + }, + { + name: "Case insensitive", + input: "AWS", + options: []string{"aws", "gcp", "azure"}, + expected: "aws", + }, + { + name: "Prefix match scores higher", + input: "hyper", + options: []string{"hypershift-hosted", "hypershift-hosted-powervs", "metal"}, + expected: "hypershift-hosted", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := findClosestMatch(tc.input, tc.options) + if result != tc.expected { + t.Errorf("Expected '%s', got '%s'", tc.expected, result) + } + }) + } +} + +func TestParseOptionsWithSuggestions(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + options string + expectError bool + errorContains string + }{ + { + name: "Valid options", + options: "aws,amd64,compact", + }, + { + name: "Invalid option with suggestion (user's typo case)", + options: "arm", // Should suggest arm64 + expectError: true, + errorContains: "Did you mean 'arm64'?", + }, + { + name: "Invalid platform with suggestion", + options: "gc", // Should suggest gcp + expectError: true, + errorContains: "Did you mean 'gcp'?", + }, + { + name: "Invalid option no suggestion", + options: "invalidoption123", + expectError: true, + errorContains: "Valid options:", + }, + { + name: "Multiple invalid options", + options: "arm,gc", // Should suggest both + expectError: true, + errorContains: "Did you mean", // Should suggest for first one + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Import manager package to get supported options, but mock for testing + _, _, _, err := ParseOptions(tc.options, [][]string{{"4.19"}}, manager.JobTypeInstall) + + if tc.expectError { + if err == nil { + t.Errorf("Expected error but got none") + } else if tc.errorContains != "" && !strings.Contains(err.Error(), tc.errorContains) { + t.Errorf("Expected error containing '%s', got: %s", tc.errorContains, err.Error()) + } + } else { + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + } + }) + } +} + func TestBuildJobParams(t *testing.T) { t.Parallel() testCases := []struct {