diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index 3e56413e57..3e7de73d82 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -225,6 +225,46 @@ func (e EngineName) IsValid() bool { return len(e) > 0 } +// DocURL represents a documentation URL for error messages and help text. +// This semantic type distinguishes documentation URLs from arbitrary URLs, +// making documentation references explicit and centralized for easier maintenance. +// +// Example usage: +// +// const DocsEnginesURL DocURL = "https://github.com/github/gh-aw/blob/main/docs/src/content/docs/reference/engines.md" +// func formatError(msg string, docURL DocURL) string { ... } +type DocURL string + +// String returns the string representation of the documentation URL +func (d DocURL) String() string { + return string(d) +} + +// IsValid returns true if the documentation URL is non-empty +func (d DocURL) IsValid() bool { + return len(d) > 0 +} + +// Documentation URLs for validation error messages. +// These URLs point to the relevant documentation pages that help users +// understand and resolve validation errors. +const ( + // DocsEnginesURL is the documentation URL for engine configuration + DocsEnginesURL DocURL = "https://github.com/github/gh-aw/blob/main/docs/src/content/docs/reference/engines.md" + + // DocsToolsURL is the documentation URL for tools and MCP server configuration + DocsToolsURL DocURL = "https://github.com/github/gh-aw/blob/main/docs/src/content/docs/reference/tools.md" + + // DocsGitHubToolsURL is the documentation URL for GitHub tools configuration + DocsGitHubToolsURL DocURL = "https://github.com/github/gh-aw/blob/main/docs/src/content/docs/reference/tools.md#github-tools-github" + + // DocsPermissionsURL is the documentation URL for GitHub permissions configuration + DocsPermissionsURL DocURL = "https://github.com/github/gh-aw/blob/main/docs/src/content/docs/reference/permissions.md" + + // DocsSandboxURL is the documentation URL for sandbox configuration + DocsSandboxURL DocURL = "https://github.com/github/gh-aw/blob/main/docs/src/content/docs/reference/sandbox.md" +) + // MaxExpressionLineLength is the maximum length for a single line expression before breaking into multiline. const MaxExpressionLineLength LineLength = 120 diff --git a/pkg/workflow/docker_validation.go b/pkg/workflow/docker_validation.go index 7e41cc88f9..009ae7c100 100644 --- a/pkg/workflow/docker_validation.go +++ b/pkg/workflow/docker_validation.go @@ -39,6 +39,7 @@ import ( "time" "github.com/github/gh-aw/pkg/console" + "github.com/github/gh-aw/pkg/constants" "github.com/github/gh-aw/pkg/logger" ) @@ -115,7 +116,7 @@ func validateDockerImage(image string, verbose bool) error { strings.Contains(outputStr, "manifest unknown") { // These errors won't be resolved by retrying dockerValidationLog.Printf("Image %s does not exist (non-retryable error)", image) - return fmt.Errorf("container image '%s' not found and could not be pulled: %s. Please verify the image name and tag. Example: container: \"node:20\" or container: \"ghcr.io/owner/image:latest\"", image, outputStr) + return fmt.Errorf("container image '%s' not found and could not be pulled: %s. Please verify the image name and tag.\n\nExample:\ntools:\n my-tool:\n container: \"node:20\"\n\nOr:\ntools:\n my-tool:\n container: \"ghcr.io/owner/image:latest\"\n\nSee: %s", image, outputStr, constants.DocsToolsURL) } // If not the last attempt, wait and retry (likely network error) @@ -127,5 +128,5 @@ func validateDockerImage(image string, verbose bool) error { } // All attempts failed with retryable errors - return fmt.Errorf("container image '%s' not found and could not be pulled after %d attempts: %s. Please verify the image name and tag. Example: container: \"node:20\" or container: \"ghcr.io/owner/image:latest\"", image, maxAttempts, lastOutput) + return fmt.Errorf("container image '%s' not found and could not be pulled after %d attempts: %s. Please verify the image name and tag.\n\nExample:\ntools:\n my-tool:\n container: \"node:20\"\n\nOr:\ntools:\n my-tool:\n container: \"ghcr.io/owner/image:latest\"\n\nSee: %s", image, maxAttempts, lastOutput, constants.DocsToolsURL) } diff --git a/pkg/workflow/engine_validation.go b/pkg/workflow/engine_validation.go index a04d60d78d..ea1762e1d2 100644 --- a/pkg/workflow/engine_validation.go +++ b/pkg/workflow/engine_validation.go @@ -37,6 +37,7 @@ import ( "encoding/json" "fmt" + "github.com/github/gh-aw/pkg/constants" "github.com/github/gh-aw/pkg/logger" ) @@ -66,7 +67,7 @@ func (c *Compiler) validateEngine(engineID string) error { engineValidationLog.Printf("Engine ID %s not found: %v", engineID, err) // Provide helpful error with valid options - return fmt.Errorf("invalid engine: %s. Valid engines are: copilot, claude, codex, custom. Example: engine: copilot", engineID) + return fmt.Errorf("invalid engine: %s. Valid engines are: copilot, claude, codex, custom.\n\nExample:\nengine: copilot\n\nSee: %s", engineID, constants.DocsEnginesURL) } // validateSingleEngineSpecification validates that only one engine field exists across all files @@ -91,7 +92,7 @@ func (c *Compiler) validateSingleEngineSpecification(mainEngineSetting string, i } if len(allEngines) > 1 { - return "", fmt.Errorf("multiple engine fields found (%d engine specifications detected). Only one engine field is allowed across the main workflow and all included files. Remove duplicate engine specifications to keep only one. Example: engine: copilot", len(allEngines)) + return "", fmt.Errorf("multiple engine fields found (%d engine specifications detected). Only one engine field is allowed across the main workflow and all included files. Remove duplicate engine specifications to keep only one.\n\nExample:\nengine: copilot\n\nSee: %s", len(allEngines), constants.DocsEnginesURL) } // Exactly one engine found - parse and return it @@ -102,7 +103,7 @@ func (c *Compiler) validateSingleEngineSpecification(mainEngineSetting string, i // Must be from included file var firstEngine any if err := json.Unmarshal([]byte(includedEnginesJSON[0]), &firstEngine); err != nil { - return "", fmt.Errorf("failed to parse included engine configuration: %w. Expected string or object format. Example (string): engine: copilot or (object): engine:\\n id: copilot\\n model: gpt-4", err) + return "", fmt.Errorf("failed to parse included engine configuration: %w. Expected string or object format.\n\nExample (string):\nengine: copilot\n\nExample (object):\nengine:\n id: copilot\n model: gpt-4\n\nSee: %s", err, constants.DocsEnginesURL) } // Handle string format @@ -117,5 +118,5 @@ func (c *Compiler) validateSingleEngineSpecification(mainEngineSetting string, i } } - return "", fmt.Errorf("invalid engine configuration in included file, missing or invalid 'id' field. Expected string or object with 'id' field. Example (string): engine: copilot or (object): engine:\\n id: copilot\\n model: gpt-4") + return "", fmt.Errorf("invalid engine configuration in included file, missing or invalid 'id' field. Expected string or object with 'id' field.\n\nExample (string):\nengine: copilot\n\nExample (object):\nengine:\n id: copilot\n model: gpt-4\n\nSee: %s", constants.DocsEnginesURL) } diff --git a/pkg/workflow/error_message_quality_test.go b/pkg/workflow/error_message_quality_test.go index 184521e04b..238507aa87 100644 --- a/pkg/workflow/error_message_quality_test.go +++ b/pkg/workflow/error_message_quality_test.go @@ -124,7 +124,7 @@ func TestErrorMessageQuality(t *testing.T) { shouldContain: []string{ "cannot specify both", "Choose one", - "Example:", + "Example", }, shouldNotBeVague: true, }, @@ -292,7 +292,7 @@ func TestMCPValidationErrorQuality(t *testing.T) { "must specify either", "command", "container", - "Example:", + "Example", }, }, { @@ -341,7 +341,7 @@ func TestMCPValidationErrorQuality(t *testing.T) { "stdio", "http", "Example:", - "mcp-servers:", + "tools:", }, }, { @@ -358,8 +358,8 @@ func TestMCPValidationErrorQuality(t *testing.T) { "command", "container", "Choose one", - "Example:", - "mcp-servers:", + "Example", + "tools:", }, }, { @@ -378,7 +378,7 @@ func TestMCPValidationErrorQuality(t *testing.T) { "local", "websocket", "Example:", - "mcp-servers:", + "tools:", }, }, } diff --git a/pkg/workflow/github_toolset_validation_error.go b/pkg/workflow/github_toolset_validation_error.go index 54bf3dfa1a..6a8cfb6060 100644 --- a/pkg/workflow/github_toolset_validation_error.go +++ b/pkg/workflow/github_toolset_validation_error.go @@ -5,6 +5,7 @@ import ( "sort" "strings" + "github.com/github/gh-aw/pkg/constants" "github.com/github/gh-aw/pkg/logger" ) @@ -66,6 +67,8 @@ func (e *GitHubToolsetValidationError) Error() string { for _, toolset := range allToolsets { lines = append(lines, fmt.Sprintf(" - %s", toolset)) } + lines = append(lines, "") + lines = append(lines, fmt.Sprintf("See: %s", constants.DocsGitHubToolsURL)) return strings.Join(lines, "\n") } diff --git a/pkg/workflow/mcp_config_validation.go b/pkg/workflow/mcp_config_validation.go index 4568466a15..cc5733ae7d 100644 --- a/pkg/workflow/mcp_config_validation.go +++ b/pkg/workflow/mcp_config_validation.go @@ -49,6 +49,7 @@ import ( "sort" "strings" + "github.com/github/gh-aw/pkg/constants" "github.com/github/gh-aw/pkg/logger" "github.com/github/gh-aw/pkg/parser" ) @@ -169,7 +170,7 @@ func getRawMCPConfig(toolConfig map[string]any) (map[string]any, error) { if len(validFields) < maxFields { maxFields = len(validFields) } - return nil, fmt.Errorf("unknown property '%s' in tool configuration. Valid properties include: %s. Example:\nmcp-servers:\n my-tool:\n command: \"node server.js\"\n args: [\"--verbose\"]", field, strings.Join(validFields[:maxFields], ", ")) // Show up to 10 to keep message reasonable + return nil, fmt.Errorf("unknown property '%s' in tool configuration. Valid properties include: %s.\n\nExample:\ntools:\n my-tool:\n command: \"node server.js\"\n args: [\"--verbose\"]\n\nSee: %s", field, strings.Join(validFields[:maxFields], ", "), constants.DocsToolsURL) } } @@ -204,10 +205,10 @@ func getTypeString(value any) string { // validateStringProperty validates that a property is a string and returns appropriate error message func validateStringProperty(toolName, propertyName string, value any, exists bool) error { if !exists { - return fmt.Errorf("tool '%s' mcp configuration missing required property '%s'. Example:\nmcp-servers:\n %s:\n %s: \"value\"", toolName, propertyName, toolName, propertyName) + return fmt.Errorf("tool '%s' mcp configuration missing required property '%s'.\n\nExample:\ntools:\n %s:\n %s: \"value\"\n\nSee: %s", toolName, propertyName, toolName, propertyName, constants.DocsToolsURL) } if _, ok := value.(string); !ok { - return fmt.Errorf("tool '%s' mcp configuration property '%s' must be a string, got %T. Example:\nmcp-servers:\n %s:\n %s: \"my-value\"", toolName, propertyName, value, toolName, propertyName) + return fmt.Errorf("tool '%s' mcp configuration property '%s' must be a string, got %T.\n\nExample:\ntools:\n %s:\n %s: \"my-value\"\n\nSee: %s", toolName, propertyName, value, toolName, propertyName, constants.DocsToolsURL) } return nil } @@ -221,7 +222,7 @@ func validateMCPRequirements(toolName string, mcpConfig map[string]any, toolConf if hasType { // Explicit type provided - validate it's a string if _, ok := mcpType.(string); !ok { - return fmt.Errorf("tool '%s' mcp configuration 'type' must be a string, got %T. Valid types per MCP Gateway Specification: stdio, http. Note: 'local' is accepted for backward compatibility and treated as 'stdio'. Example:\nmcp-servers:\n %s:\n type: \"stdio\"\n command: \"node server.js\"", toolName, mcpType, toolName) + return fmt.Errorf("tool '%s' mcp configuration 'type' must be a string, got %T. Valid types per MCP Gateway Specification: stdio, http. Note: 'local' is accepted for backward compatibility and treated as 'stdio'.\n\nExample:\ntools:\n %s:\n type: \"stdio\"\n command: \"node server.js\"\n\nSee: %s", toolName, mcpType, toolName, constants.DocsToolsURL) } typeStr = mcpType.(string) } else { @@ -233,7 +234,7 @@ func validateMCPRequirements(toolName string, mcpConfig map[string]any, toolConf } else if _, hasContainer := mcpConfig["container"]; hasContainer { typeStr = "stdio" } else { - return fmt.Errorf("tool '%s' unable to determine MCP type: missing type, url, command, or container. Example:\nmcp-servers:\n %s:\n command: \"node server.js\"\n args: [\"--port\", \"3000\"]", toolName, toolName) + return fmt.Errorf("tool '%s' unable to determine MCP type: missing type, url, command, or container.\n\nExample:\ntools:\n %s:\n command: \"node server.js\"\n args: [\"--port\", \"3000\"]\n\nSee: %s", toolName, toolName, constants.DocsToolsURL) } } @@ -244,7 +245,7 @@ func validateMCPRequirements(toolName string, mcpConfig map[string]any, toolConf // Validate type is one of the supported types if !parser.IsMCPType(typeStr) { - return fmt.Errorf("tool '%s' mcp configuration 'type' must be one of: stdio, http (per MCP Gateway Specification). Note: 'local' is accepted for backward compatibility and treated as 'stdio'. Got: %s. Example:\nmcp-servers:\n %s:\n type: \"stdio\"\n command: \"node server.js\"", toolName, typeStr, toolName) + return fmt.Errorf("tool '%s' mcp configuration 'type' must be one of: stdio, http (per MCP Gateway Specification). Note: 'local' is accepted for backward compatibility and treated as 'stdio'. Got: %s.\n\nExample:\ntools:\n %s:\n type: \"stdio\"\n command: \"node server.js\"\n\nSee: %s", toolName, typeStr, toolName, constants.DocsToolsURL) } // Validate type-specific requirements @@ -255,7 +256,7 @@ func validateMCPRequirements(toolName string, mcpConfig map[string]any, toolConf // HTTP type cannot use container field if _, hasContainer := mcpConfig["container"]; hasContainer { - return fmt.Errorf("tool '%s' mcp configuration with type 'http' cannot use 'container' field. HTTP MCP uses URL endpoints, not containers. Example:\nmcp-servers:\n %s:\n type: http\n url: \"https://api.example.com/mcp\"\n headers:\n Authorization: \"Bearer ${{ secrets.API_KEY }}\"", toolName, toolName) + return fmt.Errorf("tool '%s' mcp configuration with type 'http' cannot use 'container' field. HTTP MCP uses URL endpoints, not containers.\n\nExample:\ntools:\n %s:\n type: http\n url: \"https://api.example.com/mcp\"\n headers:\n Authorization: \"Bearer ${{ secrets.API_KEY }}\"\n\nSee: %s", toolName, toolName, constants.DocsToolsURL) } return validateStringProperty(toolName, "url", url, hasURL) @@ -266,7 +267,7 @@ func validateMCPRequirements(toolName string, mcpConfig map[string]any, toolConf container, hasContainer := mcpConfig["container"] if hasCommand && hasContainer { - return fmt.Errorf("tool '%s' mcp configuration cannot specify both 'container' and 'command'. Choose one. Example:\nmcp-servers:\n %s:\n command: \"node server.js\"\nOr use container:\nmcp-servers:\n %s:\n container: \"my-registry/my-tool\"\n version: \"latest\"", toolName, toolName, toolName) + return fmt.Errorf("tool '%s' mcp configuration cannot specify both 'container' and 'command'. Choose one.\n\nExample (command):\ntools:\n %s:\n command: \"node server.js\"\n\nExample (container):\ntools:\n %s:\n container: \"my-registry/my-tool\"\n version: \"latest\"\n\nSee: %s", toolName, toolName, toolName, constants.DocsToolsURL) } if hasCommand { @@ -278,7 +279,7 @@ func validateMCPRequirements(toolName string, mcpConfig map[string]any, toolConf return err } } else { - return fmt.Errorf("tool '%s' mcp configuration must specify either 'command' or 'container'. Example:\nmcp-servers:\n %s:\n command: \"node server.js\"\n args: [\"--port\", \"3000\"]\nOr use container:\nmcp-servers:\n %s:\n container: \"my-registry/my-tool\"\n version: \"latest\"", toolName, toolName, toolName) + return fmt.Errorf("tool '%s' mcp configuration must specify either 'command' or 'container'.\n\nExample (command):\ntools:\n %s:\n command: \"node server.js\"\n args: [\"--port\", \"3000\"]\n\nExample (container):\ntools:\n %s:\n container: \"my-registry/my-tool\"\n version: \"latest\"\n\nSee: %s", toolName, toolName, toolName, constants.DocsToolsURL) } } diff --git a/pkg/workflow/permissions_validation.go b/pkg/workflow/permissions_validation.go index dedd79ca02..2525c85dad 100644 --- a/pkg/workflow/permissions_validation.go +++ b/pkg/workflow/permissions_validation.go @@ -7,6 +7,7 @@ import ( "sort" "strings" + "github.com/github/gh-aw/pkg/constants" "github.com/github/gh-aw/pkg/logger" ) @@ -321,6 +322,8 @@ func formatMissingPermissionsMessage(result *PermissionsValidationResult) string level := result.MissingPermissions[scope] lines = append(lines, fmt.Sprintf(" %s: %s", scope, level)) } + lines = append(lines, "") + lines = append(lines, fmt.Sprintf("See: %s", constants.DocsPermissionsURL)) // Add suggestion to reduce toolsets if we have toolset details if len(result.MissingToolsetDetails) > 0 { diff --git a/pkg/workflow/sandbox_validation.go b/pkg/workflow/sandbox_validation.go index 00f26ccc05..481c4c178c 100644 --- a/pkg/workflow/sandbox_validation.go +++ b/pkg/workflow/sandbox_validation.go @@ -33,7 +33,7 @@ func validateMountsSyntax(mounts []string) error { fmt.Sprintf("sandbox.mounts[%d]", i), mount, "mount syntax must follow 'source:destination:mode' format with exactly 3 colon-separated parts", - "Use the format 'source:destination:mode'. Example:\nsandbox:\n mounts:\n - \"/host/path:/container/path:ro\"", + fmt.Sprintf("Use the format 'source:destination:mode'.\n\nExample:\nsandbox:\n mounts:\n - \"/host/path:/container/path:ro\"\n\nSee: %s", constants.DocsSandboxURL), ) } @@ -47,7 +47,7 @@ func validateMountsSyntax(mounts []string) error { fmt.Sprintf("sandbox.mounts[%d].source", i), mount, "source path cannot be empty", - "Provide a valid source path. Example:\nsandbox:\n mounts:\n - \"/host/path:/container/path:ro\"", + fmt.Sprintf("Provide a valid source path.\n\nExample:\nsandbox:\n mounts:\n - \"/host/path:/container/path:ro\"\n\nSee: %s", constants.DocsSandboxURL), ) } if dest == "" { @@ -55,7 +55,7 @@ func validateMountsSyntax(mounts []string) error { fmt.Sprintf("sandbox.mounts[%d].destination", i), mount, "destination path cannot be empty", - "Provide a valid destination path. Example:\nsandbox:\n mounts:\n - \"/host/path:/container/path:ro\"", + fmt.Sprintf("Provide a valid destination path.\n\nExample:\nsandbox:\n mounts:\n - \"/host/path:/container/path:ro\"\n\nSee: %s", constants.DocsSandboxURL), ) } @@ -65,7 +65,7 @@ func validateMountsSyntax(mounts []string) error { fmt.Sprintf("sandbox.mounts[%d].mode", i), mode, "mount mode must be 'ro' (read-only) or 'rw' (read-write)", - "Change the mount mode to either 'ro' or 'rw'. Example:\nsandbox:\n mounts:\n - \"/host/path:/container/path:ro\" # read-only\n - \"/host/path:/container/path:rw\" # read-write", + fmt.Sprintf("Change the mount mode to either 'ro' or 'rw'.\n\nExample:\nsandbox:\n mounts:\n - \"/host/path:/container/path:ro\" # read-only\n - \"/host/path:/container/path:rw\" # read-write\n\nSee: %s", constants.DocsSandboxURL), ) } @@ -136,7 +136,7 @@ func validateSandboxConfig(workflowData *WorkflowData) error { "sandbox", "sandbox-runtime with network.firewall", "sandbox-runtime and AWF firewall cannot be used together", - "Choose one sandbox approach:\n\nOption 1 (sandbox-runtime):\nsandbox: sandbox-runtime\n\nOption 2 (AWF firewall):\nnetwork:\n firewall: true", + fmt.Sprintf("Choose one sandbox approach:\n\nOption 1 (sandbox-runtime):\nsandbox: sandbox-runtime\n\nOption 2 (AWF firewall):\nnetwork:\n firewall: true\n\nSee: %s", constants.DocsSandboxURL), ) } }