Skip to content
40 changes: 40 additions & 0 deletions pkg/constants/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 3 additions & 2 deletions pkg/workflow/docker_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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)
Expand All @@ -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)
}
9 changes: 5 additions & 4 deletions pkg/workflow/engine_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import (
"encoding/json"
"fmt"

"github.com/github/gh-aw/pkg/constants"
"github.com/github/gh-aw/pkg/logger"
)

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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)
}
12 changes: 6 additions & 6 deletions pkg/workflow/error_message_quality_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ func TestErrorMessageQuality(t *testing.T) {
shouldContain: []string{
"cannot specify both",
"Choose one",
"Example:",
"Example",
},
shouldNotBeVague: true,
},
Expand Down Expand Up @@ -292,7 +292,7 @@ func TestMCPValidationErrorQuality(t *testing.T) {
"must specify either",
"command",
"container",
"Example:",
"Example",
},
},
{
Expand Down Expand Up @@ -341,7 +341,7 @@ func TestMCPValidationErrorQuality(t *testing.T) {
"stdio",
"http",
"Example:",
"mcp-servers:",
"tools:",
},
},
{
Expand All @@ -358,8 +358,8 @@ func TestMCPValidationErrorQuality(t *testing.T) {
"command",
"container",
"Choose one",
"Example:",
"mcp-servers:",
"Example",
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changing from "Example:" to "Example" makes this test assertion less specific. The colon is important as it indicates the start of an example block. Without it, the test would pass even if the word "Example" appeared elsewhere in the error message (e.g., "For example, you could..."). Consider keeping the colon to ensure the test verifies the proper example formatting pattern.

This issue also appears in the following locations of the same file:

  • line 127
  • line 295

Copilot uses AI. Check for mistakes.
"tools:",
},
},
{
Expand All @@ -378,7 +378,7 @@ func TestMCPValidationErrorQuality(t *testing.T) {
"local",
"websocket",
"Example:",
"mcp-servers:",
"tools:",
},
},
}
Expand Down
3 changes: 3 additions & 0 deletions pkg/workflow/github_toolset_validation_error.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"sort"
"strings"

"github.com/github/gh-aw/pkg/constants"
"github.com/github/gh-aw/pkg/logger"
)

Expand Down Expand Up @@ -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")
}
19 changes: 10 additions & 9 deletions pkg/workflow/mcp_config_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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
}
Expand All @@ -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 {
Expand All @@ -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)
}
}

Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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 {
Expand All @@ -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)
}
}

Expand Down
3 changes: 3 additions & 0 deletions pkg/workflow/permissions_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"sort"
"strings"

"github.com/github/gh-aw/pkg/constants"
"github.com/github/gh-aw/pkg/logger"
)

Expand Down Expand Up @@ -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 {
Expand Down
10 changes: 5 additions & 5 deletions pkg/workflow/sandbox_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
)
}

Expand All @@ -47,15 +47,15 @@ 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 == "" {
return NewValidationError(
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),
)
}

Expand All @@ -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),
)
}

Expand Down Expand Up @@ -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),
)
}
}
Expand Down
Loading