feat: go 1.25.5, dependency updates, renamed internal/errors (#129)

* feat: rename internal/errors to internal/apperrors

* fix(tests): clear env values before using in tests

* feat: rename internal/errors to internal/apperrors

* chore(deps): update go and all dependencies

* chore: remove renovate from pre-commit, formatting

* chore: sonarcloud fixes

* feat: consolidate constants to appconstants/constants.go

* chore: sonarcloud fixes

* feat: simplification, deduplication, test utils

* chore: sonarcloud fixes

* chore: sonarcloud fixes

* chore: sonarcloud fixes

* chore: sonarcloud fixes

* chore: clean up

* fix: config discovery, const deduplication

* chore: fixes
This commit is contained in:
2026-01-01 23:17:29 +02:00
committed by GitHub
parent 85a439d804
commit 7f80105ff5
65 changed files with 2321 additions and 1710 deletions

View File

@@ -0,0 +1,195 @@
// Package apperrors provides enhanced error types with contextual information and suggestions.
package apperrors
import (
"errors"
"fmt"
"strings"
"github.com/ivuorinen/gh-action-readme/appconstants"
)
// Sentinel errors for typed error checking.
var (
// ErrFileNotFound indicates a file was not found.
ErrFileNotFound = errors.New("file not found")
// ErrPermissionDenied indicates a permission error.
ErrPermissionDenied = errors.New("permission denied")
// ErrInvalidYAML indicates YAML parsing failed.
ErrInvalidYAML = errors.New("invalid YAML")
// ErrGitHubAPI indicates a GitHub API error.
ErrGitHubAPI = errors.New("GitHub API error")
// ErrConfiguration indicates a configuration error.
ErrConfiguration = errors.New("configuration error")
)
// ContextualError provides enhanced error information with actionable suggestions.
type ContextualError struct {
Code appconstants.ErrorCode
Err error
Context string
Suggestions []string
HelpURL string
Details map[string]string
}
// Error implements the error interface.
func (ce *ContextualError) Error() string {
var b strings.Builder
// Primary error message
if ce.Context != "" {
b.WriteString(fmt.Sprintf("%s: %v", ce.Context, ce.Err))
} else {
b.WriteString(ce.Err.Error())
}
// Add error code for reference
b.WriteString(fmt.Sprintf(" [%s]", ce.Code))
// Add details if available
if len(ce.Details) > 0 {
b.WriteString("\n\nDetails:")
for key, value := range ce.Details {
b.WriteString(fmt.Sprintf("\n %s: %s", key, value))
}
}
// Add suggestions
if len(ce.Suggestions) > 0 {
b.WriteString("\n\nSuggestions:")
for _, suggestion := range ce.Suggestions {
b.WriteString("\n • " + suggestion)
}
}
// Add help URL
if ce.HelpURL != "" {
b.WriteString("\n\nFor more help: " + ce.HelpURL)
}
return b.String()
}
// Unwrap returns the wrapped error.
func (ce *ContextualError) Unwrap() error {
return ce.Err
}
// Is implements errors.Is support.
func (ce *ContextualError) Is(target error) bool {
if target == nil {
return false
}
// Check if target is also a ContextualError with same code
if targetCE, ok := target.(*ContextualError); ok {
return ce.Code == targetCE.Code
}
// Check wrapped error
return errors.Is(ce.Err, target)
}
// New creates a new ContextualError with the given code and message.
func New(code appconstants.ErrorCode, message string) *ContextualError {
return &ContextualError{
Code: code,
Err: errors.New(message),
}
}
// Wrap wraps an existing error with contextual information.
func Wrap(err error, code appconstants.ErrorCode, context string) *ContextualError {
if err == nil {
return nil
}
// If already a ContextualError, preserve existing info by creating a copy
if ce, ok := err.(*ContextualError); ok {
// Create a copy to avoid mutating the original
errCopy := &ContextualError{
Code: ce.Code,
Err: ce.Err,
Context: ce.Context,
Suggestions: ce.Suggestions,
HelpURL: ce.HelpURL,
Details: make(map[string]string),
}
// Copy details map
for k, v := range ce.Details {
errCopy.Details[k] = v
}
// Only update if not already set
if errCopy.Code == appconstants.ErrCodeUnknown {
errCopy.Code = code
}
if errCopy.Context == "" {
errCopy.Context = context
}
return errCopy
}
return &ContextualError{
Code: code,
Err: err,
Context: context,
}
}
// WithSuggestions adds suggestions to a ContextualError.
func (ce *ContextualError) WithSuggestions(suggestions ...string) *ContextualError {
ce.Suggestions = append(ce.Suggestions, suggestions...)
return ce
}
// WithDetails adds detail key-value pairs to a ContextualError.
func (ce *ContextualError) WithDetails(details map[string]string) *ContextualError {
if ce.Details == nil {
ce.Details = make(map[string]string)
}
for k, v := range details {
ce.Details[k] = v
}
return ce
}
// WithHelpURL adds a help URL to a ContextualError.
func (ce *ContextualError) WithHelpURL(url string) *ContextualError {
ce.HelpURL = url
return ce
}
// GetHelpURL returns a help URL for the given error code.
func GetHelpURL(code appconstants.ErrorCode) string {
baseURL := "https://github.com/ivuorinen/gh-action-readme/blob/main/docs/troubleshooting.md"
anchors := map[appconstants.ErrorCode]string{
appconstants.ErrCodeFileNotFound: "#file-not-found",
appconstants.ErrCodePermission: "#permission-denied",
appconstants.ErrCodeInvalidYAML: "#invalid-yaml",
appconstants.ErrCodeInvalidAction: "#invalid-action-file",
appconstants.ErrCodeNoActionFiles: "#no-action-files",
appconstants.ErrCodeGitHubAPI: "#github-api-errors",
appconstants.ErrCodeGitHubRateLimit: "#rate-limit-exceeded",
appconstants.ErrCodeGitHubAuth: "#authentication-errors",
appconstants.ErrCodeConfiguration: "#configuration-errors",
appconstants.ErrCodeValidation: "#validation-errors",
appconstants.ErrCodeTemplateRender: "#template-errors",
appconstants.ErrCodeFileWrite: "#file-write-errors",
appconstants.ErrCodeDependencyAnalysis: "#dependency-analysis",
appconstants.ErrCodeCacheAccess: "#cache-errors",
}
if anchor, ok := anchors[code]; ok {
return baseURL + anchor
}
return baseURL
}

View File

@@ -0,0 +1,273 @@
package apperrors
import (
"errors"
"strings"
"testing"
"github.com/ivuorinen/gh-action-readme/appconstants"
"github.com/ivuorinen/gh-action-readme/testutil"
)
const (
testOriginalError = "original error"
testMessage = "test message"
testContext = "test context"
)
func TestContextualErrorError(t *testing.T) {
t.Parallel()
tests := []struct {
name string
err *ContextualError
contains []string
}{
{
name: "basic error",
err: &ContextualError{
Code: appconstants.ErrCodeFileNotFound,
Err: errors.New("file not found"),
},
contains: []string{"file not found", "[FILE_NOT_FOUND]"},
},
{
name: "error with context",
err: &ContextualError{
Code: appconstants.ErrCodeInvalidYAML,
Err: errors.New("invalid syntax"),
Context: "parsing action.yml",
},
contains: []string{"parsing action.yml: invalid syntax", "[INVALID_YAML]"},
},
{
name: "error with suggestions",
err: &ContextualError{
Code: appconstants.ErrCodeNoActionFiles,
Err: errors.New("no files found"),
Suggestions: []string{
"Check current directory",
"Use --recursive flag",
},
},
contains: []string{
"no files found",
"Suggestions:",
"• Check current directory",
"• Use --recursive flag",
},
},
{
name: "error with details",
err: &ContextualError{
Code: appconstants.ErrCodeConfiguration,
Err: errors.New("config error"),
Details: map[string]string{
"config_path": "/path/to/config",
"line": "42",
},
},
contains: []string{
"config error",
"Details:",
"config_path: /path/to/config",
"line: 42",
},
},
{
name: "error with help URL",
err: &ContextualError{
Code: appconstants.ErrCodeGitHubAPI,
Err: errors.New("API error"),
HelpURL: "https://docs.github.com/api",
},
contains: []string{
"API error",
"For more help: https://docs.github.com/api",
},
},
{
name: "complete error",
err: &ContextualError{
Code: appconstants.ErrCodeValidation,
Err: errors.New("validation failed"),
Context: "validating action.yml",
Details: map[string]string{"file": "action.yml"},
Suggestions: []string{
"Check required fields",
"Validate YAML syntax",
},
HelpURL: "https://example.com/help",
},
contains: []string{
"validating action.yml: validation failed",
"[VALIDATION_ERROR]",
"Details:",
"file: action.yml",
"Suggestions:",
"• Check required fields",
"• Validate YAML syntax",
"For more help: https://example.com/help",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
result := tt.err.Error()
testutil.AssertSliceContainsAll(t, []string{result}, tt.contains)
})
}
}
func TestContextualErrorUnwrap(t *testing.T) {
t.Parallel()
originalErr := errors.New(testOriginalError)
contextualErr := &ContextualError{
Code: appconstants.ErrCodeFileNotFound,
Err: originalErr,
}
if unwrapped := contextualErr.Unwrap(); unwrapped != originalErr {
t.Errorf("Unwrap() = %v, want %v", unwrapped, originalErr)
}
}
func TestContextualErrorIs(t *testing.T) {
t.Parallel()
originalErr := errors.New(testOriginalError)
contextualErr := &ContextualError{
Code: appconstants.ErrCodeFileNotFound,
Err: originalErr,
}
// Test Is with same error code
sameCodeErr := &ContextualError{Code: appconstants.ErrCodeFileNotFound}
if !contextualErr.Is(sameCodeErr) {
t.Error("Is() should return true for same error code")
}
// Test Is with different error code
differentCodeErr := &ContextualError{Code: appconstants.ErrCodeInvalidYAML}
if contextualErr.Is(differentCodeErr) {
t.Error("Is() should return false for different error code")
}
// Test Is with wrapped error
if !errors.Is(contextualErr, originalErr) {
t.Error("errors.Is() should work with wrapped error")
}
}
func TestNew(t *testing.T) {
t.Parallel()
err := New(appconstants.ErrCodeFileNotFound, testMessage)
if err.Code != appconstants.ErrCodeFileNotFound {
t.Errorf("New() code = %v, want %v", err.Code, appconstants.ErrCodeFileNotFound)
}
if err.Err.Error() != testMessage {
t.Errorf("New() message = %v, want %v", err.Err.Error(), testMessage)
}
}
func TestWrap(t *testing.T) {
t.Parallel()
originalErr := errors.New(testOriginalError)
// Test wrapping normal error
wrapped := Wrap(originalErr, appconstants.ErrCodeFileNotFound, testContext)
if wrapped.Code != appconstants.ErrCodeFileNotFound {
t.Errorf("Wrap() code = %v, want %v", wrapped.Code, appconstants.ErrCodeFileNotFound)
}
if wrapped.Context != testContext {
t.Errorf("Wrap() context = %v, want %v", wrapped.Context, testContext)
}
if wrapped.Err != originalErr {
t.Errorf("Wrap() err = %v, want %v", wrapped.Err, originalErr)
}
// Test wrapping nil error
nilWrapped := Wrap(nil, appconstants.ErrCodeFileNotFound, testContext)
if nilWrapped != nil {
t.Error("Wrap(nil) should return nil")
}
// Test wrapping already contextual error
contextualErr := &ContextualError{
Code: appconstants.ErrCodeUnknown,
Err: originalErr,
Context: "",
}
rewrapped := Wrap(contextualErr, appconstants.ErrCodeFileNotFound, "new context")
if rewrapped.Code != appconstants.ErrCodeFileNotFound {
t.Error("Wrap() should update code if it was appconstants.ErrCodeUnknown")
}
if rewrapped.Context != "new context" {
t.Error("Wrap() should update context if it was empty")
}
}
func TestContextualErrorWithMethods(t *testing.T) {
t.Parallel()
err := New(appconstants.ErrCodeFileNotFound, "test error")
// Test WithSuggestions
err = err.WithSuggestions("suggestion 1", "suggestion 2")
if len(err.Suggestions) != 2 {
t.Errorf("WithSuggestions() length = %d, want 2", len(err.Suggestions))
}
if err.Suggestions[0] != "suggestion 1" {
t.Errorf("WithSuggestions()[0] = %s, want 'suggestion 1'", err.Suggestions[0])
}
// Test WithDetails
details := map[string]string{"key1": "value1", "key2": "value2"}
err = err.WithDetails(details)
if len(err.Details) != 2 {
t.Errorf("WithDetails() length = %d, want 2", len(err.Details))
}
if err.Details["key1"] != "value1" {
t.Errorf("WithDetails()['key1'] = %s, want 'value1'", err.Details["key1"])
}
// Test WithHelpURL
url := "https://example.com/help"
err = err.WithHelpURL(url)
if err.HelpURL != url {
t.Errorf("WithHelpURL() = %s, want %s", err.HelpURL, url)
}
}
func TestGetHelpURL(t *testing.T) {
t.Parallel()
tests := []struct {
code appconstants.ErrorCode
contains string
}{
{appconstants.ErrCodeFileNotFound, "#file-not-found"},
{appconstants.ErrCodeInvalidYAML, "#invalid-yaml"},
{appconstants.ErrCodeGitHubAPI, "#github-api-errors"},
{appconstants.ErrCodeUnknown, "troubleshooting.md"}, // Should return base URL
}
for _, tt := range tests {
t.Run(string(tt.code), func(t *testing.T) {
t.Parallel()
url := GetHelpURL(tt.code)
if !strings.Contains(url, tt.contains) {
t.Errorf("GetHelpURL(%s) = %s, should contain %s", tt.code, url, tt.contains)
}
})
}
}

View File

@@ -0,0 +1,415 @@
package apperrors
import (
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"github.com/ivuorinen/gh-action-readme/appconstants"
)
// GetSuggestions returns context-aware suggestions for the given error code.
func GetSuggestions(code appconstants.ErrorCode, context map[string]string) []string {
if handler := getSuggestionHandler(code); handler != nil {
return handler(context)
}
return getDefaultSuggestions()
}
// getSuggestionHandler returns the appropriate suggestion function for the error code.
func getSuggestionHandler(code appconstants.ErrorCode) func(map[string]string) []string {
handlers := map[appconstants.ErrorCode]func(map[string]string) []string{
appconstants.ErrCodeFileNotFound: getFileNotFoundSuggestions,
appconstants.ErrCodePermission: getPermissionSuggestions,
appconstants.ErrCodeInvalidYAML: getInvalidYAMLSuggestions,
appconstants.ErrCodeInvalidAction: getInvalidActionSuggestions,
appconstants.ErrCodeNoActionFiles: getNoActionFilesSuggestions,
appconstants.ErrCodeGitHubAPI: getGitHubAPISuggestions,
appconstants.ErrCodeConfiguration: getConfigurationSuggestions,
appconstants.ErrCodeValidation: getValidationSuggestions,
appconstants.ErrCodeTemplateRender: getTemplateSuggestions,
appconstants.ErrCodeFileWrite: getFileWriteSuggestions,
appconstants.ErrCodeDependencyAnalysis: getDependencyAnalysisSuggestions,
appconstants.ErrCodeCacheAccess: getCacheAccessSuggestions,
}
// Special cases for handlers without context
if code == appconstants.ErrCodeGitHubRateLimit {
return func(_ map[string]string) []string { return getGitHubRateLimitSuggestions() }
}
if code == appconstants.ErrCodeGitHubAuth {
return func(_ map[string]string) []string { return getGitHubAuthSuggestions() }
}
// All other cases are handled by the handlers map
return handlers[code]
}
// getDefaultSuggestions returns generic suggestions for unknown errors.
func getDefaultSuggestions() []string {
return []string{
"Check the error message for more details",
"Run with --verbose flag for additional debugging information",
"Visit the project documentation for help",
}
}
func getFileNotFoundSuggestions(context map[string]string) []string {
suggestions := []string{}
if path, ok := context["path"]; ok {
suggestions = append(suggestions,
"Check if the file exists: "+path,
"Verify the file path is correct",
)
// Check if it might be a case sensitivity issue
dir := filepath.Dir(path)
if _, err := os.Stat(dir); err == nil {
suggestions = append(suggestions,
"Check for case sensitivity in the filename",
"Try: ls -la "+dir,
)
}
// Suggest common file names if looking for action files
if strings.Contains(path, "action") {
suggestions = append(suggestions,
"Common action file names: action.yml, action.yaml",
"Check if the file is in a subdirectory",
)
}
}
suggestions = append(suggestions,
"Use --recursive flag to search in subdirectories",
"Ensure you have read permissions for the directory",
)
return suggestions
}
func getPermissionSuggestions(context map[string]string) []string {
suggestions := []string{}
if path, ok := context["path"]; ok {
suggestions = append(suggestions,
"Check file permissions: ls -la "+path,
"Try changing permissions: chmod 644 "+path,
)
// Check if it's a directory
if info, err := os.Stat(path); err == nil && info.IsDir() {
suggestions = append(suggestions,
"For directories, try: chmod 755 "+path,
)
}
}
// Add OS-specific suggestions
switch runtime.GOOS {
case "windows":
suggestions = append(suggestions,
"Run the command prompt as Administrator",
"Check Windows file permissions in Properties > Security",
)
default:
suggestions = append(suggestions,
"Check file ownership with: ls -la",
"You may need to use sudo for system directories",
"Ensure the parent directory has write permissions",
)
}
return suggestions
}
func getInvalidYAMLSuggestions(context map[string]string) []string {
suggestions := []string{
"Check YAML indentation (use spaces, not tabs)",
"Ensure all strings with special characters are quoted",
"Verify brackets and braces are properly closed",
"Use a YAML validator: https://yaml-online-parser.appspot.com/",
}
if line, ok := context["line"]; ok {
suggestions = append([]string{
fmt.Sprintf("Error near line %s - check indentation and syntax", line),
}, suggestions...)
}
if err, ok := context["error"]; ok {
if strings.Contains(err, "tab") {
suggestions = append([]string{
"YAML files must use spaces for indentation, not tabs",
"Replace all tabs with spaces (usually 2 or 4 spaces)",
}, suggestions...)
}
}
suggestions = append(suggestions,
"Common YAML issues: missing colons, incorrect nesting, invalid characters",
"Example valid action.yml structure available in documentation",
)
return suggestions
}
func getInvalidActionSuggestions(context map[string]string) []string {
suggestions := []string{
"Ensure the action file has required fields: name, description",
"Check that 'runs' section is properly configured",
"Verify the action type is valid (composite, docker, or javascript)",
}
if missingFields, ok := context["missing_fields"]; ok {
suggestions = append([]string{
"Missing required fields: " + missingFields,
}, suggestions...)
}
if invalidField, ok := context["invalid_field"]; ok {
suggestions = append(suggestions,
fmt.Sprintf("Invalid field '%s' - check spelling and placement", invalidField),
)
}
suggestions = append(suggestions,
"Refer to GitHub Actions documentation for action.yml schema",
"Use 'gh-action-readme schema' to see the expected format",
"Validate against the schema with 'gh-action-readme validate'",
)
return suggestions
}
func getNoActionFilesSuggestions(context map[string]string) []string {
suggestions := []string{
"Ensure you're in the correct directory",
"Look for files named 'action.yml' or 'action.yaml'",
"Use --recursive flag to search subdirectories",
}
if dir, ok := context["directory"]; ok {
suggestions = append(suggestions,
"Current directory: "+dir,
fmt.Sprintf("Try: find %s -name 'action.y*ml' -type f", dir),
)
}
suggestions = append(suggestions,
"GitHub Actions must have an action.yml or action.yaml file",
"Check if the file has a different extension (.yaml vs .yml)",
"Example: gh-action-readme gen --recursive",
)
return suggestions
}
func getGitHubAPISuggestions(context map[string]string) []string {
suggestions := []string{
"Check your internet connection",
"Verify GitHub's API status: https://www.githubstatus.com/",
"Ensure your GitHub token has the necessary permissions",
}
if statusCode, ok := context["status_code"]; ok {
switch statusCode {
case "401":
suggestions = append([]string{
"Authentication failed - check your GitHub token",
"Token may be expired or revoked",
}, suggestions...)
case "403":
suggestions = append([]string{
"Access forbidden - check token permissions",
"You may have hit the rate limit",
}, suggestions...)
case "404":
suggestions = append([]string{
"Repository or resource not found",
"Check if the repository is private and token has access",
}, suggestions...)
}
}
return suggestions
}
func getGitHubRateLimitSuggestions() []string {
return []string{
"GitHub API rate limit exceeded",
"Authenticate with a GitHub token to increase limits",
"Set GITHUB_TOKEN environment variable: export GITHUB_TOKEN=your_token",
"For GitHub CLI: gh auth login",
"Rate limits reset every hour",
"Consider using caching to reduce API calls",
"Use --quiet mode to reduce API usage for non-critical features",
}
}
func getGitHubAuthSuggestions() []string {
return []string{
"Set GitHub token: export GITHUB_TOKEN=your_personal_access_token",
"Or use GitHub CLI: gh auth login",
"Create a token at: https://github.com/settings/tokens",
"Token needs 'repo' scope for private repositories",
"For public repos only, 'public_repo' scope is sufficient",
"Check if token is set: echo $GITHUB_TOKEN",
"Ensure token hasn't expired",
}
}
func getConfigurationSuggestions(context map[string]string) []string {
suggestions := []string{
"Check configuration file syntax",
"Ensure configuration file exists",
"Use 'gh-action-readme config init' to create default config",
"Valid config locations: .gh-action-readme.yml, ~/.config/gh-action-readme/config.yaml",
}
if configPath, ok := context["config_path"]; ok {
suggestions = append(suggestions,
"Config path: "+configPath,
"Check if file exists: ls -la "+configPath,
)
}
if err, ok := context["error"]; ok {
if strings.Contains(err, "permission") {
suggestions = append(suggestions,
"Check file permissions for config file",
"Ensure parent directory is writable",
)
}
}
return suggestions
}
func getValidationSuggestions(context map[string]string) []string {
suggestions := []string{
"Review validation errors for specific issues",
"Check required fields are present",
"Ensure field values match expected types",
"Use 'gh-action-readme schema' to see valid structure",
}
if fields, ok := context["invalid_fields"]; ok {
suggestions = append([]string{
"Invalid fields: " + fields,
"Check spelling and nesting of these fields",
}, suggestions...)
}
if validationType, ok := context["validation_type"]; ok {
switch validationType {
case "required":
suggestions = append(suggestions,
"Add missing required fields to your action.yml",
"Required fields typically include: name, description, runs",
)
case "type":
suggestions = append(suggestions,
"Ensure field values match expected types",
"Strings should be quoted, booleans should be true/false",
)
}
}
return suggestions
}
func getTemplateSuggestions(context map[string]string) []string {
suggestions := []string{
"Check template syntax",
"Ensure all template variables are defined",
"Verify custom template path is correct",
}
if templatePath, ok := context["template_path"]; ok {
suggestions = append(suggestions,
"Template path: "+templatePath,
"Ensure template file exists and is readable",
)
}
if theme, ok := context["theme"]; ok {
suggestions = append(suggestions,
"Current theme: "+theme,
"Try using a different theme: --theme github",
"Available themes: default, github, gitlab, minimal, professional",
)
}
return suggestions
}
func getFileWriteSuggestions(context map[string]string) []string {
suggestions := []string{
"Check if the output directory exists",
"Ensure you have write permissions",
"Verify disk space is available",
}
if outputPath, ok := context["output_path"]; ok {
dir := filepath.Dir(outputPath)
suggestions = append(suggestions,
"Output directory: "+dir,
"Check permissions: ls -la "+dir,
"Create directory if needed: mkdir -p "+dir,
)
// Check if file already exists
if _, err := os.Stat(outputPath); err == nil {
suggestions = append(suggestions,
"File already exists - it will be overwritten",
"Back up existing file if needed",
)
}
}
return suggestions
}
func getDependencyAnalysisSuggestions(context map[string]string) []string {
suggestions := []string{
"Ensure GitHub token is set for dependency analysis",
"Check that the action file contains valid dependencies",
"Verify network connectivity to GitHub",
}
if action, ok := context["action"]; ok {
suggestions = append(suggestions,
"Analyzing action: "+action,
"Only composite actions have analyzable dependencies",
)
}
return append(suggestions,
"Dependency analysis requires 'uses' statements in composite actions",
"Example: uses: actions/checkout@v4",
)
}
func getCacheAccessSuggestions(context map[string]string) []string {
suggestions := []string{
"Check cache directory permissions",
"Ensure cache directory exists",
"Try clearing cache: gh-action-readme cache clear",
"Default cache location: ~/.cache/gh-action-readme",
}
if cachePath, ok := context["cache_path"]; ok {
suggestions = append(suggestions,
"Cache path: "+cachePath,
"Check permissions: ls -la "+cachePath,
"You can disable cache temporarily with environment variables",
)
}
return suggestions
}

View File

@@ -0,0 +1,345 @@
package apperrors
import (
"runtime"
"testing"
"github.com/ivuorinen/gh-action-readme/appconstants"
"github.com/ivuorinen/gh-action-readme/testutil"
)
// Test helper factories for creating context maps
func ctxPath(path string) map[string]string {
return map[string]string{"path": path}
}
func ctxError(err string) map[string]string {
return map[string]string{"error": err}
}
func ctxStatusCode(code string) map[string]string {
return map[string]string{"status_code": code}
}
func ctxEmpty() map[string]string {
return map[string]string{}
}
func TestGetSuggestions(t *testing.T) {
t.Parallel()
tests := []struct {
name string
code appconstants.ErrorCode
context map[string]string
contains []string
}{
{
name: "file not found with path",
code: appconstants.ErrCodeFileNotFound,
context: ctxPath("/path/to/action.yml"),
contains: []string{
"Check if the file exists: /path/to/action.yml",
"Verify the file path is correct",
"--recursive flag",
},
},
{
name: "file not found action file",
code: appconstants.ErrCodeFileNotFound,
context: ctxPath("/project/action.yml"),
contains: []string{
"Common action file names: action.yml, action.yaml",
"Check if the file is in a subdirectory",
},
},
{
name: "permission denied",
code: appconstants.ErrCodePermission,
context: ctxPath("/restricted/file.txt"),
contains: []string{
"Check file permissions: ls -la /restricted/file.txt",
"chmod 644 /restricted/file.txt",
},
},
{
name: "invalid YAML with line number",
code: appconstants.ErrCodeInvalidYAML,
context: map[string]string{
"line": "25",
},
contains: []string{
"Error near line 25",
"Check YAML indentation",
"use spaces, not tabs",
"YAML validator",
},
},
{
name: "invalid YAML with tab error",
code: appconstants.ErrCodeInvalidYAML,
context: ctxError("found character that cannot start any token (tab)"),
contains: []string{
"YAML files must use spaces for indentation, not tabs",
"Replace all tabs with spaces",
},
},
{
name: "invalid action with missing fields",
code: appconstants.ErrCodeInvalidAction,
context: map[string]string{
"missing_fields": "name, description",
},
contains: []string{
"Missing required fields: name, description",
"required fields: name, description",
"gh-action-readme schema",
},
},
{
name: "no action files",
code: appconstants.ErrCodeNoActionFiles,
context: map[string]string{
"directory": "/project",
},
contains: []string{
"Current directory: /project",
"find /project -name 'action.y*ml'",
"--recursive flag",
"action.yml or action.yaml",
},
},
{
name: "GitHub API 401 error",
code: appconstants.ErrCodeGitHubAPI,
context: ctxStatusCode("401"),
contains: []string{
"Authentication failed",
"check your GitHub token",
"Token may be expired",
},
},
{
name: "GitHub API 403 error",
code: appconstants.ErrCodeGitHubAPI,
context: ctxStatusCode("403"),
contains: []string{
"Access forbidden",
"check token permissions",
"rate limit",
},
},
{
name: "GitHub API 404 error",
code: appconstants.ErrCodeGitHubAPI,
context: ctxStatusCode("404"),
contains: []string{
"Repository or resource not found",
"repository is private",
},
},
{
name: "GitHub rate limit",
code: appconstants.ErrCodeGitHubRateLimit,
context: ctxEmpty(),
contains: []string{
"rate limit exceeded",
"GITHUB_TOKEN",
"gh auth login",
"Rate limits reset every hour",
},
},
{
name: "GitHub auth",
code: appconstants.ErrCodeGitHubAuth,
context: ctxEmpty(),
contains: []string{
"export GITHUB_TOKEN",
"gh auth login",
"https://github.com/settings/tokens",
"'repo' scope",
},
},
{
name: "configuration error with path",
code: appconstants.ErrCodeConfiguration,
context: map[string]string{
"config_path": "~/.config/gh-action-readme/config.yaml",
},
contains: []string{
"Config path: ~/.config/gh-action-readme/config.yaml",
"ls -la ~/.config/gh-action-readme/config.yaml",
"gh-action-readme config init",
},
},
{
name: "validation error with invalid fields",
code: appconstants.ErrCodeValidation,
context: map[string]string{
"invalid_fields": "runs.using, inputs.test",
},
contains: []string{
"Invalid fields: runs.using, inputs.test",
"Check spelling and nesting",
"gh-action-readme schema",
},
},
{
name: "template error with theme",
code: appconstants.ErrCodeTemplateRender,
context: map[string]string{
"theme": "custom",
},
contains: []string{
"Current theme: custom",
"Try using a different theme",
"Available themes:",
},
},
{
name: "file write error with output path",
code: appconstants.ErrCodeFileWrite,
context: map[string]string{
"output_path": "/output/README.md",
},
contains: []string{
"Output directory: /output",
"Check permissions: ls -la /output",
"mkdir -p /output",
},
},
{
name: "dependency analysis error",
code: appconstants.ErrCodeDependencyAnalysis,
context: map[string]string{
"action": "my-action",
},
contains: []string{
"Analyzing action: my-action",
"GitHub token is set",
"composite actions",
},
},
{
name: "cache access error",
code: appconstants.ErrCodeCacheAccess,
context: map[string]string{
"cache_path": "~/.cache/gh-action-readme",
},
contains: []string{
"Cache path: ~/.cache/gh-action-readme",
"gh-action-readme cache clear",
"permissions: ls -la ~/.cache/gh-action-readme",
},
},
{
name: "unknown error code",
code: "UNKNOWN_TEST_CODE",
context: ctxEmpty(),
contains: []string{
"Check the error message",
"--verbose flag",
"project documentation",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
suggestions := GetSuggestions(tt.code, tt.context)
testutil.AssertSliceContainsAll(t, suggestions, tt.contains)
})
}
}
func TestGetPermissionSuggestionsOSSpecific(t *testing.T) {
t.Parallel()
context := map[string]string{"path": "/test/file"}
suggestions := getPermissionSuggestions(context)
switch runtime.GOOS {
case "windows":
testutil.AssertSliceContainsAll(t, suggestions, []string{"Administrator", "Windows file permissions"})
default:
testutil.AssertSliceContainsAll(t, suggestions, []string{"sudo", "ls -la"})
}
}
func TestGetSuggestionsEmptyContext(t *testing.T) {
t.Parallel()
// Test that all error codes work with empty context
errorCodes := []appconstants.ErrorCode{
appconstants.ErrCodeFileNotFound,
appconstants.ErrCodePermission,
appconstants.ErrCodeInvalidYAML,
appconstants.ErrCodeInvalidAction,
appconstants.ErrCodeNoActionFiles,
appconstants.ErrCodeGitHubAPI,
appconstants.ErrCodeGitHubRateLimit,
appconstants.ErrCodeGitHubAuth,
appconstants.ErrCodeConfiguration,
appconstants.ErrCodeValidation,
appconstants.ErrCodeTemplateRender,
appconstants.ErrCodeFileWrite,
appconstants.ErrCodeDependencyAnalysis,
appconstants.ErrCodeCacheAccess,
}
for _, code := range errorCodes {
t.Run(string(code), func(t *testing.T) {
t.Parallel()
suggestions := GetSuggestions(code, map[string]string{})
if len(suggestions) == 0 {
t.Errorf("GetSuggestions(%s, {}) returned empty slice", code)
}
})
}
}
func TestGetFileNotFoundSuggestionsActionFile(t *testing.T) {
t.Parallel()
context := map[string]string{
"path": "/project/action.yml",
}
suggestions := getFileNotFoundSuggestions(context)
testutil.AssertSliceContainsAll(t, suggestions, []string{"action.yml, action.yaml", "subdirectory"})
}
func TestGetInvalidYAMLSuggestionsTabError(t *testing.T) {
t.Parallel()
context := map[string]string{
"error": "found character that cannot start any token, tab character",
}
suggestions := getInvalidYAMLSuggestions(context)
testutil.AssertSliceContainsAll(t, suggestions, []string{"tabs with spaces"})
}
func TestGetGitHubAPISuggestionsStatusCodes(t *testing.T) {
t.Parallel()
statusCodes := map[string]string{
"401": "Authentication failed",
"403": "Access forbidden",
"404": "not found",
}
for code, expectedText := range statusCodes {
t.Run("status_"+code, func(t *testing.T) {
t.Parallel()
context := map[string]string{"status_code": code}
suggestions := getGitHubAPISuggestions(context)
testutil.AssertSliceContainsAll(t, suggestions, []string{expectedText})
})
}
}