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

@@ -1,37 +1,31 @@
// Package errors provides enhanced error types with contextual information and suggestions.
package errors
// Package apperrors provides enhanced error types with contextual information and suggestions.
package apperrors
import (
"errors"
"fmt"
"strings"
"github.com/ivuorinen/gh-action-readme/appconstants"
)
// ErrorCode represents a category of error for providing specific help.
type ErrorCode string
// Error code constants for categorizing errors.
const (
ErrCodeFileNotFound ErrorCode = "FILE_NOT_FOUND"
ErrCodePermission ErrorCode = "PERMISSION_DENIED"
ErrCodeInvalidYAML ErrorCode = "INVALID_YAML"
ErrCodeInvalidAction ErrorCode = "INVALID_ACTION"
ErrCodeNoActionFiles ErrorCode = "NO_ACTION_FILES"
ErrCodeGitHubAPI ErrorCode = "GITHUB_API_ERROR"
ErrCodeGitHubRateLimit ErrorCode = "GITHUB_RATE_LIMIT"
ErrCodeGitHubAuth ErrorCode = "GITHUB_AUTH_ERROR"
ErrCodeConfiguration ErrorCode = "CONFIG_ERROR"
ErrCodeValidation ErrorCode = "VALIDATION_ERROR"
ErrCodeTemplateRender ErrorCode = "TEMPLATE_ERROR"
ErrCodeFileWrite ErrorCode = "FILE_WRITE_ERROR"
ErrCodeDependencyAnalysis ErrorCode = "DEPENDENCY_ERROR"
ErrCodeCacheAccess ErrorCode = "CACHE_ERROR"
ErrCodeUnknown ErrorCode = "UNKNOWN_ERROR"
// 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 ErrorCode
Code appconstants.ErrorCode
Err error
Context string
Suggestions []string
@@ -98,7 +92,7 @@ func (ce *ContextualError) Is(target error) bool {
}
// New creates a new ContextualError with the given code and message.
func New(code ErrorCode, message string) *ContextualError {
func New(code appconstants.ErrorCode, message string) *ContextualError {
return &ContextualError{
Code: code,
Err: errors.New(message),
@@ -106,22 +100,37 @@ func New(code ErrorCode, message string) *ContextualError {
}
// Wrap wraps an existing error with contextual information.
func Wrap(err error, code ErrorCode, context string) *ContextualError {
func Wrap(err error, code appconstants.ErrorCode, context string) *ContextualError {
if err == nil {
return nil
}
// If already a ContextualError, preserve existing info
// If already a ContextualError, preserve existing info by creating a copy
if ce, ok := err.(*ContextualError); ok {
// Only update if not already set
if ce.Code == ErrCodeUnknown {
ce.Code = code
}
if ce.Context == "" {
ce.Context = context
// 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),
}
return ce
// 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{
@@ -158,24 +167,24 @@ func (ce *ContextualError) WithHelpURL(url string) *ContextualError {
}
// GetHelpURL returns a help URL for the given error code.
func GetHelpURL(code ErrorCode) string {
func GetHelpURL(code appconstants.ErrorCode) string {
baseURL := "https://github.com/ivuorinen/gh-action-readme/blob/main/docs/troubleshooting.md"
anchors := map[ErrorCode]string{
ErrCodeFileNotFound: "#file-not-found",
ErrCodePermission: "#permission-denied",
ErrCodeInvalidYAML: "#invalid-yaml",
ErrCodeInvalidAction: "#invalid-action-file",
ErrCodeNoActionFiles: "#no-action-files",
ErrCodeGitHubAPI: "#github-api-errors",
ErrCodeGitHubRateLimit: "#rate-limit-exceeded",
ErrCodeGitHubAuth: "#authentication-errors",
ErrCodeConfiguration: "#configuration-errors",
ErrCodeValidation: "#validation-errors",
ErrCodeTemplateRender: "#template-errors",
ErrCodeFileWrite: "#file-write-errors",
ErrCodeDependencyAnalysis: "#dependency-analysis",
ErrCodeCacheAccess: "#cache-errors",
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 {

View File

@@ -1,12 +1,21 @@
package errors
package apperrors
import (
"errors"
"strings"
"testing"
"github.com/ivuorinen/gh-action-readme/appconstants"
"github.com/ivuorinen/gh-action-readme/testutil"
)
func TestContextualError_Error(t *testing.T) {
const (
testOriginalError = "original error"
testMessage = "test message"
testContext = "test context"
)
func TestContextualErrorError(t *testing.T) {
t.Parallel()
tests := []struct {
@@ -17,7 +26,7 @@ func TestContextualError_Error(t *testing.T) {
{
name: "basic error",
err: &ContextualError{
Code: ErrCodeFileNotFound,
Code: appconstants.ErrCodeFileNotFound,
Err: errors.New("file not found"),
},
contains: []string{"file not found", "[FILE_NOT_FOUND]"},
@@ -25,7 +34,7 @@ func TestContextualError_Error(t *testing.T) {
{
name: "error with context",
err: &ContextualError{
Code: ErrCodeInvalidYAML,
Code: appconstants.ErrCodeInvalidYAML,
Err: errors.New("invalid syntax"),
Context: "parsing action.yml",
},
@@ -34,7 +43,7 @@ func TestContextualError_Error(t *testing.T) {
{
name: "error with suggestions",
err: &ContextualError{
Code: ErrCodeNoActionFiles,
Code: appconstants.ErrCodeNoActionFiles,
Err: errors.New("no files found"),
Suggestions: []string{
"Check current directory",
@@ -51,7 +60,7 @@ func TestContextualError_Error(t *testing.T) {
{
name: "error with details",
err: &ContextualError{
Code: ErrCodeConfiguration,
Code: appconstants.ErrCodeConfiguration,
Err: errors.New("config error"),
Details: map[string]string{
"config_path": "/path/to/config",
@@ -68,7 +77,7 @@ func TestContextualError_Error(t *testing.T) {
{
name: "error with help URL",
err: &ContextualError{
Code: ErrCodeGitHubAPI,
Code: appconstants.ErrCodeGitHubAPI,
Err: errors.New("API error"),
HelpURL: "https://docs.github.com/api",
},
@@ -80,7 +89,7 @@ func TestContextualError_Error(t *testing.T) {
{
name: "complete error",
err: &ContextualError{
Code: ErrCodeValidation,
Code: appconstants.ErrCodeValidation,
Err: errors.New("validation failed"),
Context: "validating action.yml",
Details: map[string]string{"file": "action.yml"},
@@ -108,26 +117,17 @@ func TestContextualError_Error(t *testing.T) {
t.Parallel()
result := tt.err.Error()
for _, expected := range tt.contains {
if !strings.Contains(result, expected) {
t.Errorf(
"Error() result missing expected content:\nExpected to contain: %q\nActual result:\n%s",
expected,
result,
)
}
}
testutil.AssertSliceContainsAll(t, []string{result}, tt.contains)
})
}
}
func TestContextualError_Unwrap(t *testing.T) {
func TestContextualErrorUnwrap(t *testing.T) {
t.Parallel()
originalErr := errors.New("original error")
originalErr := errors.New(testOriginalError)
contextualErr := &ContextualError{
Code: ErrCodeFileNotFound,
Code: appconstants.ErrCodeFileNotFound,
Err: originalErr,
}
@@ -136,23 +136,23 @@ func TestContextualError_Unwrap(t *testing.T) {
}
}
func TestContextualError_Is(t *testing.T) {
func TestContextualErrorIs(t *testing.T) {
t.Parallel()
originalErr := errors.New("original error")
originalErr := errors.New(testOriginalError)
contextualErr := &ContextualError{
Code: ErrCodeFileNotFound,
Code: appconstants.ErrCodeFileNotFound,
Err: originalErr,
}
// Test Is with same error code
sameCodeErr := &ContextualError{Code: ErrCodeFileNotFound}
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: ErrCodeInvalidYAML}
differentCodeErr := &ContextualError{Code: appconstants.ErrCodeInvalidYAML}
if contextualErr.Is(differentCodeErr) {
t.Error("Is() should return false for different error code")
}
@@ -166,59 +166,59 @@ func TestContextualError_Is(t *testing.T) {
func TestNew(t *testing.T) {
t.Parallel()
err := New(ErrCodeFileNotFound, "test message")
err := New(appconstants.ErrCodeFileNotFound, testMessage)
if err.Code != ErrCodeFileNotFound {
t.Errorf("New() code = %v, want %v", err.Code, ErrCodeFileNotFound)
if err.Code != appconstants.ErrCodeFileNotFound {
t.Errorf("New() code = %v, want %v", err.Code, appconstants.ErrCodeFileNotFound)
}
if err.Err.Error() != "test message" {
t.Errorf("New() message = %v, want %v", err.Err.Error(), "test message")
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("original error")
originalErr := errors.New(testOriginalError)
// Test wrapping normal error
wrapped := Wrap(originalErr, ErrCodeFileNotFound, "test context")
if wrapped.Code != ErrCodeFileNotFound {
t.Errorf("Wrap() code = %v, want %v", wrapped.Code, ErrCodeFileNotFound)
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 != "test context" {
t.Errorf("Wrap() context = %v, want %v", wrapped.Context, "test context")
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, ErrCodeFileNotFound, "test context")
nilWrapped := Wrap(nil, appconstants.ErrCodeFileNotFound, testContext)
if nilWrapped != nil {
t.Error("Wrap(nil) should return nil")
}
// Test wrapping already contextual error
contextualErr := &ContextualError{
Code: ErrCodeUnknown,
Code: appconstants.ErrCodeUnknown,
Err: originalErr,
Context: "",
}
rewrapped := Wrap(contextualErr, ErrCodeFileNotFound, "new context")
if rewrapped.Code != ErrCodeFileNotFound {
t.Error("Wrap() should update code if it was ErrCodeUnknown")
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 TestContextualError_WithMethods(t *testing.T) {
func TestContextualErrorWithMethods(t *testing.T) {
t.Parallel()
err := New(ErrCodeFileNotFound, "test error")
err := New(appconstants.ErrCodeFileNotFound, "test error")
// Test WithSuggestions
err = err.WithSuggestions("suggestion 1", "suggestion 2")
@@ -251,13 +251,13 @@ func TestGetHelpURL(t *testing.T) {
t.Parallel()
tests := []struct {
code ErrorCode
code appconstants.ErrorCode
contains string
}{
{ErrCodeFileNotFound, "#file-not-found"},
{ErrCodeInvalidYAML, "#invalid-yaml"},
{ErrCodeGitHubAPI, "#github-api-errors"},
{ErrCodeUnknown, "troubleshooting.md"}, // Should return base URL
{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 {

View File

@@ -1,4 +1,4 @@
package errors
package apperrors
import (
"fmt"
@@ -6,10 +6,12 @@ import (
"path/filepath"
"runtime"
"strings"
"github.com/ivuorinen/gh-action-readme/appconstants"
)
// GetSuggestions returns context-aware suggestions for the given error code.
func GetSuggestions(code ErrorCode, context map[string]string) []string {
func GetSuggestions(code appconstants.ErrorCode, context map[string]string) []string {
if handler := getSuggestionHandler(code); handler != nil {
return handler(context)
}
@@ -18,35 +20,31 @@ func GetSuggestions(code ErrorCode, context map[string]string) []string {
}
// getSuggestionHandler returns the appropriate suggestion function for the error code.
func getSuggestionHandler(code ErrorCode) func(map[string]string) []string {
handlers := map[ErrorCode]func(map[string]string) []string{
ErrCodeFileNotFound: getFileNotFoundSuggestions,
ErrCodePermission: getPermissionSuggestions,
ErrCodeInvalidYAML: getInvalidYAMLSuggestions,
ErrCodeInvalidAction: getInvalidActionSuggestions,
ErrCodeNoActionFiles: getNoActionFilesSuggestions,
ErrCodeGitHubAPI: getGitHubAPISuggestions,
ErrCodeConfiguration: getConfigurationSuggestions,
ErrCodeValidation: getValidationSuggestions,
ErrCodeTemplateRender: getTemplateSuggestions,
ErrCodeFileWrite: getFileWriteSuggestions,
ErrCodeDependencyAnalysis: getDependencyAnalysisSuggestions,
ErrCodeCacheAccess: getCacheAccessSuggestions,
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
switch code {
case ErrCodeGitHubRateLimit:
if code == appconstants.ErrCodeGitHubRateLimit {
return func(_ map[string]string) []string { return getGitHubRateLimitSuggestions() }
case ErrCodeGitHubAuth:
}
if code == appconstants.ErrCodeGitHubAuth {
return func(_ map[string]string) []string { return getGitHubAuthSuggestions() }
case ErrCodeFileNotFound, ErrCodePermission, ErrCodeInvalidYAML, ErrCodeInvalidAction,
ErrCodeNoActionFiles, ErrCodeGitHubAPI, ErrCodeConfiguration, ErrCodeValidation,
ErrCodeTemplateRender, ErrCodeFileWrite, ErrCodeDependencyAnalysis, ErrCodeCacheAccess,
ErrCodeUnknown:
// These cases are handled by the map above
}
// All other cases are handled by the handlers map
return handlers[code]
}

View File

@@ -1,26 +1,44 @@
package errors
package apperrors
import (
"runtime"
"strings"
"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 ErrorCode
code appconstants.ErrorCode
context map[string]string
contains []string
}{
{
name: "file not found with path",
code: ErrCodeFileNotFound,
context: map[string]string{
"path": "/path/to/action.yml",
},
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",
@@ -28,22 +46,18 @@ func TestGetSuggestions(t *testing.T) {
},
},
{
name: "file not found action file",
code: ErrCodeFileNotFound,
context: map[string]string{
"path": "/project/action.yml",
},
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: ErrCodePermission,
context: map[string]string{
"path": "/restricted/file.txt",
},
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",
@@ -51,7 +65,7 @@ func TestGetSuggestions(t *testing.T) {
},
{
name: "invalid YAML with line number",
code: ErrCodeInvalidYAML,
code: appconstants.ErrCodeInvalidYAML,
context: map[string]string{
"line": "25",
},
@@ -63,11 +77,9 @@ func TestGetSuggestions(t *testing.T) {
},
},
{
name: "invalid YAML with tab error",
code: ErrCodeInvalidYAML,
context: map[string]string{
"error": "found character that cannot start any token (tab)",
},
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",
@@ -75,7 +87,7 @@ func TestGetSuggestions(t *testing.T) {
},
{
name: "invalid action with missing fields",
code: ErrCodeInvalidAction,
code: appconstants.ErrCodeInvalidAction,
context: map[string]string{
"missing_fields": "name, description",
},
@@ -87,7 +99,7 @@ func TestGetSuggestions(t *testing.T) {
},
{
name: "no action files",
code: ErrCodeNoActionFiles,
code: appconstants.ErrCodeNoActionFiles,
context: map[string]string{
"directory": "/project",
},
@@ -99,11 +111,9 @@ func TestGetSuggestions(t *testing.T) {
},
},
{
name: "GitHub API 401 error",
code: ErrCodeGitHubAPI,
context: map[string]string{
"status_code": "401",
},
name: "GitHub API 401 error",
code: appconstants.ErrCodeGitHubAPI,
context: ctxStatusCode("401"),
contains: []string{
"Authentication failed",
"check your GitHub token",
@@ -111,11 +121,9 @@ func TestGetSuggestions(t *testing.T) {
},
},
{
name: "GitHub API 403 error",
code: ErrCodeGitHubAPI,
context: map[string]string{
"status_code": "403",
},
name: "GitHub API 403 error",
code: appconstants.ErrCodeGitHubAPI,
context: ctxStatusCode("403"),
contains: []string{
"Access forbidden",
"check token permissions",
@@ -123,11 +131,9 @@ func TestGetSuggestions(t *testing.T) {
},
},
{
name: "GitHub API 404 error",
code: ErrCodeGitHubAPI,
context: map[string]string{
"status_code": "404",
},
name: "GitHub API 404 error",
code: appconstants.ErrCodeGitHubAPI,
context: ctxStatusCode("404"),
contains: []string{
"Repository or resource not found",
"repository is private",
@@ -135,8 +141,8 @@ func TestGetSuggestions(t *testing.T) {
},
{
name: "GitHub rate limit",
code: ErrCodeGitHubRateLimit,
context: map[string]string{},
code: appconstants.ErrCodeGitHubRateLimit,
context: ctxEmpty(),
contains: []string{
"rate limit exceeded",
"GITHUB_TOKEN",
@@ -146,8 +152,8 @@ func TestGetSuggestions(t *testing.T) {
},
{
name: "GitHub auth",
code: ErrCodeGitHubAuth,
context: map[string]string{},
code: appconstants.ErrCodeGitHubAuth,
context: ctxEmpty(),
contains: []string{
"export GITHUB_TOKEN",
"gh auth login",
@@ -157,7 +163,7 @@ func TestGetSuggestions(t *testing.T) {
},
{
name: "configuration error with path",
code: ErrCodeConfiguration,
code: appconstants.ErrCodeConfiguration,
context: map[string]string{
"config_path": "~/.config/gh-action-readme/config.yaml",
},
@@ -169,7 +175,7 @@ func TestGetSuggestions(t *testing.T) {
},
{
name: "validation error with invalid fields",
code: ErrCodeValidation,
code: appconstants.ErrCodeValidation,
context: map[string]string{
"invalid_fields": "runs.using, inputs.test",
},
@@ -181,7 +187,7 @@ func TestGetSuggestions(t *testing.T) {
},
{
name: "template error with theme",
code: ErrCodeTemplateRender,
code: appconstants.ErrCodeTemplateRender,
context: map[string]string{
"theme": "custom",
},
@@ -193,7 +199,7 @@ func TestGetSuggestions(t *testing.T) {
},
{
name: "file write error with output path",
code: ErrCodeFileWrite,
code: appconstants.ErrCodeFileWrite,
context: map[string]string{
"output_path": "/output/README.md",
},
@@ -205,7 +211,7 @@ func TestGetSuggestions(t *testing.T) {
},
{
name: "dependency analysis error",
code: ErrCodeDependencyAnalysis,
code: appconstants.ErrCodeDependencyAnalysis,
context: map[string]string{
"action": "my-action",
},
@@ -217,7 +223,7 @@ func TestGetSuggestions(t *testing.T) {
},
{
name: "cache access error",
code: ErrCodeCacheAccess,
code: appconstants.ErrCodeCacheAccess,
context: map[string]string{
"cache_path": "~/.cache/gh-action-readme",
},
@@ -230,7 +236,7 @@ func TestGetSuggestions(t *testing.T) {
{
name: "unknown error code",
code: "UNKNOWN_TEST_CODE",
context: map[string]string{},
context: ctxEmpty(),
contains: []string{
"Check the error message",
"--verbose flag",
@@ -244,72 +250,44 @@ func TestGetSuggestions(t *testing.T) {
t.Parallel()
suggestions := GetSuggestions(tt.code, tt.context)
if len(suggestions) == 0 {
t.Error("GetSuggestions() returned empty slice")
return
}
allSuggestions := strings.Join(suggestions, " ")
for _, expected := range tt.contains {
if !strings.Contains(allSuggestions, expected) {
t.Errorf(
"GetSuggestions() missing expected content:\nExpected to contain: %q\nSuggestions:\n%s",
expected,
strings.Join(suggestions, "\n"),
)
}
}
testutil.AssertSliceContainsAll(t, suggestions, tt.contains)
})
}
}
func TestGetPermissionSuggestions_OSSpecific(t *testing.T) {
func TestGetPermissionSuggestionsOSSpecific(t *testing.T) {
t.Parallel()
context := map[string]string{"path": "/test/file"}
suggestions := getPermissionSuggestions(context)
allSuggestions := strings.Join(suggestions, " ")
switch runtime.GOOS {
case "windows":
if !strings.Contains(allSuggestions, "Administrator") {
t.Error("Windows-specific suggestions should mention Administrator")
}
if !strings.Contains(allSuggestions, "Windows file permissions") {
t.Error("Windows-specific suggestions should mention Windows file permissions")
}
testutil.AssertSliceContainsAll(t, suggestions, []string{"Administrator", "Windows file permissions"})
default:
if !strings.Contains(allSuggestions, "sudo") {
t.Error("Unix-specific suggestions should mention sudo")
}
if !strings.Contains(allSuggestions, "ls -la") {
t.Error("Unix-specific suggestions should mention ls -la")
}
testutil.AssertSliceContainsAll(t, suggestions, []string{"sudo", "ls -la"})
}
}
func TestGetSuggestions_EmptyContext(t *testing.T) {
func TestGetSuggestionsEmptyContext(t *testing.T) {
t.Parallel()
// Test that all error codes work with empty context
errorCodes := []ErrorCode{
ErrCodeFileNotFound,
ErrCodePermission,
ErrCodeInvalidYAML,
ErrCodeInvalidAction,
ErrCodeNoActionFiles,
ErrCodeGitHubAPI,
ErrCodeGitHubRateLimit,
ErrCodeGitHubAuth,
ErrCodeConfiguration,
ErrCodeValidation,
ErrCodeTemplateRender,
ErrCodeFileWrite,
ErrCodeDependencyAnalysis,
ErrCodeCacheAccess,
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 {
@@ -324,7 +302,7 @@ func TestGetSuggestions_EmptyContext(t *testing.T) {
}
}
func TestGetFileNotFoundSuggestions_ActionFile(t *testing.T) {
func TestGetFileNotFoundSuggestionsActionFile(t *testing.T) {
t.Parallel()
context := map[string]string{
@@ -332,19 +310,10 @@ func TestGetFileNotFoundSuggestions_ActionFile(t *testing.T) {
}
suggestions := getFileNotFoundSuggestions(context)
allSuggestions := strings.Join(suggestions, " ")
// Should suggest common action file names when path contains "action"
if !strings.Contains(allSuggestions, "action.yml, action.yaml") {
t.Error("Should suggest common action file names for action file paths")
}
if !strings.Contains(allSuggestions, "subdirectory") {
t.Error("Should suggest checking subdirectories for action files")
}
testutil.AssertSliceContainsAll(t, suggestions, []string{"action.yml, action.yaml", "subdirectory"})
}
func TestGetInvalidYAMLSuggestions_TabError(t *testing.T) {
func TestGetInvalidYAMLSuggestionsTabError(t *testing.T) {
t.Parallel()
context := map[string]string{
@@ -352,15 +321,10 @@ func TestGetInvalidYAMLSuggestions_TabError(t *testing.T) {
}
suggestions := getInvalidYAMLSuggestions(context)
allSuggestions := strings.Join(suggestions, " ")
// Should prioritize tab-specific suggestions when error mentions tabs
if !strings.Contains(allSuggestions, "tabs with spaces") {
t.Error("Should provide tab-specific suggestions when error mentions tabs")
}
testutil.AssertSliceContainsAll(t, suggestions, []string{"tabs with spaces"})
}
func TestGetGitHubAPISuggestions_StatusCodes(t *testing.T) {
func TestGetGitHubAPISuggestionsStatusCodes(t *testing.T) {
t.Parallel()
statusCodes := map[string]string{
@@ -375,11 +339,7 @@ func TestGetGitHubAPISuggestions_StatusCodes(t *testing.T) {
context := map[string]string{"status_code": code}
suggestions := getGitHubAPISuggestions(context)
allSuggestions := strings.Join(suggestions, " ")
if !strings.Contains(allSuggestions, expectedText) {
t.Errorf("Status code %s suggestions should contain %q", code, expectedText)
}
testutil.AssertSliceContainsAll(t, suggestions, []string{expectedText})
})
}
}

View File

@@ -10,6 +10,8 @@ import (
"time"
"github.com/adrg/xdg"
"github.com/ivuorinen/gh-action-readme/appconstants"
)
// Entry represents a cached item with TTL support.
@@ -53,13 +55,15 @@ func NewCache(config *Config) (*Cache, error) {
}
// Get XDG cache directory
cacheDir, err := xdg.CacheFile("gh-action-readme")
cacheDir, err := xdg.CacheFile(appconstants.AppName)
if err != nil {
return nil, fmt.Errorf("failed to get XDG cache directory: %w", err)
}
// Ensure cache directory exists
if err := os.MkdirAll(filepath.Dir(cacheDir), 0750); err != nil { // #nosec G301 -- cache directory permissions
cacheDirParent := filepath.Dir(cacheDir)
// #nosec G301 -- cache directory permissions
if err := os.MkdirAll(cacheDirParent, appconstants.FilePermDir); err != nil {
return nil, fmt.Errorf("failed to create cache directory: %w", err)
}
@@ -145,7 +149,7 @@ func (c *Cache) Clear() error {
c.data = make(map[string]Entry)
// Remove cache file
cacheFile := filepath.Join(c.path, "cache.json")
cacheFile := filepath.Join(c.path, appconstants.CacheJSON)
if err := os.Remove(cacheFile); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to remove cache file: %w", err)
}
@@ -245,7 +249,7 @@ func (c *Cache) cleanup() {
// loadFromDisk loads cache data from disk.
func (c *Cache) loadFromDisk() error {
cacheFile := filepath.Join(c.path, "cache.json")
cacheFile := filepath.Join(c.path, appconstants.CacheJSON)
data, err := os.ReadFile(cacheFile) // #nosec G304 -- cache file path constructed internally
if err != nil {
@@ -280,8 +284,9 @@ func (c *Cache) saveToDisk() error {
return fmt.Errorf("failed to marshal cache data: %w", err)
}
cacheFile := filepath.Join(c.path, "cache.json")
if err := os.WriteFile(cacheFile, jsonData, 0600); err != nil { // #nosec G306 -- cache file permissions
cacheFile := filepath.Join(c.path, appconstants.CacheJSON)
// #nosec G306 -- cache file permissions
if err := os.WriteFile(cacheFile, jsonData, appconstants.FilePermDefault); err != nil {
return fmt.Errorf("failed to write cache file: %w", err)
}

View File

@@ -74,7 +74,7 @@ func TestCache_SetAndGet(t *testing.T) {
defer cleanup()
cache := createTestCache(t, tmpDir)
defer func() { _ = cache.Close() }()
defer testutil.CleanupCache(t, cache)()
tests := []struct {
name string
@@ -126,7 +126,7 @@ func TestCache_TTL(t *testing.T) {
defer cleanup()
cache := createTestCache(t, tmpDir)
defer func() { _ = cache.Close() }()
defer testutil.CleanupCache(t, cache)()
// Set value with short TTL
shortTTL := 100 * time.Millisecond
@@ -155,7 +155,7 @@ func TestCache_GetOrSet(t *testing.T) {
defer cleanup()
cache := createTestCache(t, tmpDir)
defer func() { _ = cache.Close() }()
defer testutil.CleanupCache(t, cache)()
// Use unique key to avoid interference from other tests
testKey := fmt.Sprintf("test-key-%d", time.Now().UnixNano())
@@ -185,7 +185,7 @@ func TestCache_GetOrSetError(t *testing.T) {
defer cleanup()
cache := createTestCache(t, tmpDir)
defer func() { _ = cache.Close() }()
defer testutil.CleanupCache(t, cache)()
// Getter that returns error
getter := func() (any, error) {
@@ -212,7 +212,7 @@ func TestCache_ConcurrentAccess(t *testing.T) {
defer cleanup()
cache := createTestCache(t, tmpDir)
defer func() { _ = cache.Close() }()
defer testutil.CleanupCache(t, cache)()
const numGoroutines = 10
const numOperations = 100
@@ -272,7 +272,7 @@ func TestCache_Persistence(t *testing.T) {
// Create new cache instance (should load from disk)
cache2 := createTestCache(t, tmpDir)
defer func() { _ = cache2.Close() }()
defer testutil.CleanupCache(t, cache2)()
// Value should still exist
value, exists := cache2.Get("persistent-key")
@@ -287,7 +287,7 @@ func TestCache_Clear(t *testing.T) {
defer cleanup()
cache := createTestCache(t, tmpDir)
defer func() { _ = cache.Close() }()
defer testutil.CleanupCache(t, cache)()
// Add some data
_ = cache.Set("key1", "value1")
@@ -317,7 +317,7 @@ func TestCache_Delete(t *testing.T) {
defer cleanup()
cache := createTestCache(t, tmpDir)
defer func() { _ = cache.Close() }()
defer testutil.CleanupCache(t, cache)()
// Add some data
_ = cache.Set("key1", "value1")
@@ -354,7 +354,7 @@ func TestCache_Stats(t *testing.T) {
defer cleanup()
cache := createTestCache(t, tmpDir)
defer func() { _ = cache.Close() }()
defer testutil.CleanupCache(t, cache)()
// Ensure cache starts clean
_ = cache.Clear()
@@ -412,7 +412,7 @@ func TestCache_CleanupExpiredEntries(t *testing.T) {
cache, err := NewCache(config)
testutil.AssertNoError(t, err)
defer func() { _ = cache.Close() }()
defer testutil.CleanupCache(t, cache)()
// Add entry that will expire
err = cache.Set("expiring-key", "expiring-value")
@@ -465,7 +465,7 @@ func TestCache_ErrorHandling(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cache := tt.setupFunc(t)
defer func() { _ = cache.Close() }()
defer testutil.CleanupCache(t, cache)()
tt.testFunc(t, cache)
})
@@ -477,7 +477,7 @@ func TestCache_AsyncSaveErrorHandling(t *testing.T) {
defer cleanup()
cache := createTestCache(t, tmpDir)
defer func() { _ = cache.Close() }()
defer testutil.CleanupCache(t, cache)()
// This tests our new saveToDiskAsync error handling
// Set a value to trigger async save
@@ -502,7 +502,7 @@ func TestCache_EstimateSize(t *testing.T) {
defer cleanup()
cache := createTestCache(t, tmpDir)
defer func() { _ = cache.Close() }()
defer testutil.CleanupCache(t, cache)()
tests := []struct {
name string

View File

@@ -6,7 +6,6 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/adrg/xdg"
"github.com/gofri/go-github-ratelimit/github_ratelimit"
@@ -14,6 +13,7 @@ import (
"github.com/spf13/viper"
"golang.org/x/oauth2"
"github.com/ivuorinen/gh-action-readme/appconstants"
"github.com/ivuorinen/gh-action-readme/internal/git"
"github.com/ivuorinen/gh-action-readme/internal/validation"
"github.com/ivuorinen/gh-action-readme/templates_embed"
@@ -79,13 +79,8 @@ type GitHubClient struct {
// GetGitHubToken returns the GitHub token from environment variables or config.
func GetGitHubToken(config *AppConfig) string {
// Priority 1: Tool-specific env var
if token := os.Getenv(EnvGitHubToken); token != "" {
return token
}
// Priority 2: Standard GitHub env var
if token := os.Getenv(EnvGitHubTokenStandard); token != "" {
// Priority 1 & 2: Environment variables
if token := loadGitHubTokenFromEnv(); token != "" {
return token
}
@@ -109,7 +104,7 @@ func NewGitHubClient(token string) (*GitHubClient, error) {
// Add rate limiting with proper error handling
rateLimiter, err := github_ratelimit.NewRateLimitWaiterClient(tc.Transport)
if err != nil {
return nil, fmt.Errorf("failed to create rate limiter: %w", err)
return nil, fmt.Errorf(appconstants.ErrFailedToCreateRateLimiter, err)
}
client = github.NewClient(rateLimiter)
@@ -117,7 +112,7 @@ func NewGitHubClient(token string) (*GitHubClient, error) {
// For no token, use basic rate limiter
rateLimiter, err := github_ratelimit.NewRateLimitWaiterClient(nil)
if err != nil {
return nil, fmt.Errorf("failed to create rate limiter: %w", err)
return nil, fmt.Errorf(appconstants.ErrFailedToCreateRateLimiter, err)
}
client = github.NewClient(rateLimiter)
}
@@ -180,21 +175,29 @@ func resolveTemplatePath(templatePath string) string {
return resolvedPath
}
// resolveAllTemplatePaths resolves all template-related paths in the config.
func resolveAllTemplatePaths(config *AppConfig) {
config.Template = resolveTemplatePath(config.Template)
config.Header = resolveTemplatePath(config.Header)
config.Footer = resolveTemplatePath(config.Footer)
config.Schema = resolveTemplatePath(config.Schema)
}
// resolveThemeTemplate resolves the template path based on the selected theme.
func resolveThemeTemplate(theme string) string {
var templatePath string
switch theme {
case ThemeDefault:
templatePath = TemplatePathDefault
case ThemeGitHub:
templatePath = TemplatePathGitHub
case ThemeGitLab:
templatePath = TemplatePathGitLab
case ThemeMinimal:
templatePath = TemplatePathMinimal
case ThemeProfessional:
templatePath = TemplatePathProfessional
case appconstants.ThemeDefault:
templatePath = appconstants.TemplatePathDefault
case appconstants.ThemeGitHub:
templatePath = appconstants.TemplatePathGitHub
case appconstants.ThemeGitLab:
templatePath = appconstants.TemplatePathGitLab
case appconstants.ThemeMinimal:
templatePath = appconstants.TemplatePathMinimal
case appconstants.ThemeProfessional:
templatePath = appconstants.TemplatePathProfessional
case "":
// Empty theme should return empty path
return ""
@@ -290,25 +293,23 @@ func mergeStringFields(dst *AppConfig, src *AppConfig) {
}
}
// mergeStringMap is a generic helper that merges a source map into a destination map.
func mergeStringMap(dst *map[string]string, src map[string]string) {
if len(src) == 0 {
return
}
if *dst == nil {
*dst = make(map[string]string)
}
for k, v := range src {
(*dst)[k] = v
}
}
// mergeMapFields merges map fields from src to dst if non-empty.
func mergeMapFields(dst *AppConfig, src *AppConfig) {
if len(src.Permissions) > 0 {
if dst.Permissions == nil {
dst.Permissions = make(map[string]string)
}
for k, v := range src.Permissions {
dst.Permissions[k] = v
}
}
if len(src.Variables) > 0 {
if dst.Variables == nil {
dst.Variables = make(map[string]string)
}
for k, v := range src.Variables {
dst.Variables[k] = v
}
}
mergeStringMap(&dst.Permissions, src.Permissions)
mergeStringMap(&dst.Variables, src.Variables)
}
// mergeSliceFields merges slice fields from src to dst if non-empty.
@@ -353,59 +354,32 @@ func mergeSecurityFields(dst *AppConfig, src *AppConfig, allowTokens bool) {
// LoadRepoConfig loads repository-level configuration from hidden config files.
func LoadRepoConfig(repoRoot string) (*AppConfig, error) {
// Hidden config file paths in priority order
configPaths := []string{
".ghreadme.yaml", // Primary hidden config
".config/ghreadme.yaml", // Secondary hidden config
".github/ghreadme.yaml", // GitHub ecosystem standard
return loadRepoConfigInternal(repoRoot)
}
// loadRepoConfigInternal is the shared internal implementation for repo config loading.
func loadRepoConfigInternal(repoRoot string) (*AppConfig, error) {
configPath, found := findFirstExistingConfig(repoRoot, appconstants.GetConfigSearchPaths())
if found {
return loadConfigFromViper(configPath)
}
for _, configName := range configPaths {
configPath := filepath.Join(repoRoot, configName)
if _, err := os.Stat(configPath); err == nil {
// Config file found, load it
v := viper.New()
v.SetConfigFile(configPath)
v.SetConfigType("yaml")
if err := v.ReadInConfig(); err != nil {
return nil, fmt.Errorf("failed to read repo config %s: %w", configPath, err)
}
var config AppConfig
if err := v.Unmarshal(&config); err != nil {
return nil, fmt.Errorf("failed to unmarshal repo config: %w", err)
}
return &config, nil
}
}
// No config found, return empty config
return &AppConfig{}, nil
}
// LoadActionConfig loads action-level configuration from config.yaml.
func LoadActionConfig(actionDir string) (*AppConfig, error) {
configPath := filepath.Join(actionDir, "config.yaml")
return loadActionConfigInternal(actionDir)
}
// loadActionConfigInternal is the shared internal implementation for action config loading.
func loadActionConfigInternal(actionDir string) (*AppConfig, error) {
configPath := filepath.Join(actionDir, appconstants.ConfigYAML)
if _, err := os.Stat(configPath); os.IsNotExist(err) {
return &AppConfig{}, nil // No action config is fine
return &AppConfig{}, nil
}
v := viper.New()
v.SetConfigFile(configPath)
v.SetConfigType("yaml")
if err := v.ReadInConfig(); err != nil {
return nil, fmt.Errorf("failed to read action config %s: %w", configPath, err)
}
var config AppConfig
if err := v.Unmarshal(&config); err != nil {
return nil, fmt.Errorf("failed to unmarshal action config: %w", err)
}
return &config, nil
return loadConfigFromViper(configPath)
}
// DetectRepositoryName detects the repository name from git remote URL.
@@ -430,7 +404,7 @@ func LoadConfiguration(configFile, repoRoot, actionDir string) (*AppConfig, erro
// 2. Load global config
globalConfig, err := InitConfig(configFile)
if err != nil {
return nil, fmt.Errorf("failed to load global config: %w", err)
return nil, fmt.Errorf(appconstants.ErrFailedToLoadGlobalConfig, err)
}
MergeConfigs(config, globalConfig, true) // Allow tokens for global config
@@ -446,7 +420,7 @@ func LoadConfiguration(configFile, repoRoot, actionDir string) (*AppConfig, erro
if repoRoot != "" {
repoConfig, err := LoadRepoConfig(repoRoot)
if err != nil {
return nil, fmt.Errorf("failed to load repo config: %w", err)
return nil, fmt.Errorf(appconstants.ErrFailedToLoadRepoConfig, err)
}
MergeConfigs(config, repoConfig, false) // No tokens in repo config
}
@@ -455,16 +429,14 @@ func LoadConfiguration(configFile, repoRoot, actionDir string) (*AppConfig, erro
if actionDir != "" {
actionConfig, err := LoadActionConfig(actionDir)
if err != nil {
return nil, fmt.Errorf("failed to load action config: %w", err)
return nil, fmt.Errorf(appconstants.ErrFailedToLoadActionConfig, err)
}
MergeConfigs(config, actionConfig, false) // No tokens in action config
}
// 6. Apply environment variable overrides for GitHub token
// Check environment variables directly with higher priority
if token := os.Getenv(EnvGitHubToken); token != "" {
config.GitHubToken = token
} else if token := os.Getenv(EnvGitHubTokenStandard); token != "" {
if token := loadGitHubTokenFromEnv(); token != "" {
config.GitHubToken = token
}
@@ -473,108 +445,46 @@ func LoadConfiguration(configFile, repoRoot, actionDir string) (*AppConfig, erro
// InitConfig initializes the global configuration using Viper with XDG compliance.
func InitConfig(configFile string) (*AppConfig, error) {
v := viper.New()
// Set configuration file name and type
v.SetConfigName(ConfigFileName)
v.SetConfigType("yaml")
// Add XDG-compliant configuration directory
configDir, err := xdg.ConfigFile("gh-action-readme")
v, err := initializeViperInstance()
if err != nil {
return nil, fmt.Errorf("failed to get XDG config directory: %w", err)
}
v.AddConfigPath(filepath.Dir(configDir))
// Add additional search paths
v.AddConfigPath(".") // current directory
v.AddConfigPath("$HOME/.config/gh-action-readme") // fallback
v.AddConfigPath("/etc/gh-action-readme") // system-wide
// Set environment variable prefix
v.SetEnvPrefix("GH_ACTION_README")
v.SetEnvKeyReplacer(strings.NewReplacer("-", "_", ".", "_"))
v.AutomaticEnv()
// Set defaults
defaults := DefaultAppConfig()
v.SetDefault("organization", defaults.Organization)
v.SetDefault("repository", defaults.Repository)
v.SetDefault("version", defaults.Version)
v.SetDefault("theme", defaults.Theme)
v.SetDefault("output_format", defaults.OutputFormat)
v.SetDefault("output_dir", defaults.OutputDir)
v.SetDefault("template", defaults.Template)
v.SetDefault("header", defaults.Header)
v.SetDefault("footer", defaults.Footer)
v.SetDefault("schema", defaults.Schema)
v.SetDefault("analyze_dependencies", defaults.AnalyzeDependencies)
v.SetDefault("show_security_info", defaults.ShowSecurityInfo)
v.SetDefault("verbose", defaults.Verbose)
v.SetDefault("quiet", defaults.Quiet)
v.SetDefault("defaults.name", defaults.Defaults.Name)
v.SetDefault("defaults.description", defaults.Defaults.Description)
v.SetDefault("defaults.branding.icon", defaults.Defaults.Branding.Icon)
v.SetDefault("defaults.branding.color", defaults.Defaults.Branding.Color)
// Use specific config file if provided
if configFile != "" {
v.SetConfigFile(configFile)
return nil, err
}
// Read configuration
if err := v.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
return nil, fmt.Errorf("failed to read config file: %w", err)
}
// Config file not found is not an error - we'll use defaults and env vars
}
// Unmarshal configuration into struct
var config AppConfig
if err := v.Unmarshal(&config); err != nil {
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
}
// Resolve template paths relative to binary if they're not absolute
config.Template = resolveTemplatePath(config.Template)
config.Header = resolveTemplatePath(config.Header)
config.Footer = resolveTemplatePath(config.Footer)
config.Schema = resolveTemplatePath(config.Schema)
return &config, nil
return loadAndUnmarshalConfig(configFile, v)
}
// WriteDefaultConfig writes a default configuration file to the XDG config directory.
func WriteDefaultConfig() error {
configFile, err := xdg.ConfigFile("gh-action-readme/config.yaml")
configFile, err := xdg.ConfigFile(appconstants.PathXDGConfig)
if err != nil {
return fmt.Errorf("failed to get XDG config file path: %w", err)
return fmt.Errorf(appconstants.ErrFailedToGetXDGConfigFile, err)
}
// Ensure the directory exists
if err := os.MkdirAll(filepath.Dir(configFile), 0750); err != nil { // #nosec G301 -- config directory permissions
configFileDir := filepath.Dir(configFile)
// #nosec G301 -- config directory permissions
if err := os.MkdirAll(configFileDir, appconstants.FilePermDir); err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
}
v := viper.New()
v.SetConfigFile(configFile)
v.SetConfigType("yaml")
v.SetConfigType(appconstants.OutputFormatYAML)
// Set default values
defaults := DefaultAppConfig()
v.Set("theme", defaults.Theme)
v.Set("output_format", defaults.OutputFormat)
v.Set("output_dir", defaults.OutputDir)
v.Set("analyze_dependencies", defaults.AnalyzeDependencies)
v.Set("show_security_info", defaults.ShowSecurityInfo)
v.Set("verbose", defaults.Verbose)
v.Set("quiet", defaults.Quiet)
v.Set("template", defaults.Template)
v.Set("header", defaults.Header)
v.Set("footer", defaults.Footer)
v.Set("schema", defaults.Schema)
v.Set("defaults", defaults.Defaults)
v.Set(appconstants.ConfigKeyTheme, defaults.Theme)
v.Set(appconstants.ConfigKeyOutputFormat, defaults.OutputFormat)
v.Set(appconstants.ConfigKeyOutputDir, defaults.OutputDir)
v.Set(appconstants.ConfigKeyAnalyzeDependencies, defaults.AnalyzeDependencies)
v.Set(appconstants.ConfigKeyShowSecurityInfo, defaults.ShowSecurityInfo)
v.Set(appconstants.ConfigKeyVerbose, defaults.Verbose)
v.Set(appconstants.ConfigKeyQuiet, defaults.Quiet)
v.Set(appconstants.ConfigKeyTemplate, defaults.Template)
v.Set(appconstants.ConfigKeyHeader, defaults.Header)
v.Set(appconstants.ConfigKeyFooter, defaults.Footer)
v.Set(appconstants.ConfigKeySchema, defaults.Schema)
v.Set(appconstants.ConfigKeyDefaults, defaults.Defaults)
if err := v.WriteConfig(); err != nil {
return fmt.Errorf("failed to write default config: %w", err)
@@ -585,9 +495,9 @@ func WriteDefaultConfig() error {
// GetConfigPath returns the path to the configuration file.
func GetConfigPath() (string, error) {
configDir, err := xdg.ConfigFile("gh-action-readme/config.yaml")
configDir, err := xdg.ConfigFile(appconstants.PathXDGConfig)
if err != nil {
return "", fmt.Errorf("failed to get XDG config file path: %w", err)
return "", fmt.Errorf(appconstants.ErrFailedToGetXDGConfigFile, err)
}
return configDir, nil

20
internal/config_helper.go Normal file
View File

@@ -0,0 +1,20 @@
package internal
import (
"os"
"path/filepath"
)
// findFirstExistingConfig searches for the first existing config file
// from a list of config names within a base directory.
// Returns the full path to the first existing config file, or empty string if none exist.
func findFirstExistingConfig(basePath string, configNames []string) (string, bool) {
for _, name := range configNames {
path := filepath.Join(basePath, name)
if _, err := os.Stat(path); err == nil {
return path, true
}
}
return "", false
}

View File

@@ -5,6 +5,7 @@ import (
"path/filepath"
"testing"
"github.com/ivuorinen/gh-action-readme/appconstants"
"github.com/ivuorinen/gh-action-readme/testutil"
)
@@ -123,6 +124,10 @@ func TestLoadConfiguration(t *testing.T) {
name: "multi-level config hierarchy",
setupFunc: func(t *testing.T, tempDir string) (string, string, string) {
t.Helper()
// Clear environment variables to ensure config file values are used
t.Setenv(appconstants.EnvGitHubTokenStandard, "")
t.Setenv(appconstants.EnvGitHubToken, "")
// Create global config
globalConfigDir := filepath.Join(tempDir, ".config", "gh-action-readme")
_ = os.MkdirAll(globalConfigDir, 0750) // #nosec G301 -- test directory permissions
@@ -331,13 +336,7 @@ func TestWriteDefaultConfig(t *testing.T) {
// Check that config file was created
configPath, _ := GetConfigPath()
t.Logf("Expected config path: %s", configPath)
if _, err := os.Stat(configPath); os.IsNotExist(err) {
t.Errorf("config file was not created at: %s", configPath)
// List what files were actually created
if files, err := os.ReadDir(tmpDir); err == nil {
t.Logf("Files in tmpDir: %v", files)
}
}
testutil.AssertFileExists(t, configPath)
// Verify config file content
config, err := InitConfig(configPath)
@@ -543,14 +542,14 @@ func TestGetGitHubToken(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
// Set up environment
if tt.toolEnvToken != "" {
t.Setenv(EnvGitHubToken, tt.toolEnvToken)
t.Setenv(appconstants.EnvGitHubToken, tt.toolEnvToken)
} else {
t.Setenv(EnvGitHubToken, "")
t.Setenv(appconstants.EnvGitHubToken, "")
}
if tt.stdEnvToken != "" {
t.Setenv(EnvGitHubTokenStandard, tt.stdEnvToken)
t.Setenv(appconstants.EnvGitHubTokenStandard, tt.stdEnvToken)
} else {
t.Setenv(EnvGitHubTokenStandard, "")
t.Setenv(appconstants.EnvGitHubTokenStandard, "")
}
config := &AppConfig{GitHubToken: tt.configToken}

View File

@@ -3,33 +3,18 @@ package internal
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/adrg/xdg"
"github.com/spf13/viper"
)
// ConfigurationSource represents different sources of configuration.
type ConfigurationSource int
// Configuration source priority order (lowest to highest priority).
const (
// SourceDefaults represents default configuration values.
SourceDefaults ConfigurationSource = iota
SourceGlobal
SourceRepoOverride
SourceRepoConfig
SourceActionConfig
SourceEnvironment
SourceCLIFlags
"github.com/ivuorinen/gh-action-readme/appconstants"
)
// ConfigurationLoader handles loading and merging configuration from multiple sources.
type ConfigurationLoader struct {
// sources tracks which sources are enabled
sources map[ConfigurationSource]bool
sources map[appconstants.ConfigurationSource]bool
// viper instance for global configuration
viper *viper.Viper
}
@@ -41,20 +26,20 @@ type ConfigurationOptions struct {
// AllowTokens controls whether security-sensitive fields can be loaded
AllowTokens bool
// EnabledSources controls which configuration sources are used
EnabledSources []ConfigurationSource
EnabledSources []appconstants.ConfigurationSource
}
// NewConfigurationLoader creates a new configuration loader with default options.
func NewConfigurationLoader() *ConfigurationLoader {
return &ConfigurationLoader{
sources: map[ConfigurationSource]bool{
SourceDefaults: true,
SourceGlobal: true,
SourceRepoOverride: true,
SourceRepoConfig: true,
SourceActionConfig: true,
SourceEnvironment: true,
SourceCLIFlags: false, // CLI flags are applied separately
sources: map[appconstants.ConfigurationSource]bool{
appconstants.SourceDefaults: true,
appconstants.SourceGlobal: true,
appconstants.SourceRepoOverride: true,
appconstants.SourceRepoConfig: true,
appconstants.SourceActionConfig: true,
appconstants.SourceEnvironment: true,
appconstants.SourceCLIFlags: false, // CLI flags are applied separately
},
viper: viper.New(),
}
@@ -63,15 +48,15 @@ func NewConfigurationLoader() *ConfigurationLoader {
// NewConfigurationLoaderWithOptions creates a configuration loader with custom options.
func NewConfigurationLoaderWithOptions(opts ConfigurationOptions) *ConfigurationLoader {
loader := &ConfigurationLoader{
sources: make(map[ConfigurationSource]bool),
sources: make(map[appconstants.ConfigurationSource]bool),
viper: viper.New(),
}
// Set default sources if none specified
if len(opts.EnabledSources) == 0 {
opts.EnabledSources = []ConfigurationSource{
SourceDefaults, SourceGlobal, SourceRepoOverride,
SourceRepoConfig, SourceActionConfig, SourceEnvironment,
opts.EnabledSources = []appconstants.ConfigurationSource{
appconstants.SourceDefaults, appconstants.SourceGlobal, appconstants.SourceRepoOverride,
appconstants.SourceRepoConfig, appconstants.SourceActionConfig, appconstants.SourceEnvironment,
}
}
@@ -158,8 +143,8 @@ func containsString(slice []string, str string) bool {
}
// GetConfigurationSources returns the currently enabled configuration sources.
func (cl *ConfigurationLoader) GetConfigurationSources() []ConfigurationSource {
var sources []ConfigurationSource
func (cl *ConfigurationLoader) GetConfigurationSources() []appconstants.ConfigurationSource {
var sources []appconstants.ConfigurationSource
for source, enabled := range cl.sources {
if enabled {
sources = append(sources, source)
@@ -170,18 +155,18 @@ func (cl *ConfigurationLoader) GetConfigurationSources() []ConfigurationSource {
}
// EnableSource enables a specific configuration source.
func (cl *ConfigurationLoader) EnableSource(source ConfigurationSource) {
func (cl *ConfigurationLoader) EnableSource(source appconstants.ConfigurationSource) {
cl.sources[source] = true
}
// DisableSource disables a specific configuration source.
func (cl *ConfigurationLoader) DisableSource(source ConfigurationSource) {
func (cl *ConfigurationLoader) DisableSource(source appconstants.ConfigurationSource) {
cl.sources[source] = false
}
// loadDefaultsStep loads default configuration values.
func (cl *ConfigurationLoader) loadDefaultsStep(config *AppConfig) {
if cl.sources[SourceDefaults] {
if cl.sources[appconstants.SourceDefaults] {
defaults := DefaultAppConfig()
*config = *defaults
}
@@ -189,13 +174,13 @@ func (cl *ConfigurationLoader) loadDefaultsStep(config *AppConfig) {
// loadGlobalStep loads global configuration.
func (cl *ConfigurationLoader) loadGlobalStep(config *AppConfig, configFile string) error {
if !cl.sources[SourceGlobal] {
if !cl.sources[appconstants.SourceGlobal] {
return nil
}
globalConfig, err := cl.loadGlobalConfig(configFile)
if err != nil {
return fmt.Errorf("failed to load global config: %w", err)
return fmt.Errorf(appconstants.ErrFailedToLoadGlobalConfig, err)
}
cl.mergeConfigs(config, globalConfig, true) // Allow tokens for global config
@@ -204,7 +189,7 @@ func (cl *ConfigurationLoader) loadGlobalStep(config *AppConfig, configFile stri
// loadRepoOverrideStep applies repo-specific overrides from global config.
func (cl *ConfigurationLoader) loadRepoOverrideStep(config *AppConfig, repoRoot string) {
if !cl.sources[SourceRepoOverride] || repoRoot == "" {
if !cl.sources[appconstants.SourceRepoOverride] || repoRoot == "" {
return
}
@@ -213,13 +198,13 @@ func (cl *ConfigurationLoader) loadRepoOverrideStep(config *AppConfig, repoRoot
// loadRepoConfigStep loads repository root configuration.
func (cl *ConfigurationLoader) loadRepoConfigStep(config *AppConfig, repoRoot string) error {
if !cl.sources[SourceRepoConfig] || repoRoot == "" {
if !cl.sources[appconstants.SourceRepoConfig] || repoRoot == "" {
return nil
}
repoConfig, err := cl.loadRepoConfig(repoRoot)
if err != nil {
return fmt.Errorf("failed to load repo config: %w", err)
return fmt.Errorf(appconstants.ErrFailedToLoadRepoConfig, err)
}
cl.mergeConfigs(config, repoConfig, false) // No tokens in repo config
@@ -228,13 +213,13 @@ func (cl *ConfigurationLoader) loadRepoConfigStep(config *AppConfig, repoRoot st
// loadActionConfigStep loads action-specific configuration.
func (cl *ConfigurationLoader) loadActionConfigStep(config *AppConfig, actionDir string) error {
if !cl.sources[SourceActionConfig] || actionDir == "" {
if !cl.sources[appconstants.SourceActionConfig] || actionDir == "" {
return nil
}
actionConfig, err := cl.loadActionConfig(actionDir)
if err != nil {
return fmt.Errorf("failed to load action config: %w", err)
return fmt.Errorf(appconstants.ErrFailedToLoadActionConfig, err)
}
cl.mergeConfigs(config, actionConfig, false) // No tokens in action config
@@ -243,114 +228,29 @@ func (cl *ConfigurationLoader) loadActionConfigStep(config *AppConfig, actionDir
// loadEnvironmentStep applies environment variable overrides.
func (cl *ConfigurationLoader) loadEnvironmentStep(config *AppConfig) {
if cl.sources[SourceEnvironment] {
if cl.sources[appconstants.SourceEnvironment] {
cl.applyEnvironmentOverrides(config)
}
}
// loadGlobalConfig initializes and loads the global configuration using Viper.
func (cl *ConfigurationLoader) loadGlobalConfig(configFile string) (*AppConfig, error) {
v := viper.New()
// Set configuration file name and type
v.SetConfigName(ConfigFileName)
v.SetConfigType("yaml")
// Add XDG-compliant configuration directory
configDir, err := xdg.ConfigFile("gh-action-readme")
v, err := initializeViperInstance()
if err != nil {
return nil, fmt.Errorf("failed to get XDG config directory: %w", err)
}
v.AddConfigPath(filepath.Dir(configDir))
// Add additional search paths
v.AddConfigPath(".") // current directory
v.AddConfigPath("$HOME/.config/gh-action-readme") // fallback
v.AddConfigPath("/etc/gh-action-readme") // system-wide
// Set environment variable prefix
v.SetEnvPrefix("GH_ACTION_README")
v.SetEnvKeyReplacer(strings.NewReplacer("-", "_", ".", "_"))
v.AutomaticEnv()
// Set defaults
cl.setViperDefaults(v)
// Use specific config file if provided
if configFile != "" {
v.SetConfigFile(configFile)
return nil, err
}
// Read configuration
if err := v.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
return nil, fmt.Errorf("failed to read config file: %w", err)
}
// Config file not found is not an error - we'll use defaults and env vars
}
// Unmarshal configuration into struct
var config AppConfig
if err := v.Unmarshal(&config); err != nil {
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
}
// Resolve template paths relative to binary if they're not absolute
config.Template = resolveTemplatePath(config.Template)
config.Header = resolveTemplatePath(config.Header)
config.Footer = resolveTemplatePath(config.Footer)
config.Schema = resolveTemplatePath(config.Schema)
return &config, nil
return loadAndUnmarshalConfig(configFile, v)
}
// loadRepoConfig loads repository-level configuration from hidden config files.
func (cl *ConfigurationLoader) loadRepoConfig(repoRoot string) (*AppConfig, error) {
// Hidden config file paths in priority order
configPaths := []string{
".ghreadme.yaml", // Primary hidden config
".config/ghreadme.yaml", // Secondary hidden config
".github/ghreadme.yaml", // GitHub ecosystem standard
}
for _, configName := range configPaths {
configPath := filepath.Join(repoRoot, configName)
if _, err := os.Stat(configPath); err == nil {
// Config file found, load it
return cl.loadConfigFromFile(configPath)
}
}
// No config found, return empty config
return &AppConfig{}, nil
return loadRepoConfigInternal(repoRoot)
}
// loadActionConfig loads action-level configuration from config.yaml.
func (cl *ConfigurationLoader) loadActionConfig(actionDir string) (*AppConfig, error) {
configPath := filepath.Join(actionDir, "config.yaml")
if _, err := os.Stat(configPath); os.IsNotExist(err) {
return &AppConfig{}, nil // No action config is fine
}
return cl.loadConfigFromFile(configPath)
}
// loadConfigFromFile loads configuration from a specific file.
func (cl *ConfigurationLoader) loadConfigFromFile(configPath string) (*AppConfig, error) {
v := viper.New()
v.SetConfigFile(configPath)
v.SetConfigType("yaml")
if err := v.ReadInConfig(); err != nil {
return nil, fmt.Errorf("failed to read config %s: %w", configPath, err)
}
var config AppConfig
if err := v.Unmarshal(&config); err != nil {
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
}
return &config, nil
return loadActionConfigInternal(actionDir)
}
// applyRepoOverrides applies repository-specific overrides from global config.
@@ -372,9 +272,7 @@ func (cl *ConfigurationLoader) applyRepoOverrides(config *AppConfig, repoRoot st
// applyEnvironmentOverrides applies environment variable overrides.
func (cl *ConfigurationLoader) applyEnvironmentOverrides(config *AppConfig) {
// Check environment variables directly with higher priority
if token := os.Getenv(EnvGitHubToken); token != "" {
config.GitHubToken = token
} else if token := os.Getenv(EnvGitHubTokenStandard); token != "" {
if token := loadGitHubTokenFromEnv(); token != "" {
config.GitHubToken = token
}
}
@@ -384,29 +282,6 @@ func (cl *ConfigurationLoader) mergeConfigs(dst *AppConfig, src *AppConfig, allo
MergeConfigs(dst, src, allowTokens)
}
// setViperDefaults sets default values in viper.
func (cl *ConfigurationLoader) setViperDefaults(v *viper.Viper) {
defaults := DefaultAppConfig()
v.SetDefault("organization", defaults.Organization)
v.SetDefault("repository", defaults.Repository)
v.SetDefault("version", defaults.Version)
v.SetDefault("theme", defaults.Theme)
v.SetDefault("output_format", defaults.OutputFormat)
v.SetDefault("output_dir", defaults.OutputDir)
v.SetDefault("template", defaults.Template)
v.SetDefault("header", defaults.Header)
v.SetDefault("footer", defaults.Footer)
v.SetDefault("schema", defaults.Schema)
v.SetDefault("analyze_dependencies", defaults.AnalyzeDependencies)
v.SetDefault("show_security_info", defaults.ShowSecurityInfo)
v.SetDefault("verbose", defaults.Verbose)
v.SetDefault("quiet", defaults.Quiet)
v.SetDefault("defaults.name", defaults.Defaults.Name)
v.SetDefault("defaults.description", defaults.Defaults.Description)
v.SetDefault("defaults.branding.icon", defaults.Defaults.Branding.Icon)
v.SetDefault("defaults.branding.color", defaults.Defaults.Branding.Color)
}
// validateTheme validates that a theme exists and is supported.
func (cl *ConfigurationLoader) validateTheme(theme string) error {
if theme == "" {
@@ -414,8 +289,7 @@ func (cl *ConfigurationLoader) validateTheme(theme string) error {
}
// Check if it's a built-in theme
supportedThemes := []string{"default", "github", "gitlab", "minimal", "professional"}
if containsString(supportedThemes, theme) {
if containsString(appconstants.GetSupportedThemes(), theme) {
return nil
}
@@ -426,27 +300,5 @@ func (cl *ConfigurationLoader) validateTheme(theme string) error {
}
return fmt.Errorf("unsupported theme '%s', must be one of: %s",
theme, strings.Join(supportedThemes, ", "))
}
// String returns a string representation of a ConfigurationSource.
func (s ConfigurationSource) String() string {
switch s {
case SourceDefaults:
return "defaults"
case SourceGlobal:
return "global"
case SourceRepoOverride:
return "repo-override"
case SourceRepoConfig:
return "repo-config"
case SourceActionConfig:
return "action-config"
case SourceEnvironment:
return "environment"
case SourceCLIFlags:
return "cli-flags"
default:
return "unknown"
}
theme, strings.Join(appconstants.GetSupportedThemes(), ", "))
}

View File

@@ -5,6 +5,7 @@ import (
"path/filepath"
"testing"
"github.com/ivuorinen/gh-action-readme/appconstants"
"github.com/ivuorinen/gh-action-readme/testutil"
)
@@ -21,9 +22,9 @@ func TestNewConfigurationLoader(t *testing.T) {
}
// Check default sources are enabled
expectedSources := []ConfigurationSource{
SourceDefaults, SourceGlobal, SourceRepoOverride,
SourceRepoConfig, SourceActionConfig, SourceEnvironment,
expectedSources := []appconstants.ConfigurationSource{
appconstants.SourceDefaults, appconstants.SourceGlobal, appconstants.SourceRepoOverride,
appconstants.SourceRepoConfig, appconstants.SourceActionConfig, appconstants.SourceEnvironment,
}
for _, source := range expectedSources {
@@ -33,7 +34,7 @@ func TestNewConfigurationLoader(t *testing.T) {
}
// CLI flags should be disabled by default
if loader.sources[SourceCLIFlags] {
if loader.sources[appconstants.SourceCLIFlags] {
t.Error("expected CLI flags source to be disabled by default")
}
}
@@ -43,34 +44,41 @@ func TestNewConfigurationLoaderWithOptions(t *testing.T) {
tests := []struct {
name string
opts ConfigurationOptions
expected []ConfigurationSource
expected []appconstants.ConfigurationSource
}{
{
name: "default options",
opts: ConfigurationOptions{},
expected: []ConfigurationSource{
SourceDefaults, SourceGlobal, SourceRepoOverride,
SourceRepoConfig, SourceActionConfig, SourceEnvironment,
expected: []appconstants.ConfigurationSource{
appconstants.SourceDefaults, appconstants.SourceGlobal, appconstants.SourceRepoOverride,
appconstants.SourceRepoConfig, appconstants.SourceActionConfig, appconstants.SourceEnvironment,
},
},
{
name: "custom enabled sources",
opts: ConfigurationOptions{
EnabledSources: []ConfigurationSource{SourceDefaults, SourceGlobal},
EnabledSources: []appconstants.ConfigurationSource{
appconstants.SourceDefaults,
appconstants.SourceGlobal,
},
},
expected: []ConfigurationSource{SourceDefaults, SourceGlobal},
expected: []appconstants.ConfigurationSource{appconstants.SourceDefaults, appconstants.SourceGlobal},
},
{
name: "all sources enabled",
opts: ConfigurationOptions{
EnabledSources: []ConfigurationSource{
SourceDefaults, SourceGlobal, SourceRepoOverride,
SourceRepoConfig, SourceActionConfig, SourceEnvironment, SourceCLIFlags,
EnabledSources: []appconstants.ConfigurationSource{
appconstants.SourceDefaults, appconstants.SourceGlobal,
appconstants.SourceRepoOverride, appconstants.SourceRepoConfig,
appconstants.SourceActionConfig, appconstants.SourceEnvironment,
appconstants.SourceCLIFlags,
},
},
expected: []ConfigurationSource{
SourceDefaults, SourceGlobal, SourceRepoOverride,
SourceRepoConfig, SourceActionConfig, SourceEnvironment, SourceCLIFlags,
expected: []appconstants.ConfigurationSource{
appconstants.SourceDefaults, appconstants.SourceGlobal,
appconstants.SourceRepoOverride, appconstants.SourceRepoConfig,
appconstants.SourceActionConfig, appconstants.SourceEnvironment,
appconstants.SourceCLIFlags,
},
},
}
@@ -87,9 +95,11 @@ func TestNewConfigurationLoaderWithOptions(t *testing.T) {
}
// Check that non-expected sources are disabled
allSources := []ConfigurationSource{
SourceDefaults, SourceGlobal, SourceRepoOverride,
SourceRepoConfig, SourceActionConfig, SourceEnvironment, SourceCLIFlags,
allSources := []appconstants.ConfigurationSource{
appconstants.SourceDefaults, appconstants.SourceGlobal,
appconstants.SourceRepoOverride, appconstants.SourceRepoConfig,
appconstants.SourceActionConfig, appconstants.SourceEnvironment,
appconstants.SourceCLIFlags,
}
for _, source := range allSources {
@@ -256,7 +266,10 @@ verbose: true
if tt.name == "selective source loading" {
// Create loader with only defaults and global sources
loader = NewConfigurationLoaderWithOptions(ConfigurationOptions{
EnabledSources: []ConfigurationSource{SourceDefaults, SourceGlobal},
EnabledSources: []appconstants.ConfigurationSource{
appconstants.SourceDefaults,
appconstants.SourceGlobal,
},
})
} else {
loader = NewConfigurationLoader()
@@ -462,15 +475,15 @@ func TestConfigurationLoader_SourceManagement(t *testing.T) {
}
// Test disabling a source
loader.DisableSource(SourceGlobal)
if loader.sources[SourceGlobal] {
t.Error("expected SourceGlobal to be disabled")
loader.DisableSource(appconstants.SourceGlobal)
if loader.sources[appconstants.SourceGlobal] {
t.Error("expected appconstants.SourceGlobal to be disabled")
}
// Test enabling a source
loader.EnableSource(SourceCLIFlags)
if !loader.sources[SourceCLIFlags] {
t.Error("expected SourceCLIFlags to be enabled")
loader.EnableSource(appconstants.SourceCLIFlags)
if !loader.sources[appconstants.SourceCLIFlags] {
t.Error("expected appconstants.SourceCLIFlags to be enabled")
}
// Test updated sources list
@@ -484,17 +497,17 @@ func TestConfigurationLoader_SourceManagement(t *testing.T) {
func TestConfigurationSource_String(t *testing.T) {
t.Parallel()
tests := []struct {
source ConfigurationSource
source appconstants.ConfigurationSource
expected string
}{
{SourceDefaults, "defaults"},
{SourceGlobal, "global"},
{SourceRepoOverride, "repo-override"},
{SourceRepoConfig, "repo-config"},
{SourceActionConfig, "action-config"},
{SourceEnvironment, "environment"},
{SourceCLIFlags, "cli-flags"},
{ConfigurationSource(999), "unknown"},
{appconstants.SourceDefaults, "defaults"},
{appconstants.SourceGlobal, "global"},
{appconstants.SourceRepoOverride, "repo-override"},
{appconstants.SourceRepoConfig, "repo-config"},
{appconstants.SourceActionConfig, "action-config"},
{appconstants.SourceEnvironment, "environment"},
{appconstants.SourceCLIFlags, "cli-flags"},
{appconstants.ConfigurationSource(999), "unknown"},
}
for _, tt := range tests {

View File

@@ -1,109 +0,0 @@
// Package internal provides common constants used throughout the application.
package internal
// File extension constants.
const (
// ActionFileExtYML is the primary action file extension.
ActionFileExtYML = ".yml"
// ActionFileExtYAML is the alternative action file extension.
ActionFileExtYAML = ".yaml"
// ActionFileNameYML is the primary action file name.
ActionFileNameYML = "action.yml"
// ActionFileNameYAML is the alternative action file name.
ActionFileNameYAML = "action.yaml"
)
// File permission constants.
const (
// FilePermDefault is the default file permission for created files.
FilePermDefault = 0600
// FilePermTest is the file permission used in tests.
FilePermTest = 0600
)
// Configuration file constants.
const (
// ConfigFileName is the primary configuration file name.
ConfigFileName = "config"
// ConfigFileExtYAML is the configuration file extension.
ConfigFileExtYAML = ".yaml"
// ConfigFileNameFull is the full configuration file name.
ConfigFileNameFull = ConfigFileName + ConfigFileExtYAML
)
// Context key constants for maps and data structures.
const (
// ContextKeyError is used as a key for error information in context maps.
ContextKeyError = "error"
// ContextKeyTheme is used as a key for theme information.
ContextKeyTheme = "theme"
// ContextKeyConfig is used as a key for configuration information.
ContextKeyConfig = "config"
)
// Common string identifiers.
const (
// ThemeGitHub is the GitHub theme identifier.
ThemeGitHub = "github"
// ThemeGitLab is the GitLab theme identifier.
ThemeGitLab = "gitlab"
// ThemeMinimal is the minimal theme identifier.
ThemeMinimal = "minimal"
// ThemeProfessional is the professional theme identifier.
ThemeProfessional = "professional"
// ThemeDefault is the default theme identifier.
ThemeDefault = "default"
)
// Environment variable names.
const (
// EnvGitHubToken is the tool-specific GitHub token environment variable.
EnvGitHubToken = "GH_README_GITHUB_TOKEN" // #nosec G101 -- environment variable name, not a credential
// EnvGitHubTokenStandard is the standard GitHub token environment variable.
EnvGitHubTokenStandard = "GITHUB_TOKEN" // #nosec G101 -- environment variable name, not a credential
)
// Configuration keys and paths.
const (
// ConfigKeyGitHubToken is the configuration key for GitHub token.
ConfigKeyGitHubToken = "github_token"
// ConfigKeyTheme is the configuration key for theme.
ConfigKeyTheme = "theme"
// ConfigKeyOutputFormat is the configuration key for output format.
ConfigKeyOutputFormat = "output_format"
// ConfigKeyOutputDir is the configuration key for output directory.
ConfigKeyOutputDir = "output_dir"
// ConfigKeyVerbose is the configuration key for verbose mode.
ConfigKeyVerbose = "verbose"
// ConfigKeyQuiet is the configuration key for quiet mode.
ConfigKeyQuiet = "quiet"
// ConfigKeyAnalyzeDependencies is the configuration key for dependency analysis.
ConfigKeyAnalyzeDependencies = "analyze_dependencies"
// ConfigKeyShowSecurityInfo is the configuration key for security info display.
ConfigKeyShowSecurityInfo = "show_security_info"
)
// Template path constants.
const (
// TemplatePathDefault is the default template path.
TemplatePathDefault = "templates/readme.tmpl"
// TemplatePathGitHub is the GitHub theme template path.
TemplatePathGitHub = "templates/themes/github/readme.tmpl"
// TemplatePathGitLab is the GitLab theme template path.
TemplatePathGitLab = "templates/themes/gitlab/readme.tmpl"
// TemplatePathMinimal is the minimal theme template path.
TemplatePathMinimal = "templates/themes/minimal/readme.tmpl"
// TemplatePathProfessional is the professional theme template path.
TemplatePathProfessional = "templates/themes/professional/readme.tmpl"
)
// Config file search patterns.
const (
// ConfigFilePatternHidden is the primary hidden config file pattern.
ConfigFilePatternHidden = ".ghreadme.yaml"
// ConfigFilePatternConfig is the secondary config directory pattern.
ConfigFilePatternConfig = ".config/ghreadme.yaml"
// ConfigFilePatternGitHub is the GitHub ecosystem config pattern.
ConfigFilePatternGitHub = ".github/ghreadme.yaml"
)

View File

@@ -12,6 +12,7 @@ import (
"github.com/google/go-github/v74/github"
"github.com/ivuorinen/gh-action-readme/appconstants"
"github.com/ivuorinen/gh-action-readme/internal/git"
)
@@ -27,49 +28,6 @@ const (
BranchName VersionType = "branch"
// LocalPath represents a local file path reference.
LocalPath VersionType = "local"
// Common string constants.
compositeUsing = "composite"
updateTypeNone = "none"
updateTypeMajor = "major"
updateTypePatch = "patch"
updateTypeMinor = "minor"
defaultBranch = "main"
// Timeout constants.
apiCallTimeout = 10 * time.Second
cacheDefaultTTL = 1 * time.Hour
// File permission constants.
backupFilePerms = 0600
updatedFilePerms = 0600
// GitHub URL patterns.
githubBaseURL = "https://github.com"
marketplaceBaseURL = "https://github.com/marketplace/actions/"
// Version parsing constants.
fullSHALength = 40
minSHALength = 7
versionPartsCount = 3
// File path patterns.
dockerPrefix = "docker://"
localPathPrefix = "./"
localPathUpPrefix = "../"
// File extensions.
backupExtension = ".backup"
// Cache key prefixes.
cacheKeyLatest = "latest:"
cacheKeyRepo = "repo:"
// YAML structure constants.
usesFieldPrefix = "uses: "
// Special line estimation for script URLs.
scriptLineEstimate = 10
)
// Dependency represents a GitHub Action dependency with detailed information.
@@ -188,13 +146,16 @@ func (a *Analyzer) CheckOutdated(deps []Dependency) ([]OutdatedDependency, error
}
updateType := a.compareVersions(currentVersion, latestVersion)
if updateType != updateTypeNone {
if updateType != appconstants.UpdateTypeNone {
outdated = append(outdated, OutdatedDependency{
Current: dep,
LatestVersion: latestVersion,
LatestSHA: latestSHA,
UpdateType: updateType,
IsSecurityUpdate: updateType == updateTypeMajor, // Assume major updates might be security
Current: dep,
LatestVersion: latestVersion,
LatestSHA: latestSHA,
UpdateType: updateType,
// Don't assume major version bumps are security updates
// This should only be set if confirmed by security advisory data
// Future enhancement: integrate with GitHub Security Advisories API
IsSecurityUpdate: false,
})
}
}
@@ -252,7 +213,7 @@ func (a *Analyzer) validateAndCheckComposite(
action *ActionWithComposite,
progressCallback func(current, total int, message string),
) ([]Dependency, bool, error) {
if action.Runs.Using != compositeUsing {
if action.Runs.Using != appconstants.ActionTypeComposite {
if err := a.validateActionType(action.Runs.Using); err != nil {
return nil, false, err
}
@@ -336,13 +297,13 @@ func (a *Analyzer) analyzeActionDependency(step CompositeStep, _ int) (*Dependen
// Build dependency
dep := &Dependency{
Name: fmt.Sprintf("%s/%s", owner, repo),
Name: fmt.Sprintf(appconstants.URLPatternGitHubRepo, owner, repo),
Uses: step.Uses,
Version: version,
VersionType: versionType,
IsPinned: versionType == CommitSHA || (versionType == SemanticVersion && a.isVersionPinned(version)),
Author: owner,
SourceURL: fmt.Sprintf("%s/%s/%s", githubBaseURL, owner, repo),
SourceURL: fmt.Sprintf("%s/%s/%s", appconstants.GitHubBaseURL, owner, repo),
IsLocalAction: isLocal,
IsShellScript: false,
WithParams: a.convertWithParams(step.With),
@@ -350,7 +311,7 @@ func (a *Analyzer) analyzeActionDependency(step CompositeStep, _ int) (*Dependen
// Add marketplace URL for public actions
if !isLocal {
dep.MarketplaceURL = marketplaceBaseURL + repo
dep.MarketplaceURL = fmt.Sprintf("%s%s/%s", appconstants.MarketplaceBaseURL, owner, repo)
}
// Fetch additional metadata from GitHub API if available
@@ -375,11 +336,11 @@ func (a *Analyzer) analyzeShellScript(step CompositeStep, stepNumber int) *Depen
// This would ideally link to the specific line in the action.yml file
scriptURL = fmt.Sprintf(
"%s/%s/%s/blob/%s/action.yml#L%d",
githubBaseURL,
appconstants.GitHubBaseURL,
a.RepoInfo.Organization,
a.RepoInfo.Repository,
a.RepoInfo.DefaultBranch,
stepNumber*scriptLineEstimate,
stepNumber*appconstants.ScriptLineEstimate,
) // Rough estimate
}
@@ -408,11 +369,12 @@ func (a *Analyzer) parseUsesStatement(uses string) (owner, repo, version string,
// - ./local-action
// - docker://alpine:3.14
if strings.HasPrefix(uses, localPathPrefix) || strings.HasPrefix(uses, localPathUpPrefix) {
if strings.HasPrefix(uses, appconstants.LocalPathPrefix) ||
strings.HasPrefix(uses, appconstants.LocalPathUpPrefix) {
return "", "", uses, LocalPath
}
if strings.HasPrefix(uses, dockerPrefix) {
if strings.HasPrefix(uses, appconstants.DockerPrefix) {
return "", "", uses, LocalPath
}
@@ -443,9 +405,9 @@ func (a *Analyzer) parseUsesStatement(uses string) (owner, repo, version string,
// isCommitSHA checks if a version string is a commit SHA.
func (a *Analyzer) isCommitSHA(version string) bool {
// Check if it's a 40-character hex string (full SHA) or 7+ character hex (short SHA)
re := regexp.MustCompile(`^[a-f0-9]{7,40}$`)
re := regexp.MustCompile(appconstants.RegexGitSHA)
return len(version) >= minSHALength && re.MatchString(version)
return len(version) >= appconstants.MinSHALength && re.MatchString(version)
}
// isSemanticVersion checks if a version string follows semantic versioning.
@@ -460,7 +422,7 @@ func (a *Analyzer) isSemanticVersion(version string) bool {
func (a *Analyzer) isVersionPinned(version string) bool {
// Consider it pinned if it specifies patch version (v1.2.3) or is a commit SHA
// Also check for full commit SHAs (40 chars)
if len(version) == fullSHALength {
if len(version) == appconstants.FullSHALength {
return true
}
re := regexp.MustCompile(`^v?\d+\.\d+\.\d+`)
@@ -488,11 +450,11 @@ func (a *Analyzer) getLatestVersion(owner, repo string) (version, sha string, er
return "", "", errors.New("GitHub client not available")
}
ctx, cancel := context.WithTimeout(context.Background(), apiCallTimeout)
ctx, cancel := context.WithTimeout(context.Background(), appconstants.APICallTimeout)
defer cancel()
// Check cache first
cacheKey := cacheKeyLatest + fmt.Sprintf("%s/%s", owner, repo)
cacheKey := appconstants.CacheKeyLatest + fmt.Sprintf(appconstants.URLPatternGitHubRepo, owner, repo)
if version, sha, found := a.getCachedVersion(cacheKey); found {
return version, sha, nil
}
@@ -578,7 +540,7 @@ func (a *Analyzer) cacheVersion(cacheKey, version, sha string) {
}
versionInfo := map[string]string{"version": version, "sha": sha}
_ = a.Cache.SetWithTTL(cacheKey, versionInfo, cacheDefaultTTL)
_ = a.Cache.SetWithTTL(cacheKey, versionInfo, appconstants.CacheDefaultTTL)
}
// compareVersions compares two version strings and returns the update type.
@@ -587,12 +549,12 @@ func (a *Analyzer) compareVersions(current, latest string) string {
latestClean := strings.TrimPrefix(latest, "v")
if currentClean == latestClean {
return updateTypeNone
return appconstants.UpdateTypeNone
}
// Special case: floating major version (e.g., "4" -> "4.1.1") should be patch
if !strings.Contains(currentClean, ".") && strings.HasPrefix(latestClean, currentClean+".") {
return updateTypePatch
return appconstants.UpdateTypePatch
}
currentParts := a.parseVersionParts(currentClean)
@@ -605,7 +567,7 @@ func (a *Analyzer) compareVersions(current, latest string) string {
func (a *Analyzer) parseVersionParts(version string) []string {
parts := strings.Split(version, ".")
// For floating versions like "v4", treat as "v4.0.0" for comparison
for len(parts) < versionPartsCount {
for len(parts) < appconstants.VersionPartsCount {
parts = append(parts, "0")
}
@@ -615,16 +577,16 @@ func (a *Analyzer) parseVersionParts(version string) []string {
// determineUpdateType compares version parts and returns update type.
func (a *Analyzer) determineUpdateType(currentParts, latestParts []string) string {
if currentParts[0] != latestParts[0] {
return updateTypeMajor
return appconstants.UpdateTypeMajor
}
if currentParts[1] != latestParts[1] {
return updateTypeMinor
return appconstants.UpdateTypeMinor
}
if currentParts[2] != latestParts[2] {
return updateTypePatch
return appconstants.UpdateTypePatch
}
return updateTypeNone
return appconstants.UpdateTypeNone
}
// updateActionFile applies updates to a single action file.
@@ -636,8 +598,8 @@ func (a *Analyzer) updateActionFile(filePath string, updates []PinnedUpdate) err
}
// Create backup
backupPath := filePath + backupExtension
if err := os.WriteFile(backupPath, content, backupFilePerms); err != nil { // #nosec G306 -- backup file permissions
backupPath := filePath + appconstants.BackupExtension
if err := os.WriteFile(backupPath, content, appconstants.FilePermDefault); err != nil { // #nosec G306
return fmt.Errorf("failed to create backup: %w", err)
}
@@ -649,7 +611,7 @@ func (a *Analyzer) updateActionFile(filePath string, updates []PinnedUpdate) err
if strings.Contains(line, update.OldUses) {
// Replace the uses statement while preserving indentation
indent := strings.Repeat(" ", len(line)-len(strings.TrimLeft(line, " ")))
lines[i] = indent + usesFieldPrefix + update.NewUses
lines[i] = indent + appconstants.UsesFieldPrefix + update.NewUses
update.LineNumber = i + 1 // Store line number for reference
break
@@ -659,8 +621,7 @@ func (a *Analyzer) updateActionFile(filePath string, updates []PinnedUpdate) err
// Write updated content
updatedContent := strings.Join(lines, "\n")
if err := os.WriteFile(filePath, []byte(updatedContent), updatedFilePerms); err != nil {
// #nosec G306 -- updated file permissions
if err := os.WriteFile(filePath, []byte(updatedContent), appconstants.FilePermDefault); err != nil { // #nosec G306
return fmt.Errorf("failed to write updated file: %w", err)
}
@@ -689,11 +650,11 @@ func (a *Analyzer) validateActionFile(filePath string) error {
// enrichWithGitHubData fetches additional information from GitHub API.
func (a *Analyzer) enrichWithGitHubData(dep *Dependency, owner, repo string) error {
ctx, cancel := context.WithTimeout(context.Background(), apiCallTimeout)
ctx, cancel := context.WithTimeout(context.Background(), appconstants.APICallTimeout)
defer cancel()
// Check cache first
cacheKey := cacheKeyRepo + fmt.Sprintf("%s/%s", owner, repo)
cacheKey := appconstants.CacheKeyRepo + fmt.Sprintf("%s/%s", owner, repo)
if a.Cache != nil {
if cached, exists := a.Cache.Get(cacheKey); exists {
if repository, ok := cached.(*github.Repository); ok {
@@ -712,7 +673,7 @@ func (a *Analyzer) enrichWithGitHubData(dep *Dependency, owner, repo string) err
// Cache the result with 1 hour TTL
if a.Cache != nil {
_ = a.Cache.SetWithTTL(cacheKey, repository, cacheDefaultTTL) // Ignore cache errors
_ = a.Cache.SetWithTTL(cacheKey, repository, appconstants.CacheDefaultTTL) // Ignore cache errors
}
// Enrich dependency with API data

View File

@@ -10,6 +10,7 @@ import (
"github.com/google/go-github/v74/github"
"github.com/ivuorinen/gh-action-readme/appconstants"
"github.com/ivuorinen/gh-action-readme/internal/cache"
"github.com/ivuorinen/gh-action-readme/internal/git"
"github.com/ivuorinen/gh-action-readme/testutil"
@@ -28,14 +29,14 @@ func TestAnalyzer_AnalyzeActionFile(t *testing.T) {
}{
{
name: "simple action - no dependencies",
actionYML: testutil.MustReadFixture("actions/javascript/simple.yml"),
actionYML: testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple),
expectError: false,
expectDeps: false,
expectedLen: 0,
},
{
name: "composite action with dependencies",
actionYML: testutil.MustReadFixture("actions/composite/with-dependencies.yml"),
actionYML: testutil.MustReadFixture(appconstants.TestFixtureCompositeWithDeps),
expectError: false,
expectDeps: true,
expectedLen: 5, // 3 action dependencies + 2 shell script dependencies
@@ -43,14 +44,14 @@ func TestAnalyzer_AnalyzeActionFile(t *testing.T) {
},
{
name: "docker action - no step dependencies",
actionYML: testutil.MustReadFixture("actions/docker/basic.yml"),
actionYML: testutil.MustReadFixture(appconstants.TestFixtureDockerBasic),
expectError: false,
expectDeps: false,
expectedLen: 0,
},
{
name: "invalid action file",
actionYML: testutil.MustReadFixture("actions/invalid/invalid-using.yml"),
actionYML: testutil.MustReadFixture(appconstants.TestFixtureInvalidInvalidUsing),
expectError: true,
},
{
@@ -70,7 +71,7 @@ func TestAnalyzer_AnalyzeActionFile(t *testing.T) {
tmpDir, cleanup := testutil.TempDir(t)
defer cleanup()
actionPath := filepath.Join(tmpDir, "action.yml")
actionPath := filepath.Join(tmpDir, appconstants.TestPathActionYML)
testutil.WriteTestFile(t, actionPath, tt.actionYML)
// Create analyzer with mock GitHub client
@@ -429,9 +430,9 @@ func TestAnalyzer_GeneratePinnedUpdate(t *testing.T) {
defer cleanup()
// Create a test action file with composite steps
actionContent := testutil.MustReadFixture("test-composite-action.yml")
actionContent := testutil.MustReadFixture(appconstants.TestFixtureTestCompositeAction)
actionPath := filepath.Join(tmpDir, "action.yml")
actionPath := filepath.Join(tmpDir, appconstants.TestPathActionYML)
testutil.WriteTestFile(t, actionPath, actionContent)
// Create analyzer
@@ -550,8 +551,8 @@ func TestAnalyzer_WithoutGitHubClient(t *testing.T) {
tmpDir, cleanup := testutil.TempDir(t)
defer cleanup()
actionPath := filepath.Join(tmpDir, "action.yml")
testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture("actions/composite/basic.yml"))
actionPath := filepath.Join(tmpDir, appconstants.TestPathActionYML)
testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture(appconstants.TestFixtureCompositeBasic))
deps, err := analyzer.AnalyzeActionFile(actionPath)
@@ -586,7 +587,7 @@ func TestNewAnalyzer(t *testing.T) {
githubClient := testutil.MockGitHubClient(mockResponses)
cacheInstance, err := cache.NewCache(cache.DefaultConfig())
testutil.AssertNoError(t, err)
defer func() { _ = cacheInstance.Close() }()
defer testutil.CleanupCache(t, cacheInstance)()
repoInfo := git.RepoInfo{
Organization: "test-owner",

View File

@@ -5,6 +5,8 @@ import (
"os"
"github.com/goccy/go-yaml"
"github.com/ivuorinen/gh-action-readme/appconstants"
)
// parseCompositeActionFromFile reads and parses a composite action file.
@@ -33,7 +35,7 @@ func (a *Analyzer) parseCompositeAction(actionPath string) (*ActionWithComposite
}
// If this is not a composite action, return empty steps
if action.Runs.Using != compositeUsing {
if action.Runs.Using != appconstants.ActionTypeComposite {
action.Runs.Steps = []CompositeStep{}
}
@@ -47,5 +49,5 @@ func IsCompositeAction(actionPath string) (bool, error) {
return false, err
}
return action.Runs.Using == compositeUsing, nil
return action.Runs.Using == appconstants.ActionTypeComposite, nil
}

View File

@@ -2,27 +2,12 @@
package internal
import (
"errors"
"os"
"strings"
"github.com/ivuorinen/gh-action-readme/internal/errors"
)
// Error detection constants for automatic error code determination.
const (
// File system error patterns.
errorPatternFileNotFound = "no such file or directory"
errorPatternPermission = "permission denied"
// Content format error patterns.
errorPatternYAML = "yaml"
// Service-specific error patterns.
errorPatternGitHub = "github"
errorPatternConfig = "config"
// Exit code constants.
exitCodeError = 1
"github.com/ivuorinen/gh-action-readme/appconstants"
"github.com/ivuorinen/gh-action-readme/internal/apperrors"
)
// ErrorHandler provides centralized error handling and exit management.
@@ -38,17 +23,17 @@ func NewErrorHandler(output *ColoredOutput) *ErrorHandler {
}
// HandleError handles contextual errors and exits with appropriate code.
func (eh *ErrorHandler) HandleError(err *errors.ContextualError) {
func (eh *ErrorHandler) HandleError(err *apperrors.ContextualError) {
eh.output.ErrorWithSuggestions(err)
os.Exit(exitCodeError)
os.Exit(appconstants.ExitCodeError)
}
// HandleFatalError handles fatal errors with contextual information.
func (eh *ErrorHandler) HandleFatalError(code errors.ErrorCode, message string, context map[string]string) {
suggestions := errors.GetSuggestions(code, context)
helpURL := errors.GetHelpURL(code)
func (eh *ErrorHandler) HandleFatalError(code appconstants.ErrorCode, message string, context map[string]string) {
suggestions := apperrors.GetSuggestions(code, context)
helpURL := apperrors.GetHelpURL(code)
contextualErr := errors.New(code, message).
contextualErr := apperrors.New(code, message).
WithSuggestions(suggestions...).
WithHelpURL(helpURL)
@@ -61,12 +46,12 @@ func (eh *ErrorHandler) HandleFatalError(code errors.ErrorCode, message string,
// HandleSimpleError handles simple errors with automatic context detection.
func (eh *ErrorHandler) HandleSimpleError(message string, err error) {
code := errors.ErrCodeUnknown
code := appconstants.ErrCodeUnknown
context := make(map[string]string)
// Try to determine appropriate error code based on error content
if err != nil {
context[ContextKeyError] = err.Error()
context[appconstants.ContextKeyError] = err.Error()
code = eh.determineErrorCode(err)
}
@@ -74,22 +59,52 @@ func (eh *ErrorHandler) HandleSimpleError(message string, err error) {
}
// determineErrorCode attempts to determine appropriate error code from error content.
func (eh *ErrorHandler) determineErrorCode(err error) errors.ErrorCode {
errStr := err.Error()
func (eh *ErrorHandler) determineErrorCode(err error) appconstants.ErrorCode {
// First try typed error checks using errors.Is against sentinel errors
if code := eh.checkTypedError(err); code != appconstants.ErrCodeUnknown {
return code
}
// Fallback to string checks only if no typed match found
return eh.checkStringPatterns(err.Error())
}
// checkTypedError checks for typed errors using errors.Is.
func (eh *ErrorHandler) checkTypedError(err error) appconstants.ErrorCode {
if errors.Is(err, apperrors.ErrFileNotFound) || errors.Is(err, os.ErrNotExist) {
return appconstants.ErrCodeFileNotFound
}
if errors.Is(err, apperrors.ErrPermissionDenied) || errors.Is(err, os.ErrPermission) {
return appconstants.ErrCodePermission
}
if errors.Is(err, apperrors.ErrInvalidYAML) {
return appconstants.ErrCodeInvalidYAML
}
if errors.Is(err, apperrors.ErrGitHubAPI) {
return appconstants.ErrCodeGitHubAPI
}
if errors.Is(err, apperrors.ErrConfiguration) {
return appconstants.ErrCodeConfiguration
}
return appconstants.ErrCodeUnknown
}
// checkStringPatterns checks error message against string patterns.
func (eh *ErrorHandler) checkStringPatterns(errStr string) appconstants.ErrorCode {
switch {
case contains(errStr, errorPatternFileNotFound):
return errors.ErrCodeFileNotFound
case contains(errStr, errorPatternPermission):
return errors.ErrCodePermission
case contains(errStr, errorPatternYAML):
return errors.ErrCodeInvalidYAML
case contains(errStr, errorPatternGitHub):
return errors.ErrCodeGitHubAPI
case contains(errStr, errorPatternConfig):
return errors.ErrCodeConfiguration
case contains(errStr, appconstants.ErrorPatternFileNotFound):
return appconstants.ErrCodeFileNotFound
case contains(errStr, appconstants.ErrorPatternPermission):
return appconstants.ErrCodePermission
case contains(errStr, appconstants.ErrorPatternYAML):
return appconstants.ErrCodeInvalidYAML
case contains(errStr, appconstants.ErrorPatternGitHub):
return appconstants.ErrCodeGitHubAPI
case contains(errStr, appconstants.ErrorPatternConfig):
return appconstants.ErrCodeConfiguration
default:
return errors.ErrCodeUnknown
return appconstants.ErrCodeUnknown
}
}

View File

@@ -4,7 +4,8 @@ package internal
import (
"fmt"
"github.com/ivuorinen/gh-action-readme/internal/errors"
"github.com/ivuorinen/gh-action-readme/appconstants"
"github.com/ivuorinen/gh-action-readme/internal/apperrors"
)
// SimpleLogger demonstrates a component that only needs basic message logging.
@@ -50,7 +51,7 @@ func (fem *FocusedErrorManager) HandleValidationError(file string, missingFields
}
fem.manager.ErrorWithContext(
errors.ErrCodeValidation,
appconstants.ErrCodeValidation,
"Validation failed for "+file,
context,
)
@@ -138,7 +139,7 @@ func (vc *ValidationComponent) ValidateAndReport(item string, isValid bool, err
}
if err != nil {
if contextualErr, ok := err.(*errors.ContextualError); ok {
if contextualErr, ok := err.(*apperrors.ContextualError); ok {
vc.errorManager.ErrorWithSuggestions(contextualErr)
} else {
vc.errorManager.Error("Validation failed for %s: %v", item, err)

View File

@@ -12,20 +12,12 @@ import (
"github.com/google/go-github/v74/github"
"github.com/schollz/progressbar/v3"
"github.com/ivuorinen/gh-action-readme/appconstants"
"github.com/ivuorinen/gh-action-readme/internal/cache"
"github.com/ivuorinen/gh-action-readme/internal/dependencies"
errCodes "github.com/ivuorinen/gh-action-readme/internal/errors"
"github.com/ivuorinen/gh-action-readme/internal/git"
)
// Output format constants.
const (
OutputFormatHTML = "html"
OutputFormatMD = "md"
OutputFormatJSON = "json"
OutputFormatASCIIDoc = "asciidoc"
)
// Generator orchestrates the documentation generation process.
// It uses focused interfaces to reduce coupling and improve testability.
type Generator struct {
@@ -174,13 +166,13 @@ func (g *Generator) DiscoverActionFilesWithValidation(dir string, recursive bool
actionFiles, err := g.DiscoverActionFiles(dir, recursive)
if err != nil {
g.Output.ErrorWithContext(
errCodes.ErrCodeFileNotFound,
appconstants.ErrCodeFileNotFound,
"failed to discover action files for "+context,
map[string]string{
"directory": dir,
"recursive": strconv.FormatBool(recursive),
"context": context,
ContextKeyError: err.Error(),
"directory": dir,
"recursive": strconv.FormatBool(recursive),
"context": context,
appconstants.ContextKeyError: err.Error(),
},
)
@@ -191,7 +183,7 @@ func (g *Generator) DiscoverActionFilesWithValidation(dir string, recursive bool
if len(actionFiles) == 0 {
contextMsg := "no GitHub Action files found for " + context
g.Output.ErrorWithContext(
errCodes.ErrCodeNoActionFiles,
appconstants.ErrCodeNoActionFiles,
contextMsg,
map[string]string{
"directory": dir,
@@ -257,32 +249,57 @@ func (g *Generator) ValidateFiles(paths []string) error {
return nil
}
// generateMarkdown creates a README.md file using the template.
func (g *Generator) generateMarkdown(action *ActionYML, outputDir, actionPath string) error {
// Use theme-based template if theme is specified, otherwise use explicit template path
templatePath := g.Config.Template
// resolveTemplatePathForFormat determines the correct template path
// based on the configured theme or custom template path.
// If a theme is specified, it takes precedence over the template path.
func (g *Generator) resolveTemplatePathForFormat() string {
if g.Config.Theme != "" {
templatePath = resolveThemeTemplate(g.Config.Theme)
return resolveThemeTemplate(g.Config.Theme)
}
opts := TemplateOptions{
TemplatePath: templatePath,
Format: "md",
}
return g.Config.Template
}
// renderTemplateForAction builds template data and renders it using the specified options.
// It finds the repository root for git information, builds comprehensive template data,
// and renders the template. Returns the rendered content or an error.
func (g *Generator) renderTemplateForAction(
action *ActionYML,
outputDir string,
actionPath string,
opts TemplateOptions,
) (string, error) {
// Find repository root for git information
repoRoot, _ := git.FindRepositoryRoot(outputDir)
// Build comprehensive template data
templateData := BuildTemplateData(action, g.Config, repoRoot, actionPath)
// Render template with data
content, err := RenderReadme(templateData, opts)
if err != nil {
return "", fmt.Errorf("failed to render template: %w", err)
}
return content, nil
}
// generateMarkdown creates a README.md file using the template.
func (g *Generator) generateMarkdown(action *ActionYML, outputDir, actionPath string) error {
templatePath := g.resolveTemplatePathForFormat()
opts := TemplateOptions{
TemplatePath: templatePath,
Format: "md",
}
content, err := g.renderTemplateForAction(action, outputDir, actionPath, opts)
if err != nil {
return fmt.Errorf("failed to render markdown template: %w", err)
}
outputPath := g.resolveOutputPath(outputDir, "README.md")
if err := os.WriteFile(outputPath, []byte(content), FilePermDefault); err != nil {
outputPath := g.resolveOutputPath(outputDir, appconstants.ReadmeMarkdown)
if err := os.WriteFile(outputPath, []byte(content), appconstants.FilePermDefault); err != nil {
// #nosec G306 -- output file permissions
return fmt.Errorf("failed to write README.md to %s: %w", outputPath, err)
}
@@ -294,11 +311,7 @@ func (g *Generator) generateMarkdown(action *ActionYML, outputDir, actionPath st
// generateHTML creates an HTML file using the template and optional header/footer.
func (g *Generator) generateHTML(action *ActionYML, outputDir, actionPath string) error {
// Use theme-based template if theme is specified, otherwise use explicit template path
templatePath := g.Config.Template
if g.Config.Theme != "" {
templatePath = resolveThemeTemplate(g.Config.Theme)
}
templatePath := g.resolveTemplatePathForFormat()
opts := TemplateOptions{
TemplatePath: templatePath,
@@ -307,13 +320,7 @@ func (g *Generator) generateHTML(action *ActionYML, outputDir, actionPath string
Format: "html",
}
// Find repository root for git information
repoRoot, _ := git.FindRepositoryRoot(outputDir)
// Build comprehensive template data
templateData := BuildTemplateData(action, g.Config, repoRoot, actionPath)
content, err := RenderReadme(templateData, opts)
content, err := g.renderTemplateForAction(action, outputDir, actionPath, opts)
if err != nil {
return fmt.Errorf("failed to render HTML template: %w", err)
}
@@ -339,7 +346,7 @@ func (g *Generator) generateHTML(action *ActionYML, outputDir, actionPath string
func (g *Generator) generateJSON(action *ActionYML, outputDir string) error {
writer := NewJSONWriter(g.Config)
outputPath := g.resolveOutputPath(outputDir, "action-docs.json")
outputPath := g.resolveOutputPath(outputDir, appconstants.ActionDocsJSON)
if err := writer.Write(action, outputPath); err != nil {
return fmt.Errorf("failed to write JSON to %s: %w", outputPath, err)
}
@@ -351,27 +358,20 @@ func (g *Generator) generateJSON(action *ActionYML, outputDir string) error {
// generateASCIIDoc creates an AsciiDoc file using the template.
func (g *Generator) generateASCIIDoc(action *ActionYML, outputDir, actionPath string) error {
// Use AsciiDoc template
templatePath := resolveTemplatePath("templates/themes/asciidoc/readme.adoc")
templatePath := g.resolveTemplatePathForFormat()
opts := TemplateOptions{
TemplatePath: templatePath,
Format: "asciidoc",
}
// Find repository root for git information
repoRoot, _ := git.FindRepositoryRoot(outputDir)
// Build comprehensive template data
templateData := BuildTemplateData(action, g.Config, repoRoot, actionPath)
content, err := RenderReadme(templateData, opts)
content, err := g.renderTemplateForAction(action, outputDir, actionPath, opts)
if err != nil {
return fmt.Errorf("failed to render AsciiDoc template: %w", err)
}
outputPath := g.resolveOutputPath(outputDir, "README.adoc")
if err := os.WriteFile(outputPath, []byte(content), FilePermDefault); err != nil {
outputPath := g.resolveOutputPath(outputDir, appconstants.ReadmeASCIIDoc)
if err := os.WriteFile(outputPath, []byte(content), appconstants.FilePermDefault); err != nil {
// #nosec G306 -- output file permissions
return fmt.Errorf("failed to write AsciiDoc to %s: %w", outputPath, err)
}
@@ -431,7 +431,8 @@ func (g *Generator) parseAndValidateAction(actionPath string) (*ActionYML, error
// Check for critical validation errors that cannot be fixed with defaults
for _, field := range validationResult.MissingFields {
// All core required fields should cause validation failure
if field == "name" || field == "description" || field == "runs" || field == "runs.using" {
if field == appconstants.FieldName || field == appconstants.FieldDescription ||
field == appconstants.FieldRuns || field == appconstants.FieldRunsUsing {
// Required fields missing - cannot be fixed with defaults, must fail
return nil, fmt.Errorf(
"action file %s has invalid configuration, missing required field(s): %v",
@@ -478,13 +479,13 @@ func (g *Generator) resolveOutputPath(outputDir, defaultFilename string) string
// generateByFormat generates documentation in the specified format.
func (g *Generator) generateByFormat(action *ActionYML, outputDir, actionPath string) error {
switch g.Config.OutputFormat {
case "md":
case appconstants.OutputFormatMarkdown:
return g.generateMarkdown(action, outputDir, actionPath)
case OutputFormatHTML:
case appconstants.OutputFormatHTML:
return g.generateHTML(action, outputDir, actionPath)
case OutputFormatJSON:
case appconstants.OutputFormatJSON:
return g.generateJSON(action, outputDir)
case OutputFormatASCIIDoc:
case appconstants.OutputFormatASCIIDoc:
return g.generateASCIIDoc(action, outputDir, actionPath)
default:
return fmt.Errorf("unsupported output format: %s", g.Config.OutputFormat)

View File

@@ -6,6 +6,7 @@ import (
"strings"
"testing"
"github.com/ivuorinen/gh-action-readme/appconstants"
"github.com/ivuorinen/gh-action-readme/testutil"
)
@@ -47,9 +48,7 @@ func TestGenerator_DiscoverActionFiles(t *testing.T) {
name: "single action.yml in root",
setupFunc: func(t *testing.T, tmpDir string) {
t.Helper()
fixture, err := testutil.LoadActionFixture("actions/javascript/simple.yml")
testutil.AssertNoError(t, err)
testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), fixture.Content)
testutil.WriteActionFixture(t, tmpDir, appconstants.TestFixtureJavaScriptSimple)
},
recursive: false,
expectedLen: 1,
@@ -58,9 +57,12 @@ func TestGenerator_DiscoverActionFiles(t *testing.T) {
name: "action.yaml variant",
setupFunc: func(t *testing.T, tmpDir string) {
t.Helper()
fixture, err := testutil.LoadActionFixture("actions/javascript/simple.yml")
testutil.AssertNoError(t, err)
testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yaml"), fixture.Content)
testutil.WriteActionFixtureAs(
t,
tmpDir,
appconstants.TestPathActionYAML,
appconstants.TestFixtureJavaScriptSimple,
)
},
recursive: false,
expectedLen: 1,
@@ -69,12 +71,13 @@ func TestGenerator_DiscoverActionFiles(t *testing.T) {
name: "both yml and yaml files",
setupFunc: func(t *testing.T, tmpDir string) {
t.Helper()
simpleFixture, err := testutil.LoadActionFixture("actions/javascript/simple.yml")
testutil.AssertNoError(t, err)
minimalFixture, err := testutil.LoadActionFixture("minimal-action.yml")
testutil.AssertNoError(t, err)
testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), simpleFixture.Content)
testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yaml"), minimalFixture.Content)
testutil.WriteActionFixture(t, tmpDir, appconstants.TestFixtureJavaScriptSimple)
testutil.WriteActionFixtureAs(
t,
tmpDir,
appconstants.TestPathActionYAML,
appconstants.TestFixtureMinimalAction,
)
},
recursive: false,
expectedLen: 2,
@@ -83,14 +86,13 @@ func TestGenerator_DiscoverActionFiles(t *testing.T) {
name: "recursive discovery",
setupFunc: func(t *testing.T, tmpDir string) {
t.Helper()
simpleFixture, err := testutil.LoadActionFixture("actions/javascript/simple.yml")
testutil.AssertNoError(t, err)
compositeFixture, err := testutil.LoadActionFixture("actions/composite/basic.yml")
testutil.AssertNoError(t, err)
testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), simpleFixture.Content)
subDir := filepath.Join(tmpDir, "subdir")
_ = os.MkdirAll(subDir, 0750) // #nosec G301 -- test directory permissions
testutil.WriteTestFile(t, filepath.Join(subDir, "action.yml"), compositeFixture.Content)
testutil.WriteActionFixture(t, tmpDir, appconstants.TestFixtureJavaScriptSimple)
testutil.CreateActionSubdir(
t,
tmpDir,
appconstants.TestDirSubdir,
appconstants.TestFixtureCompositeBasic,
)
},
recursive: true,
expectedLen: 2,
@@ -99,14 +101,13 @@ func TestGenerator_DiscoverActionFiles(t *testing.T) {
name: "non-recursive skips subdirectories",
setupFunc: func(t *testing.T, tmpDir string) {
t.Helper()
simpleFixture, err := testutil.LoadActionFixture("actions/javascript/simple.yml")
testutil.AssertNoError(t, err)
compositeFixture, err := testutil.LoadActionFixture("actions/composite/basic.yml")
testutil.AssertNoError(t, err)
testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), simpleFixture.Content)
subDir := filepath.Join(tmpDir, "subdir")
_ = os.MkdirAll(subDir, 0750) // #nosec G301 -- test directory permissions
testutil.WriteTestFile(t, filepath.Join(subDir, "action.yml"), compositeFixture.Content)
testutil.WriteActionFixture(t, tmpDir, appconstants.TestFixtureJavaScriptSimple)
testutil.CreateActionSubdir(
t,
tmpDir,
appconstants.TestDirSubdir,
appconstants.TestFixtureCompositeBasic,
)
},
recursive: false,
expectedLen: 1,
@@ -157,11 +158,10 @@ func TestGenerator_DiscoverActionFiles(t *testing.T) {
// Verify all returned files exist and are action files
for _, file := range files {
if _, err := os.Stat(file); os.IsNotExist(err) {
t.Errorf("discovered file does not exist: %s", file)
}
testutil.AssertFileExists(t, file)
if !strings.HasSuffix(file, "action.yml") && !strings.HasSuffix(file, "action.yaml") {
if !strings.HasSuffix(file, appconstants.TestPathActionYML) &&
!strings.HasSuffix(file, appconstants.TestPathActionYAML) {
t.Errorf("discovered file is not an action file: %s", file)
}
}
@@ -180,21 +180,21 @@ func TestGenerator_GenerateFromFile(t *testing.T) {
}{
{
name: "simple action to markdown",
actionYML: testutil.MustReadFixture("actions/javascript/simple.yml"),
actionYML: testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple),
outputFormat: "md",
expectError: false,
contains: []string{"# Simple JavaScript Action", "A simple JavaScript action for testing"},
},
{
name: "composite action to markdown",
actionYML: testutil.MustReadFixture("actions/composite/basic.yml"),
actionYML: testutil.MustReadFixture(appconstants.TestFixtureCompositeBasic),
outputFormat: "md",
expectError: false,
contains: []string{"# Basic Composite Action", "A simple composite action with basic steps"},
},
{
name: "action to HTML",
actionYML: testutil.MustReadFixture("actions/javascript/simple.yml"),
actionYML: testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple),
outputFormat: "html",
expectError: false,
contains: []string{
@@ -204,7 +204,7 @@ func TestGenerator_GenerateFromFile(t *testing.T) {
},
{
name: "action to JSON",
actionYML: testutil.MustReadFixture("actions/javascript/simple.yml"),
actionYML: testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple),
outputFormat: "json",
expectError: false,
contains: []string{
@@ -214,14 +214,14 @@ func TestGenerator_GenerateFromFile(t *testing.T) {
},
{
name: "invalid action file",
actionYML: testutil.MustReadFixture("actions/invalid/invalid-using.yml"),
actionYML: testutil.MustReadFixture(appconstants.TestFixtureInvalidInvalidUsing),
outputFormat: "md",
expectError: true, // Invalid runtime configuration should cause failure
contains: []string{},
},
{
name: "unknown output format",
actionYML: testutil.MustReadFixture("actions/javascript/simple.yml"),
actionYML: testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple),
outputFormat: "unknown",
expectError: true,
},
@@ -237,7 +237,7 @@ func TestGenerator_GenerateFromFile(t *testing.T) {
testutil.SetupTestTemplates(t, tmpDir)
// Write action file
actionPath := filepath.Join(tmpDir, "action.yml")
actionPath := filepath.Join(tmpDir, appconstants.TestPathActionYML)
testutil.WriteTestFile(t, actionPath, tt.actionYML)
// Create generator with explicit template path
@@ -338,21 +338,14 @@ func TestGenerator_ProcessBatch(t *testing.T) {
setupFunc: func(t *testing.T, tmpDir string) []string {
t.Helper()
// Create separate directories for each action
dir1 := filepath.Join(tmpDir, "action1")
dir2 := filepath.Join(tmpDir, "action2")
if err := os.MkdirAll(dir1, 0750); err != nil { // #nosec G301 -- test directory permissions
t.Fatalf("failed to create dir1: %v", err)
}
if err := os.MkdirAll(dir2, 0750); err != nil { // #nosec G301 -- test directory permissions
t.Fatalf("failed to create dir2: %v", err)
}
dirs := createTestDirs(t, tmpDir, "action1", "action2")
files := []string{
filepath.Join(dir1, "action.yml"),
filepath.Join(dir2, "action.yml"),
filepath.Join(dirs[0], appconstants.TestPathActionYML),
filepath.Join(dirs[1], appconstants.TestPathActionYML),
}
testutil.WriteTestFile(t, files[0], testutil.MustReadFixture("actions/javascript/simple.yml"))
testutil.WriteTestFile(t, files[1], testutil.MustReadFixture("actions/composite/basic.yml"))
testutil.WriteTestFile(t, files[0], testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple))
testutil.WriteTestFile(t, files[1], testutil.MustReadFixture(appconstants.TestFixtureCompositeBasic))
return files
},
@@ -364,21 +357,18 @@ func TestGenerator_ProcessBatch(t *testing.T) {
setupFunc: func(t *testing.T, tmpDir string) []string {
t.Helper()
// Create separate directories for mixed test too
dir1 := filepath.Join(tmpDir, "valid-action")
dir2 := filepath.Join(tmpDir, "invalid-action")
if err := os.MkdirAll(dir1, 0750); err != nil { // #nosec G301 -- test directory permissions
t.Fatalf("failed to create dir1: %v", err)
}
if err := os.MkdirAll(dir2, 0750); err != nil { // #nosec G301 -- test directory permissions
t.Fatalf("failed to create dir2: %v", err)
}
dirs := createTestDirs(t, tmpDir, "valid-action", "invalid-action")
files := []string{
filepath.Join(dir1, "action.yml"),
filepath.Join(dir2, "action.yml"),
filepath.Join(dirs[0], appconstants.TestPathActionYML),
filepath.Join(dirs[1], appconstants.TestPathActionYML),
}
testutil.WriteTestFile(t, files[0], testutil.MustReadFixture("actions/javascript/simple.yml"))
testutil.WriteTestFile(t, files[1], testutil.MustReadFixture("actions/invalid/invalid-using.yml"))
testutil.WriteTestFile(t, files[0], testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple))
testutil.WriteTestFile(
t,
files[1],
testutil.MustReadFixture(appconstants.TestFixtureInvalidInvalidUsing),
)
return files
},
@@ -462,8 +452,8 @@ func TestGenerator_ValidateFiles(t *testing.T) {
filepath.Join(tmpDir, "action1.yml"),
filepath.Join(tmpDir, "action2.yml"),
}
testutil.WriteTestFile(t, files[0], testutil.MustReadFixture("actions/javascript/simple.yml"))
testutil.WriteTestFile(t, files[1], testutil.MustReadFixture("minimal-action.yml"))
testutil.WriteTestFile(t, files[0], testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple))
testutil.WriteTestFile(t, files[1], testutil.MustReadFixture(appconstants.TestFixtureMinimalAction))
return files
},
@@ -477,8 +467,12 @@ func TestGenerator_ValidateFiles(t *testing.T) {
filepath.Join(tmpDir, "valid.yml"),
filepath.Join(tmpDir, "invalid.yml"),
}
testutil.WriteTestFile(t, files[0], testutil.MustReadFixture("actions/javascript/simple.yml"))
testutil.WriteTestFile(t, files[1], testutil.MustReadFixture("actions/invalid/missing-description.yml"))
testutil.WriteTestFile(t, files[0], testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple))
testutil.WriteTestFile(
t,
files[1],
testutil.MustReadFixture(appconstants.TestFixtureInvalidMissingDescription),
)
return files
},
@@ -573,8 +567,8 @@ func TestGenerator_WithDifferentThemes(t *testing.T) {
// Set up test templates for this theme test
testutil.SetupTestTemplates(t, tmpDir)
actionPath := filepath.Join(tmpDir, "action.yml")
testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture("actions/javascript/simple.yml"))
actionPath := filepath.Join(tmpDir, appconstants.TestPathActionYML)
testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple))
config := &AppConfig{
Theme: theme,
@@ -617,8 +611,12 @@ func TestGenerator_ErrorHandling(t *testing.T) {
Quiet: true,
}
generator := NewGenerator(config)
actionPath := filepath.Join(tmpDir, "action.yml")
testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture("actions/javascript/simple.yml"))
actionPath := filepath.Join(tmpDir, appconstants.TestPathActionYML)
testutil.WriteTestFile(
t,
actionPath,
testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple),
)
return generator, actionPath
},
@@ -642,8 +640,12 @@ func TestGenerator_ErrorHandling(t *testing.T) {
Template: filepath.Join(tmpDir, "templates", "readme.tmpl"),
}
generator := NewGenerator(config)
actionPath := filepath.Join(tmpDir, "action.yml")
testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture("actions/javascript/simple.yml"))
actionPath := filepath.Join(tmpDir, appconstants.TestPathActionYML)
testutil.WriteTestFile(
t,
actionPath,
testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple),
)
return generator, actionPath
},
@@ -667,3 +669,19 @@ func TestGenerator_ErrorHandling(t *testing.T) {
})
}
}
// createTestDirs is a helper that creates multiple directories within tmpDir for testing.
// Returns the full paths of all created directories.
func createTestDirs(t *testing.T, tmpDir string, names ...string) []string {
t.Helper()
dirs := make([]string, len(names))
for i, name := range names {
dirPath := filepath.Join(tmpDir, name)
if err := os.MkdirAll(dirPath, 0750); err != nil { // #nosec G301 -- test directory permissions
t.Fatalf("failed to create directory %s: %v", name, err)
}
dirs[i] = dirPath
}
return dirs
}

View File

@@ -10,11 +10,8 @@ import (
"path/filepath"
"regexp"
"strings"
)
const (
// DefaultBranch is the default branch name used as fallback.
DefaultBranch = "main"
"github.com/ivuorinen/gh-action-readme/appconstants"
)
// RepoInfo contains information about a Git repository.
@@ -29,7 +26,7 @@ type RepoInfo struct {
// GetRepositoryName returns the full repository name in org/repo format.
func (r *RepoInfo) GetRepositoryName() string {
if r.Organization != "" && r.Repository != "" {
return fmt.Sprintf("%s/%s", r.Organization, r.Repository)
return fmt.Sprintf(appconstants.URLPatternGitHubRepo, r.Organization, r.Repository)
}
return ""
@@ -44,7 +41,7 @@ func FindRepositoryRoot(startPath string) (string, error) {
// Walk up the directory tree looking for .git
for {
gitPath := filepath.Join(absPath, ".git")
gitPath := filepath.Join(absPath, appconstants.DirGit)
if _, err := os.Stat(gitPath); err == nil {
return absPath, nil
}
@@ -65,7 +62,7 @@ func DetectRepository(repoRoot string) (*RepoInfo, error) {
}
// Check if this is actually a git repository
gitPath := filepath.Join(repoRoot, ".git")
gitPath := filepath.Join(repoRoot, appconstants.DirGit)
if _, err := os.Stat(gitPath); os.IsNotExist(err) {
return &RepoInfo{IsGitRepo: false}, nil
}
@@ -100,7 +97,12 @@ func getRemoteURL(repoRoot string) (string, error) {
// getRemoteURLFromGit uses git command to get remote URL.
func getRemoteURLFromGit(repoRoot string) (string, error) {
cmd := exec.Command("git", "remote", "get-url", "origin")
cmd := exec.Command(
appconstants.GitCommand,
"remote",
"get-url",
"origin",
) // #nosec G204 -- git command is a constant
cmd.Dir = repoRoot
output, err := cmd.Output()
@@ -113,7 +115,7 @@ func getRemoteURLFromGit(repoRoot string) (string, error) {
// getRemoteURLFromConfig parses .git/config to extract remote URL.
func getRemoteURLFromConfig(repoRoot string) (string, error) {
configPath := filepath.Join(repoRoot, ".git", "config")
configPath := filepath.Join(repoRoot, appconstants.DirGit, appconstants.ConfigFileName)
file, err := os.Open(configPath) // #nosec G304 -- git config path constructed from repo root
if err != nil {
return "", fmt.Errorf("failed to open git config: %w", err)
@@ -143,8 +145,8 @@ func getRemoteURLFromConfig(repoRoot string) (string, error) {
}
// Look for url = in origin section
if inOriginSection && strings.HasPrefix(line, "url = ") {
return strings.TrimPrefix(line, "url = "), nil
if inOriginSection && strings.HasPrefix(line, appconstants.GitConfigURL) {
return strings.TrimPrefix(line, appconstants.GitConfigURL), nil
}
}
@@ -159,13 +161,13 @@ func getDefaultBranch(repoRoot string) string {
output, err := cmd.Output()
if err != nil {
// Fallback to common default branches
for _, branch := range []string{DefaultBranch, "master"} {
for _, branch := range []string{appconstants.GitDefaultBranch, "master"} {
if branchExists(repoRoot, branch) {
return branch
}
}
return DefaultBranch // Default fallback
return appconstants.GitDefaultBranch // Default fallback
}
// Extract branch name from refs/remotes/origin/HEAD -> refs/remotes/origin/main
@@ -174,16 +176,16 @@ func getDefaultBranch(repoRoot string) string {
return parts[len(parts)-1]
}
return DefaultBranch
return appconstants.GitDefaultBranch
}
// branchExists checks if a branch exists in the repository.
func branchExists(repoRoot, branch string) bool {
cmd := exec.Command(
"git",
"show-ref",
"--verify",
"--quiet",
appconstants.GitCommand,
appconstants.GitShowRef,
appconstants.GitVerify,
appconstants.GitQuiet,
"refs/heads/"+branch,
) // #nosec G204 -- branch name validated by git
cmd.Dir = repoRoot

View File

@@ -109,9 +109,7 @@ func TestFindRepositoryRoot(t *testing.T) {
// Verify the returned path contains a .git directory or file
gitPath := filepath.Join(repoRoot, ".git")
if _, err := os.Stat(gitPath); os.IsNotExist(err) {
t.Errorf("repository root does not contain .git: %s", repoRoot)
}
testutil.AssertFileExists(t, gitPath)
}
})
}

25
internal/github_helper.go Normal file
View File

@@ -0,0 +1,25 @@
package internal
import (
"os"
"github.com/ivuorinen/gh-action-readme/appconstants"
)
// loadGitHubTokenFromEnv retrieves the GitHub token from environment variables.
// It checks both the tool-specific environment variable (GHREADME_GITHUB_TOKEN)
// and the standard GitHub environment variable (GITHUB_TOKEN) in that order.
// Returns an empty string if no token is found.
func loadGitHubTokenFromEnv() string {
// Priority 1: Tool-specific env var
if token := os.Getenv(appconstants.EnvGitHubToken); token != "" {
return token
}
// Priority 2: Standard GitHub env var
if token := os.Getenv(appconstants.EnvGitHubTokenStandard); token != "" {
return token
}
return ""
}

View File

@@ -2,6 +2,7 @@
package helpers
import (
"github.com/ivuorinen/gh-action-readme/appconstants"
"github.com/ivuorinen/gh-action-readme/internal"
"github.com/ivuorinen/gh-action-readme/internal/dependencies"
)
@@ -11,7 +12,7 @@ import (
func CreateAnalyzer(generator *internal.Generator, output *internal.ColoredOutput) *dependencies.Analyzer {
analyzer, err := generator.CreateDependencyAnalyzer()
if err != nil {
output.Warning("Could not create dependency analyzer: %v", err)
output.Warning(appconstants.ErrCouldNotCreateDependencyAnalyzer, err)
return nil
}

View File

@@ -28,9 +28,7 @@ func TestGetCurrentDir(t *testing.T) {
}
// Verify the directory actually exists
if _, err := os.Stat(currentDir); os.IsNotExist(err) {
t.Errorf("current directory does not exist: %s", currentDir)
}
testutil.AssertFileExists(t, currentDir)
})
}

View File

@@ -6,7 +6,8 @@ import (
"github.com/schollz/progressbar/v3"
"github.com/ivuorinen/gh-action-readme/internal/errors"
"github.com/ivuorinen/gh-action-readme/appconstants"
"github.com/ivuorinen/gh-action-readme/internal/apperrors"
)
// MessageLogger handles informational output messages.
@@ -22,14 +23,14 @@ type MessageLogger interface {
// ErrorReporter handles error output and reporting.
type ErrorReporter interface {
Error(format string, args ...any)
ErrorWithSuggestions(err *errors.ContextualError)
ErrorWithContext(code errors.ErrorCode, message string, context map[string]string)
ErrorWithSuggestions(err *apperrors.ContextualError)
ErrorWithContext(code appconstants.ErrorCode, message string, context map[string]string)
ErrorWithSimpleFix(message, suggestion string)
}
// ErrorFormatter handles formatting of contextual errors.
type ErrorFormatter interface {
FormatContextualError(err *errors.ContextualError) string
FormatContextualError(err *apperrors.ContextualError) string
}
// ProgressReporter handles progress indication and status updates.

View File

@@ -8,7 +8,8 @@ import (
"github.com/schollz/progressbar/v3"
"github.com/ivuorinen/gh-action-readme/internal/errors"
"github.com/ivuorinen/gh-action-readme/appconstants"
"github.com/ivuorinen/gh-action-readme/internal/apperrors"
)
// MockMessageLogger implements MessageLogger for testing.
@@ -57,13 +58,13 @@ func (m *MockErrorReporter) Error(format string, args ...any) {
m.ErrorCalls = append(m.ErrorCalls, formatMessage(format, args...))
}
func (m *MockErrorReporter) ErrorWithSuggestions(err *errors.ContextualError) {
func (m *MockErrorReporter) ErrorWithSuggestions(err *apperrors.ContextualError) {
if err != nil {
m.ErrorWithSuggestionsCalls = append(m.ErrorWithSuggestionsCalls, err.Error())
}
}
func (m *MockErrorReporter) ErrorWithContext(_ errors.ErrorCode, message string, _ map[string]string) {
func (m *MockErrorReporter) ErrorWithContext(_ appconstants.ErrorCode, message string, _ map[string]string) {
m.ErrorWithContextCalls = append(m.ErrorWithContextCalls, message)
}
@@ -405,16 +406,16 @@ func (m *mockCompleteOutput) Fprintf(w *os.File, format string, args ...any) {
m.logger.Fprintf(w, format, args...)
}
func (m *mockCompleteOutput) Error(format string, args ...any) { m.reporter.Error(format, args...) }
func (m *mockCompleteOutput) ErrorWithSuggestions(err *errors.ContextualError) {
func (m *mockCompleteOutput) ErrorWithSuggestions(err *apperrors.ContextualError) {
m.reporter.ErrorWithSuggestions(err)
}
func (m *mockCompleteOutput) ErrorWithContext(code errors.ErrorCode, message string, context map[string]string) {
func (m *mockCompleteOutput) ErrorWithContext(code appconstants.ErrorCode, message string, context map[string]string) {
m.reporter.ErrorWithContext(code, message, context)
}
func (m *mockCompleteOutput) ErrorWithSimpleFix(message, suggestion string) {
m.reporter.ErrorWithSimpleFix(message, suggestion)
}
func (m *mockCompleteOutput) FormatContextualError(err *errors.ContextualError) string {
func (m *mockCompleteOutput) FormatContextualError(err *apperrors.ContextualError) string {
return m.formatter.FormatContextualError(err)
}
func (m *mockCompleteOutput) Progress(format string, args ...any) {
@@ -444,7 +445,7 @@ type MockErrorFormatter struct {
FormatContextualErrorCalls []string
}
func (m *MockErrorFormatter) FormatContextualError(err *errors.ContextualError) string {
func (m *MockErrorFormatter) FormatContextualError(err *apperrors.ContextualError) string {
if err != nil {
formatted := err.Error()
m.FormatContextualErrorCalls = append(m.FormatContextualErrorCalls, formatted)
@@ -462,15 +463,15 @@ type mockErrorManager struct {
}
func (m *mockErrorManager) Error(format string, args ...any) { m.reporter.Error(format, args...) }
func (m *mockErrorManager) ErrorWithSuggestions(err *errors.ContextualError) {
func (m *mockErrorManager) ErrorWithSuggestions(err *apperrors.ContextualError) {
m.reporter.ErrorWithSuggestions(err)
}
func (m *mockErrorManager) ErrorWithContext(code errors.ErrorCode, message string, context map[string]string) {
func (m *mockErrorManager) ErrorWithContext(code appconstants.ErrorCode, message string, context map[string]string) {
m.reporter.ErrorWithContext(code, message, context)
}
func (m *mockErrorManager) ErrorWithSimpleFix(message, suggestion string) {
m.reporter.ErrorWithSimpleFix(message, suggestion)
}
func (m *mockErrorManager) FormatContextualError(err *errors.ContextualError) string {
func (m *mockErrorManager) FormatContextualError(err *apperrors.ContextualError) string {
return m.formatter.FormatContextualError(err)
}

View File

@@ -6,6 +6,8 @@ import (
"os"
"strings"
"time"
"github.com/ivuorinen/gh-action-readme/appconstants"
)
// getVersion returns the current version - can be overridden at build time.
@@ -119,7 +121,7 @@ func (jw *JSONWriter) Write(action *ActionYML, outputPath string) error {
}
// Write to file
return os.WriteFile(outputPath, data, FilePermDefault) // #nosec G306 -- JSON output file permissions
return os.WriteFile(outputPath, data, appconstants.FilePermDefault) // #nosec G306 -- JSON output file permissions
}
// convertToJSONOutput converts ActionYML to structured JSON output.

View File

@@ -7,7 +7,8 @@ import (
"github.com/fatih/color"
"github.com/ivuorinen/gh-action-readme/internal/errors"
"github.com/ivuorinen/gh-action-readme/appconstants"
"github.com/ivuorinen/gh-action-readme/internal/apperrors"
)
// ColoredOutput provides methods for colored terminal output.
@@ -123,7 +124,7 @@ func (co *ColoredOutput) Fprintf(w *os.File, format string, args ...any) {
}
// ErrorWithSuggestions prints a ContextualError with suggestions and help.
func (co *ColoredOutput) ErrorWithSuggestions(err *errors.ContextualError) {
func (co *ColoredOutput) ErrorWithSuggestions(err *apperrors.ContextualError) {
if err == nil {
return
}
@@ -138,14 +139,14 @@ func (co *ColoredOutput) ErrorWithSuggestions(err *errors.ContextualError) {
// ErrorWithContext creates and prints a contextual error with suggestions.
func (co *ColoredOutput) ErrorWithContext(
code errors.ErrorCode,
code appconstants.ErrorCode,
message string,
context map[string]string,
) {
suggestions := errors.GetSuggestions(code, context)
helpURL := errors.GetHelpURL(code)
suggestions := apperrors.GetSuggestions(code, context)
helpURL := apperrors.GetHelpURL(code)
contextualErr := errors.New(code, message).
contextualErr := apperrors.New(code, message).
WithSuggestions(suggestions...).
WithHelpURL(helpURL)
@@ -158,14 +159,14 @@ func (co *ColoredOutput) ErrorWithContext(
// ErrorWithSimpleFix prints an error with a simple suggestion.
func (co *ColoredOutput) ErrorWithSimpleFix(message, suggestion string) {
contextualErr := errors.New(errors.ErrCodeUnknown, message).
contextualErr := apperrors.New(appconstants.ErrCodeUnknown, message).
WithSuggestions(suggestion)
co.ErrorWithSuggestions(contextualErr)
}
// FormatContextualError formats a ContextualError for display.
func (co *ColoredOutput) FormatContextualError(err *errors.ContextualError) string {
func (co *ColoredOutput) FormatContextualError(err *apperrors.ContextualError) string {
if err == nil {
return ""
}
@@ -194,7 +195,7 @@ func (co *ColoredOutput) FormatContextualError(err *errors.ContextualError) stri
}
// formatMainError formats the main error message with code.
func (co *ColoredOutput) formatMainError(err *errors.ContextualError) string {
func (co *ColoredOutput) formatMainError(err *apperrors.ContextualError) string {
mainMsg := fmt.Sprintf("%s [%s]", err.Error(), err.Code)
if co.NoColor {
return "❌ " + mainMsg
@@ -208,16 +209,16 @@ func (co *ColoredOutput) formatDetailsSection(details map[string]string) []strin
var parts []string
if co.NoColor {
parts = append(parts, "\nDetails:")
parts = append(parts, appconstants.SectionDetails)
} else {
parts = append(parts, color.New(color.Bold).Sprint("\nDetails:"))
parts = append(parts, color.New(color.Bold).Sprint(appconstants.SectionDetails))
}
for key, value := range details {
if co.NoColor {
parts = append(parts, fmt.Sprintf(" %s: %s", key, value))
parts = append(parts, fmt.Sprintf(appconstants.FormatDetailKeyValue, key, value))
} else {
parts = append(parts, fmt.Sprintf(" %s: %s",
parts = append(parts, fmt.Sprintf(appconstants.FormatDetailKeyValue,
color.CyanString(key),
color.WhiteString(value)))
}
@@ -231,9 +232,9 @@ func (co *ColoredOutput) formatSuggestionsSection(suggestions []string) []string
var parts []string
if co.NoColor {
parts = append(parts, "\nSuggestions:")
parts = append(parts, appconstants.SectionSuggestions)
} else {
parts = append(parts, color.New(color.Bold).Sprint("\nSuggestions:"))
parts = append(parts, color.New(color.Bold).Sprint(appconstants.SectionSuggestions))
}
for _, suggestion := range suggestions {

View File

@@ -7,6 +7,8 @@ import (
"strings"
"github.com/goccy/go-yaml"
"github.com/ivuorinen/gh-action-readme/appconstants"
)
// ActionYML models the action.yml metadata (fields are updateable as schema evolves).
@@ -78,7 +80,7 @@ func DiscoverActionFiles(dir string, recursive bool) ([]string, error) {
// Check for action.yml or action.yaml files
filename := strings.ToLower(info.Name())
if filename == "action.yml" || filename == "action.yaml" {
if filename == appconstants.ActionFileNameYML || filename == appconstants.ActionFileNameYAML {
actionFiles = append(actionFiles, path)
}
@@ -89,7 +91,7 @@ func DiscoverActionFiles(dir string, recursive bool) ([]string, error) {
}
} else {
// Check only the specified directory
for _, filename := range []string{"action.yml", "action.yaml"} {
for _, filename := range []string{appconstants.ActionFileNameYML, appconstants.ActionFileNameYAML} {
path := filepath.Join(dir, filename)
if _, err := os.Stat(path); err == nil {
actionFiles = append(actionFiles, path)

View File

@@ -7,6 +7,7 @@ import (
"github.com/google/go-github/v74/github"
"github.com/ivuorinen/gh-action-readme/appconstants"
"github.com/ivuorinen/gh-action-readme/internal/cache"
"github.com/ivuorinen/gh-action-readme/internal/dependencies"
"github.com/ivuorinen/gh-action-readme/internal/git"
@@ -14,12 +15,6 @@ import (
"github.com/ivuorinen/gh-action-readme/templates_embed"
)
const (
defaultOrgPlaceholder = "your-org"
defaultRepoPlaceholder = "your-repo"
defaultUsesPlaceholder = "your-org/your-action@v1"
)
// TemplateOptions defines options for rendering templates.
type TemplateOptions struct {
TemplatePath string
@@ -71,7 +66,7 @@ func getGitOrg(data any) string {
}
}
return defaultOrgPlaceholder
return appconstants.DefaultOrgPlaceholder
}
// getGitRepo returns the Git repository name from template data.
@@ -85,21 +80,21 @@ func getGitRepo(data any) string {
}
}
return defaultRepoPlaceholder
return appconstants.DefaultRepoPlaceholder
}
// getGitUsesString returns a complete uses string for the action.
func getGitUsesString(data any) string {
td, ok := data.(*TemplateData)
if !ok {
return defaultUsesPlaceholder
return appconstants.DefaultUsesPlaceholder
}
org := strings.TrimSpace(getGitOrg(data))
repo := strings.TrimSpace(getGitRepo(data))
if !isValidOrgRepo(org, repo) {
return defaultUsesPlaceholder
return appconstants.DefaultUsesPlaceholder
}
version := formatVersion(getActionVersion(data))
@@ -109,7 +104,9 @@ func getGitUsesString(data any) string {
// isValidOrgRepo checks if org and repo are valid.
func isValidOrgRepo(org, repo string) bool {
return org != "" && repo != "" && org != defaultOrgPlaceholder && repo != defaultRepoPlaceholder
return org != "" && repo != "" &&
org != appconstants.DefaultOrgPlaceholder &&
repo != appconstants.DefaultRepoPlaceholder
}
// formatVersion ensures version has proper @ prefix.
@@ -129,7 +126,7 @@ func formatVersion(version string) string {
func buildUsesString(td *TemplateData, org, repo, version string) string {
// Use the validation package's FormatUsesStatement for consistency
if org == "" || repo == "" {
return defaultUsesPlaceholder
return appconstants.DefaultUsesPlaceholder
}
// For actions within subdirectories, include the action name
@@ -235,8 +232,8 @@ func RenderReadme(action any, opts TemplateOptions) (string, error) {
return "", err
}
var tmpl *template.Template
if opts.Format == OutputFormatHTML {
tmpl, err = template.New("readme").Funcs(templateFuncs()).Parse(string(tmplContent))
if opts.Format == appconstants.OutputFormatHTML {
tmpl, err = template.New(appconstants.TemplateNameReadme).Funcs(templateFuncs()).Parse(string(tmplContent))
if err != nil {
return "", err
}
@@ -260,7 +257,7 @@ func RenderReadme(action any, opts TemplateOptions) (string, error) {
return buf.String(), nil
}
tmpl, err = template.New("readme").Funcs(templateFuncs()).Parse(string(tmplContent))
tmpl, err = template.New(appconstants.TemplateNameReadme).Funcs(templateFuncs()).Parse(string(tmplContent))
if err != nil {
return "", err
}

View File

@@ -5,7 +5,8 @@ import (
"github.com/schollz/progressbar/v3"
"github.com/ivuorinen/gh-action-readme/internal/errors"
"github.com/ivuorinen/gh-action-readme/appconstants"
"github.com/ivuorinen/gh-action-readme/internal/apperrors"
)
// NullOutput is a no-op implementation of CompleteOutput for testing.
@@ -57,11 +58,13 @@ func (no *NullOutput) Printf(_ string, _ ...any) {}
func (no *NullOutput) Fprintf(_ *os.File, _ string, _ ...any) {}
// ErrorWithSuggestions is a no-op.
func (no *NullOutput) ErrorWithSuggestions(_ *errors.ContextualError) {}
func (no *NullOutput) ErrorWithSuggestions(_ *apperrors.ContextualError) {
// Intentionally empty - no-op implementation for testing
}
// ErrorWithContext is a no-op.
func (no *NullOutput) ErrorWithContext(
_ errors.ErrorCode,
_ appconstants.ErrorCode,
_ string,
_ map[string]string,
) {
@@ -71,7 +74,7 @@ func (no *NullOutput) ErrorWithContext(
func (no *NullOutput) ErrorWithSimpleFix(_, _ string) {}
// FormatContextualError returns empty string.
func (no *NullOutput) FormatContextualError(_ *errors.ContextualError) string {
func (no *NullOutput) FormatContextualError(_ *apperrors.ContextualError) string {
return ""
}

View File

@@ -6,13 +6,14 @@ import (
"path/filepath"
"regexp"
"github.com/ivuorinen/gh-action-readme/appconstants"
"github.com/ivuorinen/gh-action-readme/internal/git"
)
// IsCommitSHA checks if a version string is a commit SHA.
func IsCommitSHA(version string) bool {
// Check if it's a 40-character hex string (full SHA) or 7+ character hex (short SHA)
re := regexp.MustCompile(`^[a-f0-9]{7,40}$`)
re := regexp.MustCompile(appconstants.RegexGitSHA)
return len(version) >= 7 && re.MatchString(version)
}
@@ -34,10 +35,10 @@ func IsVersionPinned(version string) bool {
// ValidateGitBranch checks if a branch exists in the given repository.
func ValidateGitBranch(repoRoot, branch string) bool {
cmd := exec.Command(
"git",
"show-ref",
"--verify",
"--quiet",
appconstants.GitCommand,
appconstants.GitShowRef,
appconstants.GitVerify,
appconstants.GitQuiet,
"refs/heads/"+branch,
) // #nosec G204 -- branch name validated by git
cmd.Dir = repoRoot
@@ -54,7 +55,7 @@ func ValidateActionYMLPath(path string) error {
// Check if it's an action.yml or action.yaml file
filename := filepath.Base(path)
if filename != "action.yml" && filename != "action.yaml" {
if filename != appconstants.ActionFileNameYML && filename != appconstants.ActionFileNameYAML {
return os.ErrInvalid
}

View File

@@ -5,6 +5,7 @@ import (
"path/filepath"
"testing"
"github.com/ivuorinen/gh-action-readme/appconstants"
"github.com/ivuorinen/gh-action-readme/testutil"
)
@@ -21,10 +22,8 @@ func TestValidateActionYMLPath(t *testing.T) {
name: "valid action.yml file",
setupFunc: func(t *testing.T, tmpDir string) string {
t.Helper()
actionPath := filepath.Join(tmpDir, "action.yml")
testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture("actions/javascript/simple.yml"))
return actionPath
return testutil.WriteActionFixture(t, tmpDir, appconstants.TestFixtureJavaScriptSimple)
},
expectError: false,
},
@@ -32,10 +31,8 @@ func TestValidateActionYMLPath(t *testing.T) {
name: "valid action.yaml file",
setupFunc: func(t *testing.T, tmpDir string) string {
t.Helper()
actionPath := filepath.Join(tmpDir, "action.yaml")
testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture("minimal-action.yml"))
return actionPath
return testutil.WriteActionFixtureAs(t, tmpDir, "action.yaml", appconstants.TestFixtureMinimalAction)
},
expectError: false,
},
@@ -50,10 +47,8 @@ func TestValidateActionYMLPath(t *testing.T) {
name: "file with wrong extension",
setupFunc: func(t *testing.T, tmpDir string) string {
t.Helper()
actionPath := filepath.Join(tmpDir, "action.txt")
testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture("actions/javascript/simple.yml"))
return actionPath
return testutil.WriteActionFixtureAs(t, tmpDir, "action.txt", appconstants.TestFixtureJavaScriptSimple)
},
expectError: true,
},
@@ -522,9 +517,7 @@ func TestGetBinaryDir(t *testing.T) {
}
// Verify the directory exists
if _, err := os.Stat(dir); os.IsNotExist(err) {
t.Errorf("binary directory does not exist: %s", dir)
}
testutil.AssertFileExists(t, dir)
}
func TestEnsureAbsolutePath(t *testing.T) {

View File

@@ -3,6 +3,8 @@ package internal
import (
"fmt"
"strings"
"github.com/ivuorinen/gh-action-readme/appconstants"
)
// ValidationResult holds the results of action.yml validation.
@@ -18,18 +20,18 @@ func ValidateActionYML(action *ActionYML) ValidationResult {
// Validate required fields with helpful suggestions
if action.Name == "" {
result.MissingFields = append(result.MissingFields, "name")
result.MissingFields = append(result.MissingFields, appconstants.FieldName)
result.Suggestions = append(result.Suggestions, "Add 'name: Your Action Name' to describe your action")
}
if action.Description == "" {
result.MissingFields = append(result.MissingFields, "description")
result.MissingFields = append(result.MissingFields, appconstants.FieldDescription)
result.Suggestions = append(
result.Suggestions,
"Add 'description: Brief description of what your action does' for better documentation",
)
}
if len(action.Runs) == 0 {
result.MissingFields = append(result.MissingFields, "runs")
result.MissingFields = append(result.MissingFields, appconstants.FieldRuns)
result.Suggestions = append(
result.Suggestions,
"Add 'runs:' section with 'using: node20' or 'using: docker' and specify the main file",
@@ -38,14 +40,14 @@ func ValidateActionYML(action *ActionYML) ValidationResult {
// Validate the runs section content
if using, ok := action.Runs["using"].(string); ok {
if !isValidRuntime(using) {
result.MissingFields = append(result.MissingFields, "runs.using")
result.MissingFields = append(result.MissingFields, appconstants.FieldRunsUsing)
result.Suggestions = append(
result.Suggestions,
fmt.Sprintf("Invalid runtime '%s'. Valid runtimes: node12, node16, node20, docker, composite", using),
)
}
} else {
result.MissingFields = append(result.MissingFields, "runs.using")
result.MissingFields = append(result.MissingFields, appconstants.FieldRunsUsing)
result.Suggestions = append(
result.Suggestions,
"Missing 'using' field in runs section. Specify 'using: node20', 'using: docker', or 'using: composite'",

122
internal/viper_helper.go Normal file
View File

@@ -0,0 +1,122 @@
package internal
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/adrg/xdg"
"github.com/spf13/viper"
"github.com/ivuorinen/gh-action-readme/appconstants"
)
// initializeViperInstance creates and configures a new viper instance with standard settings.
// This includes XDG-compliant configuration paths, environment variable support,
// and standard search paths for configuration files.
func initializeViperInstance() (*viper.Viper, error) {
v := viper.New()
// Set configuration file name and type
v.SetConfigName(appconstants.ConfigFileName)
v.SetConfigType(appconstants.OutputFormatYAML)
// Add XDG-compliant configuration directory
configDir, err := xdg.ConfigFile(appconstants.PathXDGConfig)
if err != nil {
return nil, fmt.Errorf(appconstants.ErrFailedToGetXDGConfigDir, err)
}
v.AddConfigPath(filepath.Dir(configDir))
// Add additional search paths
v.AddConfigPath(".") // current directory
// Expand home directory for fallback config path
if home, err := os.UserHomeDir(); err == nil {
v.AddConfigPath(filepath.Join(home, ".config", appconstants.AppName)) // fallback
}
v.AddConfigPath(appconstants.PathEtcConfig) // system-wide
// Set environment variable prefix
v.SetEnvPrefix(appconstants.EnvPrefix)
v.SetEnvKeyReplacer(strings.NewReplacer("-", "_", ".", "_"))
v.AutomaticEnv()
return v, nil
}
// setConfigDefaults sets all default configuration values in the viper instance.
// This ensures consistent default values across all configuration loading scenarios.
func setConfigDefaults(v *viper.Viper, defaults *AppConfig) {
v.SetDefault(appconstants.ConfigKeyOrganization, defaults.Organization)
v.SetDefault(appconstants.ConfigKeyRepository, defaults.Repository)
v.SetDefault(appconstants.ConfigKeyVersion, defaults.Version)
v.SetDefault(appconstants.ConfigKeyTheme, defaults.Theme)
v.SetDefault(appconstants.ConfigKeyOutputFormat, defaults.OutputFormat)
v.SetDefault(appconstants.ConfigKeyOutputDir, defaults.OutputDir)
v.SetDefault(appconstants.ConfigKeyTemplate, defaults.Template)
v.SetDefault(appconstants.ConfigKeyHeader, defaults.Header)
v.SetDefault(appconstants.ConfigKeyFooter, defaults.Footer)
v.SetDefault(appconstants.ConfigKeySchema, defaults.Schema)
v.SetDefault(appconstants.ConfigKeyAnalyzeDependencies, defaults.AnalyzeDependencies)
v.SetDefault(appconstants.ConfigKeyShowSecurityInfo, defaults.ShowSecurityInfo)
v.SetDefault(appconstants.ConfigKeyVerbose, defaults.Verbose)
v.SetDefault(appconstants.ConfigKeyQuiet, defaults.Quiet)
v.SetDefault(appconstants.ConfigKeyDefaultsName, defaults.Defaults.Name)
v.SetDefault(appconstants.ConfigKeyDefaultsDescription, defaults.Defaults.Description)
v.SetDefault(appconstants.ConfigKeyDefaultsBrandingIcon, defaults.Defaults.Branding.Icon)
v.SetDefault(appconstants.ConfigKeyDefaultsBrandingColor, defaults.Defaults.Branding.Color)
}
// loadConfigFromViper loads an AppConfig from a specified YAML config file using viper.
func loadConfigFromViper(configPath string) (*AppConfig, error) {
v := viper.New()
v.SetConfigFile(configPath)
v.SetConfigType(appconstants.OutputFormatYAML)
if err := v.ReadInConfig(); err != nil {
return nil, fmt.Errorf("failed to read config %s: %w", configPath, err)
}
var config AppConfig
if err := v.Unmarshal(&config); err != nil {
return nil, fmt.Errorf(appconstants.ErrFailedToUnmarshalConfig, err)
}
return &config, nil
}
// loadAndUnmarshalConfig initializes viper with defaults, reads config file,
// and unmarshals into AppConfig with proper error handling.
// Returns *AppConfig with resolved template paths.
func loadAndUnmarshalConfig(configFile string, v *viper.Viper) (*AppConfig, error) {
// Set defaults
defaults := DefaultAppConfig()
setConfigDefaults(v, defaults)
// Use specific config file if provided
if configFile != "" {
v.SetConfigFile(configFile)
}
// Read configuration
if err := v.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
return nil, fmt.Errorf(appconstants.ErrFailedToReadConfigFile, err)
}
// Config file not found is not an error - we'll use defaults and env vars
}
// Unmarshal configuration into struct
var config AppConfig
if err := v.Unmarshal(&config); err != nil {
return nil, fmt.Errorf(appconstants.ErrFailedToUnmarshalConfig, err)
}
// Resolve template paths relative to binary if they're not absolute
resolveAllTemplatePaths(&config)
return &config, nil
}

View File

@@ -11,17 +11,12 @@ import (
"github.com/goccy/go-yaml"
"github.com/ivuorinen/gh-action-readme/appconstants"
"github.com/ivuorinen/gh-action-readme/internal"
"github.com/ivuorinen/gh-action-readme/internal/git"
"github.com/ivuorinen/gh-action-readme/internal/helpers"
)
const (
// Language constants to avoid repetition.
langJavaScriptTypeScript = "JavaScript/TypeScript"
langGo = "Go"
)
// ProjectDetector handles auto-detection of project settings.
type ProjectDetector struct {
output *internal.ColoredOutput
@@ -33,7 +28,7 @@ type ProjectDetector struct {
func NewProjectDetector(output *internal.ColoredOutput) (*ProjectDetector, error) {
currentDir, err := helpers.GetCurrentDir()
if err != nil {
return nil, fmt.Errorf("failed to get current directory: %w", err)
return nil, fmt.Errorf(appconstants.ErrFailedToGetCurrentDir, err)
}
return &ProjectDetector{
@@ -172,7 +167,7 @@ func (d *ProjectDetector) detectVersion() string {
// detectVersionFromPackageJSON detects version from package.json.
func (d *ProjectDetector) detectVersionFromPackageJSON() string {
packageJSONPath := filepath.Join(d.currentDir, "package.json")
packageJSONPath := filepath.Join(d.currentDir, appconstants.PackageJSON)
data, err := os.ReadFile(packageJSONPath) // #nosec G304 -- path is constructed from current directory
if err != nil {
return ""
@@ -264,7 +259,7 @@ func (d *ProjectDetector) handleDirectory(info os.FileInfo) error {
func (d *ProjectDetector) findActionFilesInDirectory(dir string) ([]string, error) {
var actionFiles []string
for _, filename := range []string{"action.yml", "action.yaml"} {
for _, filename := range []string{appconstants.ActionFileNameYML, appconstants.ActionFileNameYAML} {
actionPath := filepath.Join(dir, filename)
if _, err := os.Stat(actionPath); err == nil {
actionFiles = append(actionFiles, actionPath)
@@ -276,7 +271,7 @@ func (d *ProjectDetector) findActionFilesInDirectory(dir string) ([]string, erro
// isActionFile checks if a filename is an action file.
func (d *ProjectDetector) isActionFile(filename string) bool {
return filename == "action.yml" || filename == "action.yaml"
return filename == appconstants.ActionFileNameYML || filename == appconstants.ActionFileNameYAML
}
// analyzeActionFile analyzes an action file to extract characteristics.
@@ -315,7 +310,7 @@ func (d *ProjectDetector) analyzeRunsSection(action map[string]any, settings *De
}
// Check if it's a composite action
if using, ok := runs["using"].(string); ok && using == "composite" {
if using, ok := runs["using"].(string); ok && using == appconstants.ActionTypeComposite {
settings.HasCompositeAction = true
}
@@ -377,17 +372,17 @@ func (d *ProjectDetector) analyzeProjectFiles() map[string]string {
// detectLanguageFromFile detects programming language from filename.
func (d *ProjectDetector) detectLanguageFromFile(filename string, characteristics map[string]string) {
switch filename {
case "package.json":
characteristics["language"] = langJavaScriptTypeScript
case appconstants.PackageJSON:
characteristics["language"] = appconstants.LangJavaScriptTypeScript
characteristics["type"] = "Node.js Project"
case "go.mod":
characteristics["language"] = langGo
characteristics["language"] = appconstants.LangGo
characteristics["type"] = "Go Module"
case "Cargo.toml":
characteristics["language"] = "Rust"
characteristics["type"] = "Rust Project"
case "pyproject.toml", "requirements.txt":
characteristics["language"] = "Python"
characteristics["language"] = appconstants.LangPython
characteristics["type"] = "Python Project"
case "Gemfile":
characteristics["language"] = "Ruby"
@@ -447,11 +442,11 @@ func (d *ProjectDetector) suggestTheme(settings *DetectedSettings) {
case settings.HasCompositeAction:
settings.SuggestedTheme = "professional"
case settings.HasDockerfile:
settings.SuggestedTheme = "github"
case settings.Language == langGo:
settings.SuggestedTheme = "minimal"
settings.SuggestedTheme = appconstants.ThemeGitHub
case settings.Language == appconstants.LangGo:
settings.SuggestedTheme = appconstants.ThemeMinimal
case settings.Framework != "":
settings.SuggestedTheme = "github"
settings.SuggestedTheme = appconstants.ThemeGitHub
default:
settings.SuggestedTheme = "default"
}
@@ -464,9 +459,9 @@ func (d *ProjectDetector) suggestRunsOn(settings *DetectedSettings) {
}
switch settings.Language {
case langJavaScriptTypeScript:
case appconstants.LangJavaScriptTypeScript:
settings.SuggestedRunsOn = []string{"ubuntu-latest", "windows-latest", "macos-latest"}
case langGo, "Python":
case appconstants.LangGo, appconstants.LangPython:
settings.SuggestedRunsOn = []string{"ubuntu-latest"}
}
}

View File

@@ -9,6 +9,7 @@ import (
"github.com/goccy/go-yaml"
"github.com/ivuorinen/gh-action-readme/appconstants"
"github.com/ivuorinen/gh-action-readme/internal"
)
@@ -17,11 +18,11 @@ type ExportFormat string
const (
// FormatYAML exports configuration as YAML.
FormatYAML ExportFormat = "yaml"
FormatYAML ExportFormat = appconstants.OutputFormatYAML
// FormatJSON exports configuration as JSON.
FormatJSON ExportFormat = "json"
FormatJSON ExportFormat = appconstants.OutputFormatJSON
// FormatTOML exports configuration as TOML.
FormatTOML ExportFormat = "toml"
FormatTOML ExportFormat = appconstants.OutputFormatTOML
)
// ConfigExporter handles exporting configuration to various formats.
@@ -39,7 +40,9 @@ func NewConfigExporter(output *internal.ColoredOutput) *ConfigExporter {
// ExportConfig exports the configuration to the specified format and path.
func (e *ConfigExporter) ExportConfig(config *internal.AppConfig, format ExportFormat, outputPath string) error {
// Create output directory if it doesn't exist
if err := os.MkdirAll(filepath.Dir(outputPath), 0750); err != nil { // #nosec G301 -- output directory permissions
outputDir := filepath.Dir(outputPath)
// #nosec G301 -- output directory permissions
if err := os.MkdirAll(outputDir, appconstants.FilePermDir); err != nil {
return fmt.Errorf("failed to create output directory: %w", err)
}
@@ -71,7 +74,7 @@ func (e *ConfigExporter) GetDefaultOutputPath(format ExportFormat) (string, erro
switch format {
case FormatYAML:
return filepath.Join(dir, "config.yaml"), nil
return filepath.Join(dir, appconstants.ConfigYAML), nil
case FormatJSON:
return filepath.Join(dir, "config.json"), nil
case FormatTOML:
@@ -97,14 +100,14 @@ func (e *ConfigExporter) exportYAML(config *internal.AppConfig, outputPath strin
encoder := yaml.NewEncoder(file, yaml.Indent(2))
// Add header comment
_, _ = file.WriteString("# gh-action-readme configuration file\n")
_, _ = file.WriteString("# Generated by the interactive configuration wizard\n\n")
_, _ = file.WriteString(appconstants.MsgConfigHeader)
_, _ = file.WriteString(appconstants.MsgConfigWizardHeader)
if err := encoder.Encode(exportConfig); err != nil {
return fmt.Errorf("failed to encode YAML: %w", err)
}
e.output.Success("Configuration exported to: %s", outputPath)
e.output.Success(appconstants.MsgConfigurationExportedTo, outputPath)
return nil
}
@@ -129,7 +132,7 @@ func (e *ConfigExporter) exportJSON(config *internal.AppConfig, outputPath strin
return fmt.Errorf("failed to encode JSON: %w", err)
}
e.output.Success("Configuration exported to: %s", outputPath)
e.output.Success(appconstants.MsgConfigurationExportedTo, outputPath)
return nil
}
@@ -149,13 +152,13 @@ func (e *ConfigExporter) exportTOML(config *internal.AppConfig, outputPath strin
}()
// Write TOML header
_, _ = file.WriteString("# gh-action-readme configuration file\n")
_, _ = file.WriteString("# Generated by the interactive configuration wizard\n\n")
_, _ = file.WriteString(appconstants.MsgConfigHeader)
_, _ = file.WriteString(appconstants.MsgConfigWizardHeader)
// Basic TOML export (simplified version)
e.writeTOMLConfig(file, exportConfig)
e.output.Success("Configuration exported to: %s", outputPath)
e.output.Success(appconstants.MsgConfigurationExportedTo, outputPath)
return nil
}
@@ -270,7 +273,7 @@ func (e *ConfigExporter) writePermissionsSection(file *os.File, config *internal
_, _ = fmt.Fprintf(file, "\n[permissions]\n")
for key, value := range config.Permissions {
_, _ = fmt.Fprintf(file, "%s = %q\n", key, value)
_, _ = fmt.Fprintf(file, appconstants.FormatEnvVar, key, value)
}
}
@@ -282,6 +285,6 @@ func (e *ConfigExporter) writeVariablesSection(file *os.File, config *internal.A
_, _ = fmt.Fprintf(file, "\n[variables]\n")
for key, value := range config.Variables {
_, _ = fmt.Fprintf(file, "%s = %q\n", key, value)
_, _ = fmt.Fprintf(file, appconstants.FormatEnvVar, key, value)
}
}

View File

@@ -10,6 +10,7 @@ import (
"github.com/goccy/go-yaml"
"github.com/ivuorinen/gh-action-readme/internal"
"github.com/ivuorinen/gh-action-readme/testutil"
)
func TestConfigExporter_ExportConfig(t *testing.T) {
@@ -68,7 +69,7 @@ func testYAMLExport(exporter *ConfigExporter, config *internal.AppConfig) func(*
t.Fatalf("ExportConfig() error = %v", err)
}
verifyFileExists(t, outputPath)
testutil.AssertFileExists(t, outputPath)
verifyYAMLContent(t, outputPath, config)
}
}
@@ -85,7 +86,7 @@ func testJSONExport(exporter *ConfigExporter, config *internal.AppConfig) func(*
t.Fatalf("ExportConfig() error = %v", err)
}
verifyFileExists(t, outputPath)
testutil.AssertFileExists(t, outputPath)
verifyJSONContent(t, outputPath, config)
}
}
@@ -102,19 +103,11 @@ func testTOMLExport(exporter *ConfigExporter, config *internal.AppConfig) func(*
t.Fatalf("ExportConfig() error = %v", err)
}
verifyFileExists(t, outputPath)
testutil.AssertFileExists(t, outputPath)
verifyTOMLContent(t, outputPath)
}
}
// verifyFileExists checks that a file exists at the given path.
func verifyFileExists(t *testing.T, outputPath string) {
t.Helper()
if _, err := os.Stat(outputPath); os.IsNotExist(err) {
t.Fatal("Expected output file to exist")
}
}
// verifyYAMLContent verifies YAML content is valid and contains expected data.
func verifyYAMLContent(t *testing.T, outputPath string, expected *internal.AppConfig) {
t.Helper()

View File

@@ -8,6 +8,7 @@ import (
"regexp"
"strings"
"github.com/ivuorinen/gh-action-readme/appconstants"
"github.com/ivuorinen/gh-action-readme/internal"
)
@@ -88,11 +89,11 @@ func (v *ConfigValidator) ValidateField(fieldName, value string) *ValidationResu
v.validateRepository(value, result)
case "version":
v.validateVersion(value, result)
case "theme":
case appconstants.ConfigKeyTheme:
v.validateTheme(value, result)
case "output_format":
case appconstants.ConfigKeyOutputFormat:
v.validateOutputFormat(value, result)
case "output_dir":
case appconstants.ConfigKeyOutputDir:
v.validateOutputDir(value, result)
case "github_token":
v.validateGitHubToken(value, result)
@@ -129,7 +130,7 @@ func (v *ConfigValidator) DisplayValidationResult(result *ValidationResult) {
// Display suggestions
if len(result.Suggestions) > 0 {
v.output.Info("\nSuggestions:")
v.output.Info(appconstants.SectionSuggestions)
for _, suggestion := range result.Suggestions {
v.output.Printf(" 💡 %s", suggestion)
}
@@ -485,8 +486,8 @@ func (v *ConfigValidator) isValidGitHubToken(token string) bool {
// GitHub personal access tokens start with ghp_ or github_pat_
// Classic tokens are 40 characters after the prefix
// Fine-grained tokens have different formats
return strings.HasPrefix(token, "ghp_") ||
strings.HasPrefix(token, "github_pat_") ||
return strings.HasPrefix(token, appconstants.TokenPrefixGitHubPersonal) ||
strings.HasPrefix(token, appconstants.TokenPrefixGitHubPAT) ||
strings.HasPrefix(token, "gho_") ||
strings.HasPrefix(token, "ghu_") ||
strings.HasPrefix(token, "ghs_") ||

View File

@@ -10,6 +10,7 @@ import (
"strconv"
"strings"
"github.com/ivuorinen/gh-action-readme/appconstants"
"github.com/ivuorinen/gh-action-readme/internal"
"github.com/ivuorinen/gh-action-readme/internal/git"
"github.com/ivuorinen/gh-action-readme/internal/helpers"
@@ -72,7 +73,7 @@ func (w *ConfigWizard) detectProjectSettings() error {
// Detect current directory
currentDir, err := helpers.GetCurrentDir()
if err != nil {
return fmt.Errorf("failed to get current directory: %w", err)
return fmt.Errorf(appconstants.ErrFailedToGetCurrentDir, err)
}
w.actionDir = currentDir
@@ -180,7 +181,7 @@ func (w *ConfigWizard) displayThemeOptions(themes []struct {
for i, theme := range themes {
marker := " "
if theme.name == w.config.Theme {
marker = "►"
marker = appconstants.SymbolArrow
}
w.output.Printf(" %s %d. %s - %s", marker, i+1, theme.name, theme.desc)
}
@@ -191,7 +192,7 @@ func (w *ConfigWizard) displayFormatOptions(formats []string) {
for i, format := range formats {
marker := " "
if format == w.config.OutputFormat {
marker = "►"
marker = appconstants.SymbolArrow
}
w.output.Printf(" %s %d. %s", marker, i+1, format)
}
@@ -247,7 +248,9 @@ func (w *ConfigWizard) configureGitHubIntegration() {
token := w.promptSensitive("Enter your GitHub token (or press Enter to skip)")
if token != "" {
// Validate token format (basic check)
if strings.HasPrefix(token, "ghp_") || strings.HasPrefix(token, "github_pat_") {
hasPersonalPrefix := strings.HasPrefix(token, appconstants.TokenPrefixGitHubPersonal)
hasPATPrefix := strings.HasPrefix(token, appconstants.TokenPrefixGitHubPAT)
if hasPersonalPrefix || hasPATPrefix {
w.config.GitHubToken = token
w.output.Success("GitHub token configured ✓")
} else {
@@ -297,9 +300,9 @@ func (w *ConfigWizard) confirmConfiguration() error {
// promptWithDefault prompts for input with a default value.
func (w *ConfigWizard) promptWithDefault(prompt, defaultValue string) string {
if defaultValue != "" {
w.output.Printf("%s [%s]: ", prompt, defaultValue)
w.output.Printf(appconstants.FormatPromptDefault, prompt, defaultValue)
} else {
w.output.Printf("%s: ", prompt)
w.output.Printf(appconstants.FormatPrompt, prompt)
}
if w.scanner.Scan() {
@@ -316,7 +319,7 @@ func (w *ConfigWizard) promptWithDefault(prompt, defaultValue string) string {
// promptSensitive prompts for sensitive input (like tokens) without echoing.
func (w *ConfigWizard) promptSensitive(prompt string) string {
w.output.Printf("%s: ", prompt)
w.output.Printf(appconstants.FormatPrompt, prompt)
if w.scanner.Scan() {
return strings.TrimSpace(w.scanner.Text())
}
@@ -331,12 +334,12 @@ func (w *ConfigWizard) promptYesNo(prompt string, defaultValue bool) bool {
defaultStr = "Y/n"
}
w.output.Printf("%s [%s]: ", prompt, defaultStr)
w.output.Printf(appconstants.FormatPromptDefault, prompt, defaultStr)
if w.scanner.Scan() {
input := strings.ToLower(strings.TrimSpace(w.scanner.Text()))
switch input {
case "y", "yes":
case "y", appconstants.InputYes:
return true
case "n", "no":
return false