mirror of
https://github.com/ivuorinen/gh-action-readme.git
synced 2026-01-26 03:04:10 +00:00
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:
@@ -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 {
|
||||
@@ -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 {
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
})
|
||||
}
|
||||
}
|
||||
17
internal/cache/cache.go
vendored
17
internal/cache/cache.go
vendored
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
26
internal/cache/cache_test.go
vendored
26
internal/cache/cache_test.go
vendored
@@ -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
|
||||
|
||||
@@ -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
20
internal/config_helper.go
Normal 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
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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(), ", "))
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
25
internal/github_helper.go
Normal 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 ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
122
internal/viper_helper.go
Normal 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
|
||||
}
|
||||
@@ -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"}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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_") ||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user