diff --git a/.commitlintrc.json b/.commitlintrc.json index 66ca2e5..e693fdd 100644 --- a/.commitlintrc.json +++ b/.commitlintrc.json @@ -1,13 +1,55 @@ { - "extends": ["@commitlint/config-conventional"], + "extends": [ + "@commitlint/config-conventional" + ], "rules": { - "type-enum": [2, "always", ["feat", "fix", "docs", "style", "refactor", "perf", "test", "chore", "ci", "build", "revert"]], - "type-case": [2, "always", "lower-case"], - "type-empty": [2, "never"], - "subject-empty": [2, "never"], - "subject-full-stop": [2, "never", "."], - "header-max-length": [2, "always", 100], - "body-leading-blank": [1, "always"], - "footer-leading-blank": [1, "always"] + "type-enum": [ + 2, + "always", + [ + "feat", + "fix", + "docs", + "style", + "refactor", + "perf", + "test", + "chore", + "ci", + "build", + "revert" + ] + ], + "type-case": [ + 2, + "always", + "lower-case" + ], + "type-empty": [ + 2, + "never" + ], + "subject-empty": [ + 2, + "never" + ], + "subject-full-stop": [ + 2, + "never", + "." + ], + "header-max-length": [ + 2, + "always", + 100 + ], + "body-leading-blank": [ + 1, + "always" + ], + "footer-leading-blank": [ + 1, + "always" + ] } } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 172bd2b..68cfe73 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,17 +9,17 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6 + uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 - name: Install dependencies run: go mod tidy - name: Setup Node.js for EditorConfig tools - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6 + uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 with: - node-version: '24' + node-version: "24" - name: Install EditorConfig tools run: npm install -g eclint - name: Check EditorConfig compliance @@ -58,7 +58,7 @@ jobs: echo "Verifying generated documentation files..." ls -la docs/ - name: Upload Generated Documentation - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 if: always() with: name: generated-documentation diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 01f7448..348cdaa 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,14 +1,14 @@ --- # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json -name: 'CodeQL' +name: "CodeQL" on: push: - branches: ['main'] + branches: ["main"] pull_request: - branches: ['main'] + branches: ["main"] schedule: - - cron: '30 1 * * 0' # Run at 1:30 AM UTC every Sunday + - cron: "30 1 * * 0" # Run at 1:30 AM UTC every Sunday merge_group: permissions: @@ -25,7 +25,7 @@ jobs: strategy: fail-fast: false matrix: - language: ['go'] + language: ["go"] steps: - name: Checkout repository @@ -45,4 +45,4 @@ jobs: - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 with: - category: '/language:${{matrix.language}}' + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml index 0be19c3..635bb1f 100644 --- a/.github/workflows/commitlint.yml +++ b/.github/workflows/commitlint.yml @@ -25,7 +25,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 with: - node-version: '24' + node-version: "24" - name: Install commitlint run: | diff --git a/.github/workflows/pr-lint.yml b/.github/workflows/pr-lint.yml index d64ca57..70403a4 100644 --- a/.github/workflows/pr-lint.yml +++ b/.github/workflows/pr-lint.yml @@ -12,7 +12,8 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true -permissions: read-all +permissions: + contents: read jobs: Linter: @@ -30,4 +31,4 @@ jobs: steps: - name: Run PR Lint # https://github.com/ivuorinen/actions - uses: ivuorinen/actions/pr-lint@5cc7373a22402ee8985376bc713f00e09b5b2edb # v2025.11.23 + uses: ivuorinen/actions/pr-lint@fb25736f7e7a438979c11764e9fe6a100278b4c5 # v2025.12.30 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 95327f6..b33f454 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,9 +4,10 @@ name: Release on: push: tags: - - 'v*.*.*' + - "v*.*.*" -permissions: read-all +permissions: + contents: read jobs: release: @@ -29,15 +30,15 @@ jobs: - name: Set up Node.js (for cosign) uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 with: - node-version: '24' + node-version: "24" - name: Install cosign uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0 with: - cosign-release: 'v2.4.0' + cosign-release: "v2.4.0" - name: Install syft - uses: anchore/sbom-action/download-syft@43a17d6e7add2b5535efe4dcae9952337c479a93 # v0.20.11 + uses: anchore/sbom-action/download-syft@a930d0ac434e3182448fe678398ba5713717112a # v0.21.0 - name: Set up Docker Buildx uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 65ebfd0..4d96728 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -1,23 +1,17 @@ --- # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json -name: 'Security Scanning' +name: "Security Scanning" on: push: - branches: ['main'] + branches: ["main"] pull_request: - branches: ['main'] + branches: ["main"] schedule: # Run security scans every Sunday at 2:00 AM UTC - - cron: '0 2 * * 0' + - cron: "0 2 * * 0" merge_group: -permissions: - contents: read - security-events: write - actions: read - pull-requests: write - jobs: # Comprehensive security coverage: # - govulncheck: Go-specific vulnerability scanning @@ -27,16 +21,18 @@ jobs: govulncheck: name: Go Vulnerability Check runs-on: ubuntu-latest + permissions: + contents: read steps: - name: Checkout repository - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6 + uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 with: - go-version-file: 'go.mod' + go-version-file: "go.mod" check-latest: true - name: Install govulncheck @@ -48,47 +44,52 @@ jobs: trivy: name: Trivy Security Scan runs-on: ubuntu-latest + permissions: + contents: read + security-events: write steps: - name: Checkout repository - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 0 - name: Run Trivy vulnerability scanner in repo mode - uses: aquasecurity/trivy-action@master # 0.32.0 + uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # 0.33.1 with: - scan-type: 'fs' - scan-ref: '.' - format: 'sarif' - output: 'trivy-results.sarif' - severity: 'CRITICAL,HIGH,MEDIUM' + scan-type: "fs" + scan-ref: "." + format: "sarif" + output: "trivy-results.sarif" + severity: "CRITICAL,HIGH,MEDIUM" - name: Upload Trivy scan results to GitHub Security tab - uses: github/codeql-action/upload-sarif@0499de31b99561a6d14a36a5f662c2a54f91beee # v4 + uses: github/codeql-action/upload-sarif@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 if: always() with: - sarif_file: 'trivy-results.sarif' + sarif_file: "trivy-results.sarif" - name: Run Trivy in GitHub SBOM mode and submit results to Dependency Graph - uses: aquasecurity/trivy-action@master # 0.32.0 + uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # 0.33.1 with: - scan-type: 'fs' - format: 'github' - output: 'dependency-results.sbom.json' - image-ref: '.' + scan-type: "fs" + format: "github" + output: "dependency-results.sbom.json" + image-ref: "." github-pat: ${{ secrets.GITHUB_TOKEN }} secrets: name: Secrets Detection runs-on: ubuntu-latest + permissions: + contents: read steps: - name: Checkout repository - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 0 # Full history for gitleaks - name: Run gitleaks to detect secrets - uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2 + uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2.3.9 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE}} # Only required for gitleaks-action pro @@ -96,17 +97,20 @@ jobs: docker-security: name: Docker Image Security runs-on: ubuntu-latest + permissions: + contents: read + security-events: write if: github.event_name != 'pull_request' # Skip on PRs to avoid building images unnecessarily steps: - name: Checkout repository - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6 + uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 with: - go-version-file: 'go.mod' + go-version-file: "go.mod" check-latest: true - name: Build the bin @@ -117,30 +121,33 @@ jobs: run: docker build -t gh-action-readme:test . - name: Run Trivy vulnerability scanner on Docker image - uses: aquasecurity/trivy-action@master # 0.32.0 + uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # 0.33.1 with: - image-ref: 'gh-action-readme:test' - format: 'sarif' - output: 'trivy-docker-results.sarif' + image-ref: "gh-action-readme:test" + format: "sarif" + output: "trivy-docker-results.sarif" - name: Upload Docker Trivy scan results - uses: github/codeql-action/upload-sarif@0499de31b99561a6d14a36a5f662c2a54f91beee # v4 + uses: github/codeql-action/upload-sarif@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 if: always() with: - sarif_file: 'trivy-docker-results.sarif' + sarif_file: "trivy-docker-results.sarif" dependency-review: name: Dependency Review runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write if: github.event_name == 'pull_request' steps: - name: Checkout repository - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 0 - name: Dependency Review - uses: actions/dependency-review-action@da24556b548a50705dd671f47852072ea4c105d9 # v4 + uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2 with: fail-on-severity: high comment-summary-in-pr: always diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 4e83eca..b05736b 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -4,7 +4,7 @@ name: Stale on: schedule: - - cron: '0 8 * * *' # Every day at 08:00 + - cron: "0 8 * * *" # Every day at 08:00 workflow_call: workflow_dispatch: @@ -23,4 +23,4 @@ jobs: issues: write pull-requests: write steps: - - uses: ivuorinen/actions/stale@5cc7373a22402ee8985376bc713f00e09b5b2edb # v2025.11.23 + - uses: ivuorinen/actions/stale@fb25736f7e7a438979c11764e9fe6a100278b4c5 # v2025.12.30 diff --git a/.github/workflows/sync-labels.yml b/.github/workflows/sync-labels.yml index a35059a..b1f7628 100644 --- a/.github/workflows/sync-labels.yml +++ b/.github/workflows/sync-labels.yml @@ -8,10 +8,10 @@ on: - main - master paths: - - '.github/labels.yml' - - '.github/workflows/sync-labels.yml' + - ".github/labels.yml" + - ".github/workflows/sync-labels.yml" schedule: - - cron: '34 5 * * *' # Run every day at 05:34 AM UTC + - cron: "34 5 * * *" # Run every day at 05:34 AM UTC workflow_call: workflow_dispatch: merge_group: @@ -20,7 +20,8 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true -permissions: read-all +permissions: + contents: read jobs: labels: @@ -39,4 +40,4 @@ jobs: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} - name: โคต๏ธ Sync Latest Labels Definitions - uses: ivuorinen/actions/sync-labels@5cc7373a22402ee8985376bc713f00e09b5b2edb # v2025.11.23 + uses: ivuorinen/actions/sync-labels@fb25736f7e7a438979c11764e9fe6a100278b4c5 # v2025.12.30 diff --git a/.gitignore b/.gitignore index 5c8f280..70bba6c 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,4 @@ coverage.* # Other /megalinter-reports/ +cr.txt diff --git a/.go-version b/.go-version index 26a9e99..b45fe31 100644 --- a/.go-version +++ b/.go-version @@ -1 +1 @@ -1.25.4 +1.25.5 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 57065bf..752a3ef 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -25,12 +25,6 @@ repos: - id: pretty-format-json args: [--autofix, --no-sort-keys] - # Renovatebot pre-commit hooks - - repo: https://github.com/renovatebot/pre-commit-hooks - rev: 42.64.1 - hooks: - - id: renovate-config-validator - # YAML formatting with yamlfmt (replaces yamllint for formatting) - repo: https://github.com/google/yamlfmt rev: v0.20.0 @@ -71,14 +65,14 @@ repos: # GitHub Actions linting - repo: https://github.com/rhysd/actionlint - rev: v1.7.9 + rev: v1.7.10 hooks: - id: actionlint args: ["-shellcheck="] # Commit message linting - repo: https://github.com/alessandrojcm/commitlint-pre-commit-hook - rev: v9.24.0 + rev: v9.23.0 hooks: - id: commitlint stages: [commit-msg] diff --git a/appconstants/constants.go b/appconstants/constants.go new file mode 100644 index 0000000..9b8c4de --- /dev/null +++ b/appconstants/constants.go @@ -0,0 +1,618 @@ +// Package appconstants provides common constants used throughout the application. +package appconstants + +import "time" + +// 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 and tests. + FilePermDefault = 0600 +) + +// ErrorCode represents a category of error for providing specific help. +type ErrorCode string + +// Error code constants for application error handling. +const ( + // ErrCodeFileNotFound represents file not found errors. + ErrCodeFileNotFound ErrorCode = "FILE_NOT_FOUND" + // ErrCodePermission represents permission denied errors. + ErrCodePermission ErrorCode = "PERMISSION_DENIED" + // ErrCodeInvalidYAML represents invalid YAML syntax errors. + ErrCodeInvalidYAML ErrorCode = "INVALID_YAML" + // ErrCodeInvalidAction represents invalid action file errors. + ErrCodeInvalidAction ErrorCode = "INVALID_ACTION" + // ErrCodeNoActionFiles represents no action files found errors. + ErrCodeNoActionFiles ErrorCode = "NO_ACTION_FILES" + // ErrCodeGitHubAPI represents GitHub API errors. + ErrCodeGitHubAPI ErrorCode = "GITHUB_API_ERROR" + // ErrCodeGitHubRateLimit represents GitHub API rate limit errors. + ErrCodeGitHubRateLimit ErrorCode = "GITHUB_RATE_LIMIT" + // ErrCodeGitHubAuth represents GitHub authentication errors. + ErrCodeGitHubAuth ErrorCode = "GITHUB_AUTH_ERROR" + // ErrCodeConfiguration represents configuration errors. + ErrCodeConfiguration ErrorCode = "CONFIG_ERROR" + // ErrCodeValidation represents validation errors. + ErrCodeValidation ErrorCode = "VALIDATION_ERROR" + // ErrCodeTemplateRender represents template rendering errors. + ErrCodeTemplateRender ErrorCode = "TEMPLATE_ERROR" + // ErrCodeFileWrite represents file write errors. + ErrCodeFileWrite ErrorCode = "FILE_WRITE_ERROR" + // ErrCodeDependencyAnalysis represents dependency analysis errors. + ErrCodeDependencyAnalysis ErrorCode = "DEPENDENCY_ERROR" + // ErrCodeCacheAccess represents cache access errors. + ErrCodeCacheAccess ErrorCode = "CACHE_ERROR" + // ErrCodeUnknown represents unknown error types. + ErrCodeUnknown ErrorCode = "UNKNOWN_ERROR" +) + +// Error detection pattern constants. +const ( + // ErrorPatternFileNotFound is the error pattern for file not found errors. + ErrorPatternFileNotFound = "no such file or directory" + // ErrorPatternPermission is the error pattern for permission denied errors. + ErrorPatternPermission = "permission denied" +) + +// Exit code constants. +const ( + // ExitCodeError is the exit code for errors. + ExitCodeError = 1 +) + +// 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" +) + +// supportedThemes lists all available theme names (unexported to prevent modification). +var supportedThemes = []string{ + ThemeDefault, + ThemeGitHub, + ThemeGitLab, + ThemeMinimal, + ThemeProfessional, +} + +// GetSupportedThemes returns a copy of the supported theme names. +// Returns a new slice to prevent external modification of the internal list. +func GetSupportedThemes() []string { + themes := make([]string, len(supportedThemes)) + copy(themes, supportedThemes) + + return themes +} + +// Template placeholder constants for Git repository information. +const ( + // DefaultOrgPlaceholder is the default organization placeholder. + DefaultOrgPlaceholder = "your-org" + // DefaultRepoPlaceholder is the default repository placeholder. + DefaultRepoPlaceholder = "your-repo" + // DefaultUsesPlaceholder is the default uses statement placeholder. + DefaultUsesPlaceholder = "your-org/your-action@v1" +) + +// 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 - organized by functional groups. +const ( + // Repository/Project Configuration + // ConfigKeyOrganization is the organization config key. + ConfigKeyOrganization = "organization" + // ConfigKeyRepository is the repository config key. + ConfigKeyRepository = "repository" + // ConfigKeyVersion is the version config key. + ConfigKeyVersion = "version" + + // Template Configuration + // ConfigKeyTheme is the configuration key for theme. + ConfigKeyTheme = "theme" + // ConfigKeyTemplate is the template config key. + ConfigKeyTemplate = "template" + // ConfigKeyHeader is the header config key. + ConfigKeyHeader = "header" + // ConfigKeyFooter is the footer config key. + ConfigKeyFooter = "footer" + // ConfigKeySchema is the schema config key. + ConfigKeySchema = "schema" + + // Output Configuration + // ConfigKeyOutputFormat is the configuration key for output format. + ConfigKeyOutputFormat = "output_format" + // ConfigKeyOutputDir is the configuration key for output directory. + ConfigKeyOutputDir = "output_dir" + + // Feature Flags + // ConfigKeyAnalyzeDependencies is the configuration key for dependency analysis. + ConfigKeyAnalyzeDependencies = "analyze_dependencies" + // ConfigKeyShowSecurityInfo is the configuration key for security info display. + ConfigKeyShowSecurityInfo = "show_security_info" + + // Behavior Flags + // ConfigKeyVerbose is the configuration key for verbose mode. + ConfigKeyVerbose = "verbose" + // ConfigKeyQuiet is the configuration key for quiet mode. + ConfigKeyQuiet = "quiet" + + // GitHub Integration + // ConfigKeyGitHubToken is the configuration key for GitHub token. + ConfigKeyGitHubToken = "github_token" + + // Default Values Configuration + // ConfigKeyDefaults is the defaults config key. + ConfigKeyDefaults = "defaults" + // ConfigKeyDefaultsName is the defaults.name config key. + ConfigKeyDefaultsName = "defaults.name" + // ConfigKeyDefaultsDescription is the defaults.description config key. + ConfigKeyDefaultsDescription = "defaults.description" + // ConfigKeyDefaultsBrandingIcon is the defaults.branding.icon config key. + ConfigKeyDefaultsBrandingIcon = "defaults.branding.icon" + // ConfigKeyDefaultsBrandingColor is the defaults.branding.color config key. + ConfigKeyDefaultsBrandingColor = "defaults.branding.color" +) + +// ConfigurationSource represents different sources of configuration. +type ConfigurationSource int + +// Configuration source priority constants (lowest to highest priority). +const ( + // SourceDefaults represents default configuration values. + SourceDefaults ConfigurationSource = iota + // SourceGlobal represents global user configuration. + SourceGlobal + // SourceRepoOverride represents repository-specific overrides from global config. + SourceRepoOverride + // SourceRepoConfig represents repository-level configuration. + SourceRepoConfig + // SourceActionConfig represents action-specific configuration. + SourceActionConfig + // SourceEnvironment represents environment variable configuration. + SourceEnvironment + // SourceCLIFlags represents command-line flag configuration. + SourceCLIFlags +) + +// 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" +) + +// configSearchPaths defines the order in which config files are searched (unexported to prevent modification). +var configSearchPaths = []string{ + ConfigFilePatternHidden, + ConfigFilePatternConfig, + ConfigFilePatternGitHub, +} + +// GetConfigSearchPaths returns a copy of the config search paths. +// Returns a new slice to prevent external modification of the internal list. +func GetConfigSearchPaths() []string { + paths := make([]string, len(configSearchPaths)) + copy(paths, configSearchPaths) + + return paths +} + +// Output format constants. +const ( + // OutputFormatMarkdown is the Markdown output format. + OutputFormatMarkdown = "md" + // OutputFormatHTML is the HTML output format. + OutputFormatHTML = "html" + // OutputFormatJSON is the JSON output format. + OutputFormatJSON = "json" + // OutputFormatYAML is the YAML output format. + OutputFormatYAML = "yaml" + // OutputFormatTOML is the TOML output format. + OutputFormatTOML = "toml" + // OutputFormatASCIIDoc is the AsciiDoc output format. + OutputFormatASCIIDoc = "asciidoc" +) + +// Common file names. +const ( + // ReadmeMarkdown is the standard README markdown filename. + ReadmeMarkdown = "README.md" + // ReadmeASCIIDoc is the AsciiDoc README filename. + ReadmeASCIIDoc = "README.adoc" + // ActionDocsJSON is the JSON action docs filename. + ActionDocsJSON = "action-docs.json" + // CacheJSON is the cache file name. + CacheJSON = "cache.json" + // PackageJSON is the npm package.json filename. + PackageJSON = "package.json" + // TemplateReadme is the readme template filename. + TemplateReadme = "readme.tmpl" + // TemplateNameReadme is the template name used in template.New(). + TemplateNameReadme = "readme" + // ConfigYAML is the config.yaml filename. + ConfigYAML = "config.yaml" +) + +// Directory and path constants. +const ( + // DirGit is the .git directory name. + DirGit = ".git" + // DirTemplates is the templates directory. + DirTemplates = "templates/" + // DirTestdata is the testdata directory. + DirTestdata = "testdata" + // DirYAMLFixtures is the yaml-fixtures directory. + DirYAMLFixtures = "yaml-fixtures" + // PathEtcConfig is the etc config directory path. + PathEtcConfig = "/etc/gh-action-readme" + // PathXDGConfig is the XDG config path pattern. + PathXDGConfig = "gh-action-readme/config.yaml" + // AppName is the application name. + AppName = "gh-action-readme" + // EnvPrefix is the environment variable prefix. + EnvPrefix = "GH_ACTION_README" +) + +// Git constants. +const ( + // GitCommand is the git command name. + GitCommand = "git" + // GitDefaultBranch is the default git branch name. + GitDefaultBranch = "main" + // GitShowRef is the git show-ref command. + GitShowRef = "show-ref" + // GitVerify is the git --verify flag. + GitVerify = "--verify" + // GitQuiet is the git --quiet flag. + GitQuiet = "--quiet" + // GitConfigURL is the git config url pattern. + GitConfigURL = "url = " +) + +// Action type constants. +const ( + // ActionTypeComposite is the composite action type. + ActionTypeComposite = "composite" + // ActionTypeJavaScript is the JavaScript action type. + ActionTypeJavaScript = "javascript" + // ActionTypeDocker is the Docker action type. + ActionTypeDocker = "docker" + // ActionTypeInvalid is the invalid action type for testing. + ActionTypeInvalid = "invalid" + // ActionTypeMinimal is the minimal action type for testing. + ActionTypeMinimal = "minimal" +) + +// Programming language identifier constants. +const ( + // LangJavaScriptTypeScript is the JavaScript/TypeScript language identifier. + LangJavaScriptTypeScript = "JavaScript/TypeScript" + // LangGo is the Go language identifier. + LangGo = "Go" + // LangPython is the Python programming language identifier. + LangPython = "Python" +) + +// Update type constants for version comparison. +const ( + // UpdateTypeNone indicates no update is needed. + UpdateTypeNone = "none" + // UpdateTypeMajor indicates a major version update. + UpdateTypeMajor = "major" + // UpdateTypeMinor indicates a minor version update. + UpdateTypeMinor = "minor" + // UpdateTypePatch indicates a patch version update. + UpdateTypePatch = "patch" +) + +// Timeout constants for API operations. +const ( + // APICallTimeout is the timeout for API calls. + APICallTimeout = 10 * time.Second + // CacheDefaultTTL is the default cache time-to-live. + CacheDefaultTTL = 1 * time.Hour +) + +// GitHub URL constants. +const ( + // GitHubBaseURL is the base GitHub URL. + GitHubBaseURL = "https://github.com" + // MarketplaceBaseURL is the GitHub Marketplace base URL. + MarketplaceBaseURL = "https://github.com/marketplace/actions/" +) + +// Version validation constants. +const ( + // FullSHALength is the full commit SHA length. + FullSHALength = 40 + // MinSHALength is the minimum commit SHA length. + MinSHALength = 7 + // VersionPartsCount is the number of parts in semantic versioning. + VersionPartsCount = 3 +) + +// Path prefix constants. +const ( + // DockerPrefix is the Docker image prefix. + DockerPrefix = "docker://" + // LocalPathPrefix is the local path prefix. + LocalPathPrefix = "./" + // LocalPathUpPrefix is the parent directory path prefix. + LocalPathUpPrefix = "../" +) + +// File operation constants. +const ( + // BackupExtension is the file backup extension. + BackupExtension = ".backup" + // UsesFieldPrefix is the YAML uses field prefix. + UsesFieldPrefix = "uses: " +) + +// Cache key prefix constants. +const ( + // CacheKeyLatest is the cache key prefix for latest versions. + CacheKeyLatest = "latest:" + // CacheKeyRepo is the cache key prefix for repository data. + CacheKeyRepo = "repo:" +) + +// Miscellaneous analysis constants. +const ( + // ScriptLineEstimate is the estimated lines per script step. + ScriptLineEstimate = 10 +) + +// Scope level constants. +const ( + // ScopeGlobal is the global scope. + ScopeGlobal = "global" + // ScopeUnknown is the unknown scope. + ScopeUnknown = "unknown" +) + +// User input constants. +const ( + // InputYes is the yes confirmation input. + InputYes = "yes" + // InputAll is the all input option. + InputAll = "all" + // InputDryRun is the dry-run input option. + InputDryRun = "dry-run" +) + +// YAML format string constants for test fixtures and action generation. +const ( + // YAMLFieldName is the YAML name field format. + YAMLFieldName = "name: %s\n" + // YAMLFieldDescription is the YAML description field format. + YAMLFieldDescription = "description: %s\n" + // YAMLFieldRuns is the YAML runs field. + YAMLFieldRuns = "runs:\n" + // JSONCloseBrace is the JSON closing brace with newline. + JSONCloseBrace = " },\n" +) + +// UI and display constants. +const ( + // SymbolArrow is the arrow symbol for UI. + SymbolArrow = "โ–บ" + // FormatKeyValue is the key-value format string. + FormatKeyValue = "%s: %s" + // FormatDetailKeyValue is the detailed key-value format string. + FormatDetailKeyValue = " %s: %s" + // FormatPrompt is the prompt format string. + FormatPrompt = "%s: " + // FormatPromptDefault is the prompt with default format string. + FormatPromptDefault = "%s [%s]: " + // FormatEnvVar is the environment variable format string. + FormatEnvVar = "%s = %q\n" +) + +// CLI flag and command names. +const ( + // FlagFormat is the format flag name. + FlagFormat = "format" + // FlagOutputDir is the output-dir flag name. + FlagOutputDir = "output-dir" + // FlagOutputFormat is the output-format flag name. + FlagOutputFormat = "output-format" + // FlagOutput is the output flag name. + FlagOutput = "output" + // FlagRecursive is the recursive flag name. + FlagRecursive = "recursive" +) + +// Field names for validation. +const ( + // FieldName is the name field. + FieldName = "name" + // FieldDescription is the description field. + FieldDescription = "description" + // FieldRuns is the runs field. + FieldRuns = "runs" + // FieldRunsUsing is the runs.using field. + FieldRunsUsing = "runs.using" +) + +// Error patterns for error handling. +const ( + // ErrorPatternYAML is the yaml error pattern. + ErrorPatternYAML = "yaml" + // ErrorPatternGitHub is the github error pattern. + ErrorPatternGitHub = "github" + // ErrorPatternConfig is the config error pattern. + ErrorPatternConfig = "config" +) + +// Regex patterns. +const ( + // RegexGitSHA is the regex pattern for git SHA. + RegexGitSHA = "^[a-f0-9]{7,40}$" +) + +// Token prefixes for validation. +const ( + // TokenPrefixGitHubPersonal is the GitHub personal access token prefix. + TokenPrefixGitHubPersonal = "ghp_" // #nosec G101 -- token prefix pattern, not a credential + // TokenPrefixGitHubPAT is the GitHub PAT prefix. + TokenPrefixGitHubPAT = "github_pat_" // #nosec G101 -- token prefix pattern, not a credential + // TokenFallback is the fallback token value. + TokenFallback = "fallback-token" // #nosec G101 -- test value, not a credential +) + +// Section markers for output. +const ( + // SectionDetails is the details section marker. + SectionDetails = "\nDetails:" + // SectionSuggestions is the suggestions section marker. + SectionSuggestions = "\nSuggestions:" +) + +// URL patterns. +const ( + // URLPatternGitHubRepo is the GitHub repository URL pattern. + URLPatternGitHubRepo = "%s/%s" +) + +// Common error messages. +const ( + // ErrFailedToLoadActionConfig is the failed to load action config error. + ErrFailedToLoadActionConfig = "failed to load action config: %w" + // ErrFailedToLoadRepoConfig is the failed to load repo config error. + ErrFailedToLoadRepoConfig = "failed to load repo config: %w" + // ErrFailedToLoadGlobalConfig is the failed to load global config error. + ErrFailedToLoadGlobalConfig = "failed to load global config: %w" + // ErrFailedToReadConfigFile is the failed to read config file error. + ErrFailedToReadConfigFile = "failed to read config file: %w" + // ErrFailedToUnmarshalConfig is the failed to unmarshal config error. + ErrFailedToUnmarshalConfig = "failed to unmarshal config: %w" + // ErrFailedToGetXDGConfigDir is the failed to get XDG config directory error. + ErrFailedToGetXDGConfigDir = "failed to get XDG config directory: %w" + // ErrFailedToGetXDGConfigFile is the failed to get XDG config file path error. + ErrFailedToGetXDGConfigFile = "failed to get XDG config file path: %w" + // ErrFailedToCreateRateLimiter is the failed to create rate limiter error. + ErrFailedToCreateRateLimiter = "failed to create rate limiter: %w" + // ErrFailedToGetCurrentDir is the failed to get current directory error. + ErrFailedToGetCurrentDir = "failed to get current directory: %w" + // ErrCouldNotCreateDependencyAnalyzer is the could not create dependency analyzer error. + ErrCouldNotCreateDependencyAnalyzer = "Could not create dependency analyzer: %v" + // ErrErrorAnalyzing is the error analyzing error. + ErrErrorAnalyzing = "Error analyzing %s: %v" + // ErrErrorCheckingOutdated is the error checking outdated error. + ErrErrorCheckingOutdated = "Error checking outdated for %s: %v" + // ErrErrorGettingCurrentDir is the error getting current directory error. + ErrErrorGettingCurrentDir = "Error getting current directory: %v" + // ErrFailedToApplyUpdates is the failed to apply updates error. + ErrFailedToApplyUpdates = "Failed to apply updates: %v" + // ErrFailedToAccessCache is the failed to access cache error. + ErrFailedToAccessCache = "Failed to access cache: %v" + // ErrNoActionFilesFound is the no action files found error. + ErrNoActionFilesFound = "No action files found" + // ErrFailedToGetCurrentFilePath is the failed to get current file path error. + ErrFailedToGetCurrentFilePath = "failed to get current file path" + // ErrFailedToLoadActionFixture is the failed to load action fixture error. + ErrFailedToLoadActionFixture = "failed to load action fixture %s: %v" +) + +// Common message templates. +const ( + // MsgConfigHeader is the config file header. + MsgConfigHeader = "# gh-action-readme configuration file\n" + // MsgConfigWizardHeader is the config wizard header. + MsgConfigWizardHeader = "# Generated by the interactive configuration wizard\n\n" + // MsgConfigurationExportedTo is the configuration exported to success message. + MsgConfigurationExportedTo = "Configuration exported to: %s" +) + +// File permissions (additional). +const ( + // FilePermDir is the directory permission. + FilePermDir = 0750 +) + +// String returns a string representation of a ConfigurationSource. +func (s ConfigurationSource) String() string { + switch s { + case SourceDefaults: + return ConfigKeyDefaults + case SourceGlobal: + return ScopeGlobal + 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 ScopeUnknown + } +} diff --git a/appconstants/test_constants.go b/appconstants/test_constants.go new file mode 100644 index 0000000..bc9f4d5 --- /dev/null +++ b/appconstants/test_constants.go @@ -0,0 +1,72 @@ +package appconstants + +// This file contains constants used exclusively for testing. +// These are separated from production constants to: +// - Reduce API surface pollution in the main constants file +// - Make it clear which constants are test-only +// - Improve code organization and maintainability +// +// Note: These constants must remain exported so they can be used by +// test files in other packages (e.g., internal/*_test.go, main_test.go). + +// Test assertion message format templates. +const ( + // TestMsgExitCode is the format for exit code mismatch assertions. + TestMsgExitCode = "expected exit code %d, got %d" + + // TestMsgStdout is the format for standard output logging. + TestMsgStdout = "stdout: %s" + + // TestMsgStderr is the format for standard error logging. + TestMsgStderr = "stderr: %s" +) + +// Test fixture path constants. +const ( + // JavaScript action fixtures. + TestFixtureJavaScriptSimple = "actions/javascript/simple.yml" + + // Composite action fixtures. + TestFixtureCompositeBasic = "actions/composite/basic.yml" + TestFixtureCompositeWithDeps = "actions/composite/with-dependencies.yml" + + // Docker action fixtures. + TestFixtureDockerBasic = "actions/docker/basic.yml" + + // Invalid action fixtures. + TestFixtureInvalidMissingDescription = "actions/invalid/missing-description.yml" + TestFixtureInvalidInvalidUsing = "actions/invalid/invalid-using.yml" + + // Minimal/other fixtures. + TestFixtureMinimalAction = "minimal-action.yml" + TestFixtureProfessionalConfig = "professional-config.yml" + TestFixtureTestCompositeAction = "test-composite-action.yml" + TestFixtureMyNewAction = "my-new-action.yml" +) + +// Test file path constants. +const ( + TestPathActionYML = "action.yml" + TestPathActionYAML = "action.yaml" + TestPathConfigYML = "config.yml" + TestPathCustomConfigYML = "custom-config.yml" + TestPathNonexistentYML = "nonexistent.yml" +) + +// Test directory path constants. +const ( + TestDirSubdir = "subdir" + TestDirActions = "actions" + TestDirActionsDeploy = "actions/deploy" + TestDirActionsTest = "actions/test" + TestDirActionsComposite = "actions/composite" + TestDirActionsDocker = "actions/docker" + TestDirNested = "nested" + TestDirNestedDeep = "nested/deep" + + // Config directories. + TestDirConfigGhActionReadme = ".config/gh-action-readme" + TestDirDotConfig = ".config" + TestDirDotGitHub = ".github" + TestDirCacheGhActionReadme = ".cache/gh-action-readme" +) diff --git a/go.mod b/go.mod index 97863d5..36c3630 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/goccy/go-yaml v1.19.1 github.com/gofri/go-github-ratelimit v1.1.1 github.com/google/go-github/v74 v74.0.0 - github.com/schollz/progressbar/v3 v3.18.0 + github.com/schollz/progressbar/v3 v3.19.0 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 golang.org/x/oauth2 v0.34.0 @@ -19,7 +19,7 @@ require ( require ( github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect - github.com/google/go-querystring v1.1.0 // indirect + github.com/google/go-querystring v1.2.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -32,8 +32,8 @@ require ( github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/sys v0.38.0 // indirect - golang.org/x/term v0.37.0 // indirect - golang.org/x/text v0.31.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/term v0.38.0 // indirect + golang.org/x/text v0.32.0 // indirect gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect ) diff --git a/go.sum b/go.sum index da96d8c..afc4578 100644 --- a/go.sum +++ b/go.sum @@ -13,21 +13,17 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= -github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= -github.com/goccy/go-yaml v1.19.0 h1:EmkZ9RIsX+Uq4DYFowegAuJo8+xdX3T/2dwNPXbxEYE= -github.com/goccy/go-yaml v1.19.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/goccy/go-yaml v1.19.1 h1:3rG3+v8pkhRqoQ/88NYNMHYVGYztCOCIZ7UQhu7H+NE= github.com/goccy/go-yaml v1.19.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/gofri/go-github-ratelimit v1.1.1 h1:5TCOtFf45M2PjSYU17txqbiYBEzjOuK1+OhivbW69W0= github.com/gofri/go-github-ratelimit v1.1.1/go.mod h1:wGZlBbzHmIVjwDR3pZgKY7RBTV6gsQWxLVkpfwhcMJM= -github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-github/v74 v74.0.0 h1:yZcddTUn8DPbj11GxnMrNiAnXH14gNs559AsUpNpPgM= github.com/google/go-github/v74 v74.0.0/go.mod h1:ubn/YdyftV80VPSI26nSJvaEsTOnsjrxG3o9kJhcyak= -github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= -github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= +github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -53,14 +49,12 @@ github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/f github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= -github.com/schollz/progressbar/v3 v3.18.0 h1:uXdoHABRFmNIjUfte/Ex7WtuyVslrw2wVPQmCN62HpA= -github.com/schollz/progressbar/v3 v3.18.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec= +github.com/schollz/progressbar/v3 v3.19.0 h1:Ea18xuIRQXLAUidVDox3AbwfUhD0/1IvohyTutOIFoc= +github.com/schollz/progressbar/v3 v3.19.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= -github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= -github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= @@ -74,18 +68,15 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= -golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= -golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= +golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/integration_test.go b/integration_test.go index 721ff75..4b6684f 100644 --- a/integration_test.go +++ b/integration_test.go @@ -10,6 +10,7 @@ import ( "sync" "testing" + "github.com/ivuorinen/gh-action-readme/appconstants" "github.com/ivuorinen/gh-action-readme/testutil" ) @@ -135,8 +136,8 @@ func buildTestBinary(t *testing.T) string { // setupCompleteWorkflow creates a realistic project structure for testing. func setupCompleteWorkflow(t *testing.T, tmpDir string) { t.Helper() - testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), - testutil.MustReadFixture("actions/composite/basic.yml")) + testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.TestPathActionYML), + testutil.MustReadFixture(appconstants.TestFixtureCompositeBasic)) testutil.WriteTestFile(t, filepath.Join(tmpDir, "README.md"), "# Old README") testutil.WriteTestFile(t, filepath.Join(tmpDir, ".gitignore"), testutil.GitIgnoreContent) testutil.WriteTestFile(t, filepath.Join(tmpDir, "package.json"), testutil.PackageJSONContent) @@ -145,46 +146,36 @@ func setupCompleteWorkflow(t *testing.T, tmpDir string) { // setupMultiActionWorkflow creates a project with multiple actions. func setupMultiActionWorkflow(t *testing.T, tmpDir string) { t.Helper() - testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), - testutil.MustReadFixture("actions/javascript/simple.yml")) + testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.TestPathActionYML), + testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple)) - subDir := filepath.Join(tmpDir, "actions", "deploy") - _ = os.MkdirAll(subDir, 0750) // #nosec G301 -- test directory permissions - testutil.WriteTestFile(t, filepath.Join(subDir, "action.yml"), - testutil.MustReadFixture("actions/docker/basic.yml")) - - subDir2 := filepath.Join(tmpDir, "actions", "test") - _ = os.MkdirAll(subDir2, 0750) // #nosec G301 -- test directory permissions - testutil.WriteTestFile(t, filepath.Join(subDir2, "action.yml"), - testutil.MustReadFixture("actions/composite/basic.yml")) + testutil.CreateActionSubdir(t, tmpDir, "actions/deploy", appconstants.TestFixtureDockerBasic) + testutil.CreateActionSubdir(t, tmpDir, "actions/test", appconstants.TestFixtureCompositeBasic) } // setupConfigWorkflow creates a simple action for config testing. func setupConfigWorkflow(t *testing.T, tmpDir string) { t.Helper() - testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), - testutil.MustReadFixture("actions/javascript/simple.yml")) + testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.TestPathActionYML), + testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple)) } // setupErrorWorkflow creates an invalid action file for error testing. func setupErrorWorkflow(t *testing.T, tmpDir string) { t.Helper() - testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), - testutil.MustReadFixture("actions/invalid/missing-description.yml")) + testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.TestPathActionYML), + testutil.MustReadFixture(appconstants.TestFixtureInvalidMissingDescription)) } // setupConfigurationHierarchy creates a complex configuration hierarchy for testing. func setupConfigurationHierarchy(t *testing.T, tmpDir string) { t.Helper() // Create action file - testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), - testutil.MustReadFixture("actions/composite/basic.yml")) + testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.TestPathActionYML), + testutil.MustReadFixture(appconstants.TestFixtureCompositeBasic)) // Create global config - configDir := filepath.Join(tmpDir, ".config", "gh-action-readme") - _ = os.MkdirAll(configDir, 0750) // #nosec G301 -- test directory permissions - testutil.WriteTestFile(t, filepath.Join(configDir, "config.yml"), - testutil.MustReadFixture("configs/global/default.yml")) + testutil.WriteConfigFile(t, tmpDir, testutil.MustReadFixture("configs/global/default.yml")) // Create repo-specific config override testutil.WriteTestFile(t, filepath.Join(tmpDir, "gh-action-readme.yml"), @@ -195,36 +186,20 @@ func setupConfigurationHierarchy(t *testing.T, tmpDir string) { testutil.MustReadFixture("repo-config.yml")) // Set XDG config home to our test directory - t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, ".config")) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, appconstants.TestDirDotConfig)) } // setupMultiActionWithTemplates creates multiple actions with custom templates. func setupMultiActionWithTemplates(t *testing.T, tmpDir string) { t.Helper() // Root action - testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), - testutil.MustReadFixture("actions/javascript/simple.yml")) + testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.TestPathActionYML), + testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple)) // Nested actions with different types - actionsDir := filepath.Join(tmpDir, "actions") - - // Composite action - compositeDir := filepath.Join(actionsDir, "composite") - _ = os.MkdirAll(compositeDir, 0750) // #nosec G301 -- test directory permissions - testutil.WriteTestFile(t, filepath.Join(compositeDir, "action.yml"), - testutil.MustReadFixture("actions/composite/basic.yml")) - - // Docker action - dockerDir := filepath.Join(actionsDir, "docker") - _ = os.MkdirAll(dockerDir, 0750) // #nosec G301 -- test directory permissions - testutil.WriteTestFile(t, filepath.Join(dockerDir, "action.yml"), - testutil.MustReadFixture("actions/docker/basic.yml")) - - // Minimal action - minimalDir := filepath.Join(actionsDir, "minimal") - _ = os.MkdirAll(minimalDir, 0750) // #nosec G301 -- test directory permissions - testutil.WriteTestFile(t, filepath.Join(minimalDir, "action.yml"), - testutil.MustReadFixture("minimal-action.yml")) + testutil.CreateActionSubdir(t, tmpDir, "actions/composite", appconstants.TestFixtureCompositeBasic) + testutil.CreateActionSubdir(t, tmpDir, "actions/docker", appconstants.TestFixtureDockerBasic) + testutil.CreateActionSubdir(t, tmpDir, "actions/minimal", appconstants.TestFixtureMinimalAction) // Setup templates testutil.SetupTestTemplates(t, tmpDir) @@ -264,7 +239,7 @@ func setupDependencyAnalysisWorkflow(t *testing.T, tmpDir string) { "actions/upload-artifact@v3", }, ) - testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), compositeAction) + testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.TestPathActionYML), compositeAction) // Add package.json with npm dependencies testutil.WriteTestFile(t, filepath.Join(tmpDir, "package.json"), testutil.PackageJSONContent) @@ -281,18 +256,18 @@ func setupDependencyAnalysisWorkflow(t *testing.T, tmpDir string) { "aws-actions/configure-aws-credentials@v2", }, ) - testutil.WriteTestFile(t, filepath.Join(nestedDir, "action.yml"), nestedAction) + testutil.WriteTestFile(t, filepath.Join(nestedDir, appconstants.TestPathActionYML), nestedAction) } // setupConfigurationHierarchyWorkflow creates a comprehensive configuration hierarchy. func setupConfigurationHierarchyWorkflow(t *testing.T, tmpDir string) { t.Helper() // Create action file - testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), - testutil.MustReadFixture("actions/composite/basic.yml")) + testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.TestPathActionYML), + testutil.MustReadFixture(appconstants.TestFixtureCompositeBasic)) // Set up XDG config home - configHome := filepath.Join(tmpDir, ".config") + configHome := filepath.Join(tmpDir, appconstants.TestDirDotConfig) t.Setenv("XDG_CONFIG_HOME", configHome) // Global configuration (lowest priority) @@ -302,7 +277,7 @@ func setupConfigurationHierarchyWorkflow(t *testing.T, tmpDir string) { output_format: md verbose: false github_token: ghp_test1234567890abcdefghijklmnopqrstuvwxyz` - testutil.WriteTestFile(t, filepath.Join(globalConfigDir, "config.yml"), globalConfig) + testutil.WriteTestFile(t, filepath.Join(globalConfigDir, appconstants.TestPathConfigYML), globalConfig) // Repository configuration (medium priority) repoConfig := `theme: github @@ -330,8 +305,8 @@ output_dir: docs` func setupTemplateErrorScenario(t *testing.T, tmpDir string) { t.Helper() // Create valid action file - testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), - testutil.MustReadFixture("actions/javascript/simple.yml")) + testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.TestPathActionYML), + testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple)) // Create a broken template directory structure templatesDir := filepath.Join(tmpDir, "templates") @@ -348,8 +323,8 @@ func setupTemplateErrorScenario(t *testing.T, tmpDir string) { func setupConfigurationErrorScenario(t *testing.T, tmpDir string) { t.Helper() // Create valid action file - testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), - testutil.MustReadFixture("actions/javascript/simple.yml")) + testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.TestPathActionYML), + testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple)) // Create invalid configuration files invalidConfig := `theme: [invalid yaml structure @@ -360,12 +335,12 @@ verbose: not_a_boolean` // Create configuration with missing required fields incompleteConfig := `unknown_field: value invalid_theme: nonexistent` - configDir := filepath.Join(tmpDir, ".config", "gh-action-readme") + configDir := filepath.Join(tmpDir, appconstants.TestDirDotConfig, "gh-action-readme") _ = os.MkdirAll(configDir, 0750) // #nosec G301 -- test directory permissions - testutil.WriteTestFile(t, filepath.Join(configDir, "config.yml"), incompleteConfig) + testutil.WriteTestFile(t, filepath.Join(configDir, appconstants.TestPathConfigYML), incompleteConfig) // Set XDG config home - t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, ".config")) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, appconstants.TestDirDotConfig)) } // setupFileDiscoveryErrorScenario creates a scenario with file discovery issues. @@ -378,29 +353,23 @@ func setupFileDiscoveryErrorScenario(t *testing.T, tmpDir string) { // Create files with similar names but not action files testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.txt"), "not an action") testutil.WriteTestFile(t, filepath.Join(tmpDir, "workflow.yml"), - testutil.MustReadFixture("actions/javascript/simple.yml")) + testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple)) testutil.WriteTestFile(t, filepath.Join(tmpDir, "actions", "action.bak"), - testutil.MustReadFixture("actions/javascript/simple.yml")) + testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple)) } // setupServiceIntegrationErrorScenario creates a mixed scenario with various issues. func setupServiceIntegrationErrorScenario(t *testing.T, tmpDir string) { t.Helper() // Valid action at root - testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), - testutil.MustReadFixture("actions/javascript/simple.yml")) + testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.TestPathActionYML), + testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple)) // Invalid action in subdirectory - subDir := filepath.Join(tmpDir, "actions", "broken") - _ = os.MkdirAll(subDir, 0750) // #nosec G301 -- test directory permissions - testutil.WriteTestFile(t, filepath.Join(subDir, "action.yml"), - testutil.MustReadFixture("actions/invalid/missing-description.yml")) + testutil.CreateActionSubdir(t, tmpDir, "actions/broken", appconstants.TestFixtureInvalidMissingDescription) // Valid action in another subdirectory - validDir := filepath.Join(tmpDir, "actions", "valid") - _ = os.MkdirAll(validDir, 0750) // #nosec G301 -- test directory permissions - testutil.WriteTestFile(t, filepath.Join(validDir, "action.yml"), - testutil.MustReadFixture("actions/composite/basic.yml")) + testutil.CreateActionSubdir(t, tmpDir, "actions/valid", appconstants.TestFixtureCompositeBasic) // Broken configuration brokenConfig := `theme: nonexistent_theme @@ -784,8 +753,8 @@ type errorScenario struct { func testProjectSetup(t *testing.T, binaryPath, tmpDir string) { t.Helper() // Create a new GitHub Action project - testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), - testutil.MustReadFixture("my-new-action.yml")) + testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.TestPathActionYML), + testutil.MustReadFixture(appconstants.TestFixtureMyNewAction)) // Validate the action cmd := exec.Command(binaryPath, "validate") // #nosec G204 -- controlled test input @@ -822,8 +791,8 @@ func testDocumentationGeneration(t *testing.T, binaryPath, tmpDir string) { func testDependencyManagement(t *testing.T, binaryPath, tmpDir string) { t.Helper() // Update action to be composite with dependencies - testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), - testutil.MustReadFixture("actions/composite/basic.yml")) + testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.TestPathActionYML), + testutil.MustReadFixture(appconstants.TestFixtureCompositeBasic)) // List dependencies cmd := exec.Command(binaryPath, "deps", "list") @@ -1193,9 +1162,9 @@ func TestStressTestWorkflow(t *testing.T) { actionDir := filepath.Join(tmpDir, "action"+string(rune('A'+i))) _ = os.MkdirAll(actionDir, 0750) // #nosec G301 -- test directory permissions - actionContent := strings.ReplaceAll(testutil.MustReadFixture("actions/javascript/simple.yml"), + actionContent := strings.ReplaceAll(testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple), "Simple Action", "Action "+string(rune('A'+i))) - testutil.WriteTestFile(t, filepath.Join(actionDir, "action.yml"), actionContent) + testutil.WriteTestFile(t, filepath.Join(actionDir, appconstants.TestPathActionYML), actionContent) } // Test recursive processing @@ -1326,13 +1295,11 @@ func TestErrorRecoveryWorkflow(t *testing.T) { // Create a project with mixed valid and invalid files // Note: validation looks for files named exactly "action.yml" or "action.yaml" - testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), - testutil.MustReadFixture("actions/javascript/simple.yml")) + testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.TestPathActionYML), + testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple)) - subDir := filepath.Join(tmpDir, "subdir") - _ = os.MkdirAll(subDir, 0750) // #nosec G301 -- test directory permissions - testutil.WriteTestFile(t, filepath.Join(subDir, "action.yml"), - testutil.MustReadFixture("actions/invalid/missing-description.yml")) + testutil.CreateActionSubdir(t, tmpDir, appconstants.TestDirSubdir, + appconstants.TestFixtureInvalidMissingDescription) // Test that validation reports issues but doesn't crash cmd := exec.Command(binaryPath, "validate") // #nosec G204 -- controlled test input @@ -1378,8 +1345,8 @@ func TestConfigurationWorkflow(t *testing.T) { configHome := filepath.Join(tmpDir, "config") t.Setenv("XDG_CONFIG_HOME", configHome) - testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), - testutil.MustReadFixture("actions/javascript/simple.yml")) + testutil.WriteTestFile(t, filepath.Join(tmpDir, appconstants.TestPathActionYML), + testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple)) var err error @@ -1421,7 +1388,7 @@ func verifyConfigurationLoading(t *testing.T, tmpDir string) { // Since files may be cleaned up between runs, we'll check if the configuration loading succeeded // by verifying that the setup created the expected configuration files configFiles := []string{ - filepath.Join(tmpDir, ".config", "gh-action-readme", "config.yml"), + filepath.Join(tmpDir, appconstants.TestDirDotConfig, "gh-action-readme", appconstants.TestPathConfigYML), filepath.Join(tmpDir, "gh-action-readme.yml"), filepath.Join(tmpDir, ".github", "gh-action-readme.yml"), } @@ -1451,7 +1418,7 @@ func verifyProgressIndicators(t *testing.T, tmpDir string) { // The actual progress output is captured during the workflow step execution // Here we verify the infrastructure was set up correctly - actionFile := filepath.Join(tmpDir, "action.yml") + actionFile := filepath.Join(tmpDir, appconstants.TestPathActionYML) if _, err := os.Stat(actionFile); err != nil { t.Error("action file missing, progress tracking test setup failed") @@ -1473,10 +1440,10 @@ func verifyProgressIndicators(t *testing.T, tmpDir string) { func verifyFileDiscovery(t *testing.T, tmpDir string) { t.Helper() expectedActions := []string{ - filepath.Join(tmpDir, "action.yml"), - filepath.Join(tmpDir, "actions", "composite", "action.yml"), - filepath.Join(tmpDir, "actions", "docker", "action.yml"), - filepath.Join(tmpDir, "actions", "minimal", "action.yml"), + filepath.Join(tmpDir, appconstants.TestPathActionYML), + filepath.Join(tmpDir, "actions", "composite", appconstants.TestPathActionYML), + filepath.Join(tmpDir, "actions", "docker", appconstants.TestPathActionYML), + filepath.Join(tmpDir, "actions", "minimal", appconstants.TestPathActionYML), } // Verify action files were set up correctly and exist @@ -1515,13 +1482,13 @@ func verifyTemplateRendering(t *testing.T, tmpDir string) { actionFiles, _ := filepath.Glob(filepath.Join(tmpDir, "**/action.yml")) if len(actionFiles) == 0 { // Try different pattern - actionFiles, _ = filepath.Glob(filepath.Join(tmpDir, "action.yml")) + actionFiles, _ = filepath.Glob(filepath.Join(tmpDir, appconstants.TestPathActionYML)) if len(actionFiles) == 0 { t.Error("no action files found for template rendering verification") t.Logf( "Checked patterns: %s and %s", filepath.Join(tmpDir, "**/action.yml"), - filepath.Join(tmpDir, "action.yml"), + filepath.Join(tmpDir, appconstants.TestPathActionYML), ) return @@ -1563,7 +1530,7 @@ func verifyCompleteServiceChain(t *testing.T, tmpDir string) { // Verify the complete test environment was set up correctly requiredComponents := []string{ - filepath.Join(tmpDir, "action.yml"), + filepath.Join(tmpDir, appconstants.TestPathActionYML), filepath.Join(tmpDir, "package.json"), filepath.Join(tmpDir, ".gitignore"), } diff --git a/internal/errors/errors.go b/internal/apperrors/errors.go similarity index 53% rename from internal/errors/errors.go rename to internal/apperrors/errors.go index f58ffaf..ff38e78 100644 --- a/internal/errors/errors.go +++ b/internal/apperrors/errors.go @@ -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 { diff --git a/internal/errors/errors_test.go b/internal/apperrors/errors_test.go similarity index 65% rename from internal/errors/errors_test.go rename to internal/apperrors/errors_test.go index db8d9c9..9fac79c 100644 --- a/internal/errors/errors_test.go +++ b/internal/apperrors/errors_test.go @@ -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 { diff --git a/internal/errors/suggestions.go b/internal/apperrors/suggestions.go similarity index 89% rename from internal/errors/suggestions.go rename to internal/apperrors/suggestions.go index aaef5fc..e4b0363 100644 --- a/internal/errors/suggestions.go +++ b/internal/apperrors/suggestions.go @@ -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] } diff --git a/internal/errors/suggestions_test.go b/internal/apperrors/suggestions_test.go similarity index 57% rename from internal/errors/suggestions_test.go rename to internal/apperrors/suggestions_test.go index bd2a717..0ea65df 100644 --- a/internal/errors/suggestions_test.go +++ b/internal/apperrors/suggestions_test.go @@ -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}) }) } } diff --git a/internal/cache/cache.go b/internal/cache/cache.go index a54ba7c..cd154a5 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -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) } diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go index ad932e7..a606ec3 100644 --- a/internal/cache/cache_test.go +++ b/internal/cache/cache_test.go @@ -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 diff --git a/internal/config.go b/internal/config.go index 70c8078..b0a6671 100644 --- a/internal/config.go +++ b/internal/config.go @@ -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 diff --git a/internal/config_helper.go b/internal/config_helper.go new file mode 100644 index 0000000..896e613 --- /dev/null +++ b/internal/config_helper.go @@ -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 +} diff --git a/internal/config_test.go b/internal/config_test.go index b127400..d7df701 100644 --- a/internal/config_test.go +++ b/internal/config_test.go @@ -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} diff --git a/internal/configuration_loader.go b/internal/configuration_loader.go index 22a97f0..69e9add 100644 --- a/internal/configuration_loader.go +++ b/internal/configuration_loader.go @@ -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(), ", ")) } diff --git a/internal/configuration_loader_test.go b/internal/configuration_loader_test.go index a0ff48e..60bddf0 100644 --- a/internal/configuration_loader_test.go +++ b/internal/configuration_loader_test.go @@ -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 { diff --git a/internal/constants.go b/internal/constants.go deleted file mode 100644 index a525c21..0000000 --- a/internal/constants.go +++ /dev/null @@ -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" -) diff --git a/internal/dependencies/analyzer.go b/internal/dependencies/analyzer.go index 261457b..7eda8d3 100644 --- a/internal/dependencies/analyzer.go +++ b/internal/dependencies/analyzer.go @@ -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 diff --git a/internal/dependencies/analyzer_test.go b/internal/dependencies/analyzer_test.go index dd31cb2..b78b01f 100644 --- a/internal/dependencies/analyzer_test.go +++ b/internal/dependencies/analyzer_test.go @@ -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", diff --git a/internal/dependencies/parser.go b/internal/dependencies/parser.go index 56ae296..1e5ccf7 100644 --- a/internal/dependencies/parser.go +++ b/internal/dependencies/parser.go @@ -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 } diff --git a/internal/errorhandler.go b/internal/errorhandler.go index 79f1319..b42fa18 100644 --- a/internal/errorhandler.go +++ b/internal/errorhandler.go @@ -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 } } diff --git a/internal/focused_consumers.go b/internal/focused_consumers.go index 1328e3d..458ae49 100644 --- a/internal/focused_consumers.go +++ b/internal/focused_consumers.go @@ -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) diff --git a/internal/generator.go b/internal/generator.go index 5f65e5d..75213b2 100644 --- a/internal/generator.go +++ b/internal/generator.go @@ -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) diff --git a/internal/generator_test.go b/internal/generator_test.go index 49d39f9..3e0fe73 100644 --- a/internal/generator_test.go +++ b/internal/generator_test.go @@ -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 +} diff --git a/internal/git/detector.go b/internal/git/detector.go index 8cb6d80..2662bab 100644 --- a/internal/git/detector.go +++ b/internal/git/detector.go @@ -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 diff --git a/internal/git/detector_test.go b/internal/git/detector_test.go index de5aed6..35a8a0f 100644 --- a/internal/git/detector_test.go +++ b/internal/git/detector_test.go @@ -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) } }) } diff --git a/internal/github_helper.go b/internal/github_helper.go new file mode 100644 index 0000000..7aad5cf --- /dev/null +++ b/internal/github_helper.go @@ -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 "" +} diff --git a/internal/helpers/analyzer.go b/internal/helpers/analyzer.go index 7833160..acf7080 100644 --- a/internal/helpers/analyzer.go +++ b/internal/helpers/analyzer.go @@ -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 } diff --git a/internal/helpers/common_test.go b/internal/helpers/common_test.go index 4ac2e10..04905bd 100644 --- a/internal/helpers/common_test.go +++ b/internal/helpers/common_test.go @@ -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) }) } diff --git a/internal/interfaces.go b/internal/interfaces.go index e4ca038..967f93a 100644 --- a/internal/interfaces.go +++ b/internal/interfaces.go @@ -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. diff --git a/internal/interfaces_test.go b/internal/interfaces_test.go index 2d4e22b..289ea8d 100644 --- a/internal/interfaces_test.go +++ b/internal/interfaces_test.go @@ -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) } diff --git a/internal/json_writer.go b/internal/json_writer.go index bfe4cbf..cd79f0f 100644 --- a/internal/json_writer.go +++ b/internal/json_writer.go @@ -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. diff --git a/internal/output.go b/internal/output.go index d45ea85..0a65ce9 100644 --- a/internal/output.go +++ b/internal/output.go @@ -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 { diff --git a/internal/parser.go b/internal/parser.go index 59d1b48..87d63d1 100644 --- a/internal/parser.go +++ b/internal/parser.go @@ -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) diff --git a/internal/template.go b/internal/template.go index 5fc5c5f..45fc410 100644 --- a/internal/template.go +++ b/internal/template.go @@ -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 } diff --git a/internal/testoutput.go b/internal/testoutput.go index 81de95f..534804f 100644 --- a/internal/testoutput.go +++ b/internal/testoutput.go @@ -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 "" } diff --git a/internal/validation/validation.go b/internal/validation/validation.go index 802d487..7e70d78 100644 --- a/internal/validation/validation.go +++ b/internal/validation/validation.go @@ -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 } diff --git a/internal/validation/validation_test.go b/internal/validation/validation_test.go index 8d67e31..6650752 100644 --- a/internal/validation/validation_test.go +++ b/internal/validation/validation_test.go @@ -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) { diff --git a/internal/validator.go b/internal/validator.go index bed0fe3..2fefa83 100644 --- a/internal/validator.go +++ b/internal/validator.go @@ -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'", diff --git a/internal/viper_helper.go b/internal/viper_helper.go new file mode 100644 index 0000000..f82903d --- /dev/null +++ b/internal/viper_helper.go @@ -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 +} diff --git a/internal/wizard/detector.go b/internal/wizard/detector.go index 29b06e3..fd9ef60 100644 --- a/internal/wizard/detector.go +++ b/internal/wizard/detector.go @@ -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"} } } diff --git a/internal/wizard/exporter.go b/internal/wizard/exporter.go index e369712..f1bb996 100644 --- a/internal/wizard/exporter.go +++ b/internal/wizard/exporter.go @@ -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) } } diff --git a/internal/wizard/exporter_test.go b/internal/wizard/exporter_test.go index c7dfcc0..1e0f772 100644 --- a/internal/wizard/exporter_test.go +++ b/internal/wizard/exporter_test.go @@ -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() diff --git a/internal/wizard/validator.go b/internal/wizard/validator.go index 6a1278b..54dcc43 100644 --- a/internal/wizard/validator.go +++ b/internal/wizard/validator.go @@ -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_") || diff --git a/internal/wizard/wizard.go b/internal/wizard/wizard.go index 9cece76..3673c7f 100644 --- a/internal/wizard/wizard.go +++ b/internal/wizard/wizard.go @@ -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 diff --git a/main.go b/main.go index 343fbbb..4160a96 100644 --- a/main.go +++ b/main.go @@ -12,21 +12,15 @@ import ( "github.com/schollz/progressbar/v3" "github.com/spf13/cobra" + "github.com/ivuorinen/gh-action-readme/appconstants" "github.com/ivuorinen/gh-action-readme/internal" + "github.com/ivuorinen/gh-action-readme/internal/apperrors" "github.com/ivuorinen/gh-action-readme/internal/cache" "github.com/ivuorinen/gh-action-readme/internal/dependencies" - "github.com/ivuorinen/gh-action-readme/internal/errors" "github.com/ivuorinen/gh-action-readme/internal/helpers" "github.com/ivuorinen/gh-action-readme/internal/wizard" ) -const ( - // Export format constants. - formatJSON = "json" - formatTOML = "toml" - formatYAML = "yaml" -) - var ( // Version information (set by GoReleaser). version = "dev" @@ -69,9 +63,9 @@ func formatSize(totalSize int64) string { // resolveExportFormat converts a format string to wizard.ExportFormat. func resolveExportFormat(format string) wizard.ExportFormat { switch format { - case formatJSON: + case appconstants.OutputFormatJSON: return wizard.FormatJSON - case formatTOML: + case appconstants.OutputFormatTOML: return wizard.FormatTOML default: return wizard.FormatYAML @@ -105,9 +99,11 @@ func main() { } // Global flags - rootCmd.PersistentFlags().StringVar(&configFile, "config", "", "config file (default: XDG config directory)") - rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output") - rootCmd.PersistentFlags().BoolVarP(&quiet, "quiet", "q", false, "quiet output (overrides verbose)") + configDesc := "config file (default: XDG config directory)" + rootCmd.PersistentFlags().StringVar(&configFile, appconstants.ContextKeyConfig, "", configDesc) + rootCmd.PersistentFlags().BoolVarP(&verbose, appconstants.ConfigKeyVerbose, "v", false, "verbose output") + quietDesc := "quiet output (overrides verbose)" + rootCmd.PersistentFlags().BoolVarP(&quiet, appconstants.ConfigKeyQuiet, "q", false, quietDesc) rootCmd.AddCommand(newGenCmd()) rootCmd.AddCommand(newValidateCmd()) @@ -117,7 +113,7 @@ func main() { Short: "Print the version number", Long: "Print the version number and build information", Run: func(cmd *cobra.Command, _ []string) { - verbose, _ := cmd.Flags().GetBool("verbose") + verbose, _ := cmd.Flags().GetBool(appconstants.ConfigKeyVerbose) if verbose { fmt.Printf("gh-action-readme version %s\n", version) fmt.Printf(" commit: %s\n", commit) @@ -182,11 +178,11 @@ Examples: Run: genHandler, } - cmd.Flags().StringP("output-format", "f", "md", "output format: md, html, json, asciidoc") - cmd.Flags().StringP("output-dir", "o", ".", "output directory") - cmd.Flags().StringP("output", "", "", "custom output filename (overrides default naming)") - cmd.Flags().StringP("theme", "t", "", "template theme: github, gitlab, minimal, professional") - cmd.Flags().BoolP("recursive", "r", false, "search for action.yml files recursively") + cmd.Flags().StringP(appconstants.FlagOutputFormat, "f", "md", "output format: md, html, json, asciidoc") + cmd.Flags().StringP(appconstants.FlagOutputDir, "o", ".", "output directory") + cmd.Flags().StringP(appconstants.FlagOutput, "", "", "custom output filename (overrides default naming)") + cmd.Flags().StringP(appconstants.ConfigKeyTheme, "t", "", "template theme: github, gitlab, minimal, professional") + cmd.Flags().BoolP(appconstants.FlagRecursive, "r", false, "search for action.yml files recursively") return cmd } @@ -218,7 +214,7 @@ func genHandler(cmd *cobra.Command, args []string) { var err error targetPath, err = helpers.GetCurrentDir() if err != nil { - output.Error("Error getting current directory: %v", err) + output.Error(appconstants.ErrErrorGettingCurrentDir, err) os.Exit(1) } } @@ -244,7 +240,7 @@ func genHandler(cmd *cobra.Command, args []string) { // Target is a directory workingDir = absTargetPath generator := internal.NewGenerator(globalConfig) // Temporary generator for discovery - recursive, _ := cmd.Flags().GetBool("recursive") + recursive, _ := cmd.Flags().GetBool(appconstants.FlagRecursive) actionFiles, err = generator.DiscoverActionFilesWithValidation( workingDir, recursive, @@ -306,10 +302,10 @@ func applyGlobalFlags(config *internal.AppConfig) { // applyCommandFlags applies command-specific flags. func applyCommandFlags(cmd *cobra.Command, config *internal.AppConfig) { - outputFormat, _ := cmd.Flags().GetString("output-format") - outputDir, _ := cmd.Flags().GetString("output-dir") - outputFilename, _ := cmd.Flags().GetString("output") - theme, _ := cmd.Flags().GetString("theme") + outputFormat, _ := cmd.Flags().GetString(appconstants.FlagOutputFormat) + outputDir, _ := cmd.Flags().GetString(appconstants.FlagOutputDir) + outputFilename, _ := cmd.Flags().GetString(appconstants.FlagOutput) + theme, _ := cmd.Flags().GetString(appconstants.ConfigKeyTheme) if outputFormat != "md" { config.OutputFormat = outputFormat @@ -363,11 +359,11 @@ func validateHandler(_ *cobra.Command, _ []string) { // Validate the discovered files if err := generator.ValidateFiles(actionFiles); err != nil { generator.Output.ErrorWithContext( - errors.ErrCodeValidation, + appconstants.ErrCodeValidation, "validation failed", map[string]string{ - "files_count": strconv.Itoa(len(actionFiles)), - internal.ContextKeyError: err.Error(), + "files_count": strconv.Itoa(len(actionFiles)), + appconstants.ContextKeyError: err.Error(), }, ) os.Exit(1) @@ -416,8 +412,8 @@ func newConfigCmd() *cobra.Command { Long: "Launch an interactive wizard to set up your configuration step by step", Run: configWizardHandler, } - initCmd.Flags().String("format", "yaml", "Export format: yaml, json, toml") - initCmd.Flags().String("output", "", "Output path (default: XDG config directory)") + initCmd.Flags().String(appconstants.FlagFormat, "yaml", "Export format: yaml, json, toml") + initCmd.Flags().String(appconstants.FlagOutput, "", "Output path (default: XDG config directory)") cmd.AddCommand(initCmd) cmd.AddCommand(&cobra.Command{ @@ -483,11 +479,11 @@ func configThemesHandler(_ *cobra.Command, _ []string) { name string desc string }{ - {internal.ThemeDefault, "Original simple template"}, - {internal.ThemeGitHub, "GitHub-style with badges and collapsible sections"}, - {internal.ThemeGitLab, "GitLab-focused with CI/CD examples"}, - {internal.ThemeMinimal, "Clean and concise documentation"}, - {internal.ThemeProfessional, "Comprehensive with troubleshooting and ToC"}, + {appconstants.ThemeDefault, "Original simple template"}, + {appconstants.ThemeGitHub, "GitHub-style with badges and collapsible sections"}, + {appconstants.ThemeGitLab, "GitLab-focused with CI/CD examples"}, + {appconstants.ThemeMinimal, "Clean and concise documentation"}, + {appconstants.ThemeProfessional, "Comprehensive with troubleshooting and ToC"}, } for _, theme := range themes { @@ -539,8 +535,8 @@ func newDepsCmd() *cobra.Command { Run: depsUpgradeHandler, } upgradeCmd.Flags().Bool("ci", false, "CI/CD mode: automatically pin all updates to commit SHAs") - upgradeCmd.Flags().Bool("all", false, "Update all outdated dependencies without prompts") - upgradeCmd.Flags().Bool("dry-run", false, "Show what would be updated without making changes") + upgradeCmd.Flags().Bool(appconstants.InputAll, false, "Update all outdated dependencies without prompts") + upgradeCmd.Flags().Bool(appconstants.InputDryRun, false, "Show what would be updated without making changes") cmd.AddCommand(upgradeCmd) pinCmd := &cobra.Command{ @@ -549,8 +545,8 @@ func newDepsCmd() *cobra.Command { Long: "Convert floating versions (like @v4) to pinned commit SHAs with version comments.", Run: depsUpgradeHandler, // Uses same handler with different flags } - pinCmd.Flags().Bool("all", false, "Pin all floating dependencies") - pinCmd.Flags().Bool("dry-run", false, "Show what would be pinned without making changes") + pinCmd.Flags().Bool(appconstants.InputAll, false, "Pin all floating dependencies") + pinCmd.Flags().Bool(appconstants.InputDryRun, false, "Show what would be pinned without making changes") cmd.AddCommand(pinCmd) return cmd @@ -588,7 +584,7 @@ func depsListHandler(_ *cobra.Command, _ []string) { output := createOutputManager(globalConfig.Quiet) currentDir, err := helpers.GetCurrentDir() if err != nil { - output.Error("Error getting current directory: %v", err) + output.Error(appconstants.ErrErrorGettingCurrentDir, err) os.Exit(1) } @@ -596,7 +592,7 @@ func depsListHandler(_ *cobra.Command, _ []string) { actionFiles, err := generator.DiscoverActionFilesWithValidation(currentDir, true, "dependency listing") if err != nil { // For deps list, we can continue if no files found (show warning instead of error) - output.Warning("No action files found") + output.Warning(appconstants.ErrNoActionFilesFound) return } @@ -765,7 +761,7 @@ func depsOutdatedHandler(_ *cobra.Command, _ []string) { output := createOutputManager(globalConfig.Quiet) currentDir, err := helpers.GetCurrentDir() if err != nil { - output.Error("Error getting current directory: %v", err) + output.Error(appconstants.ErrErrorGettingCurrentDir, err) os.Exit(1) } @@ -773,7 +769,7 @@ func depsOutdatedHandler(_ *cobra.Command, _ []string) { actionFiles, err := generator.DiscoverActionFilesWithValidation(currentDir, true, "outdated dependency analysis") if err != nil { // For deps outdated, we can continue if no files found (show warning instead of error) - output.Warning("No action files found") + output.Warning(appconstants.ErrNoActionFilesFound) return } @@ -794,9 +790,9 @@ func depsOutdatedHandler(_ *cobra.Command, _ []string) { // validateGitHubToken checks if GitHub token is available. func validateGitHubToken(output *internal.ColoredOutput) bool { if globalConfig.GitHubToken == "" { - contextualErr := errors.New(errors.ErrCodeGitHubAuth, "GitHub token not found"). - WithSuggestions(errors.GetSuggestions(errors.ErrCodeGitHubAuth, map[string]string{})...). - WithHelpURL(errors.GetHelpURL(errors.ErrCodeGitHubAuth)) + contextualErr := apperrors.New(appconstants.ErrCodeGitHubAuth, "GitHub token not found"). + WithSuggestions(apperrors.GetSuggestions(appconstants.ErrCodeGitHubAuth, map[string]string{})...). + WithHelpURL(apperrors.GetHelpURL(appconstants.ErrCodeGitHubAuth)) output.Warning("โš ๏ธ %s", contextualErr.Error()) @@ -818,14 +814,14 @@ func checkAllOutdated( for _, actionFile := range actionFiles { deps, err := analyzer.AnalyzeActionFile(actionFile) if err != nil { - output.Warning("Error analyzing %s: %v", actionFile, err) + output.Warning(appconstants.ErrErrorAnalyzing, actionFile, err) continue } outdated, err := analyzer.CheckOutdated(deps) if err != nil { - output.Warning("Error checking outdated for %s: %v", actionFile, err) + output.Warning(appconstants.ErrErrorCheckingOutdated, actionFile, err) continue } @@ -863,7 +859,7 @@ func depsUpgradeHandler(cmd *cobra.Command, _ []string) { output := createOutputManager(globalConfig.Quiet) currentDir, err := helpers.GetCurrentDir() if err != nil { - output.Error("Error getting current directory: %v", err) + output.Error(appconstants.ErrErrorGettingCurrentDir, err) os.Exit(1) } @@ -875,8 +871,8 @@ func depsUpgradeHandler(cmd *cobra.Command, _ []string) { // Parse flags and show mode ciMode, _ := cmd.Flags().GetBool("ci") - allFlag, _ := cmd.Flags().GetBool("all") - dryRun, _ := cmd.Flags().GetBool("dry-run") + allFlag, _ := cmd.Flags().GetBool(appconstants.InputAll) + dryRun, _ := cmd.Flags().GetBool(appconstants.InputDryRun) isPinCmd := cmd.Use == "pin" showUpgradeMode(output, ciMode, isPinCmd) @@ -915,7 +911,7 @@ func setupDepsUpgrade(output *internal.ColoredOutput, currentDir string) (*depen analyzer, err := generator.CreateDependencyAnalyzer() if err != nil { - output.Warning("Could not create dependency analyzer: %v", err) + output.Warning(appconstants.ErrCouldNotCreateDependencyAnalyzer, err) return nil, nil } @@ -952,14 +948,14 @@ func collectAllUpdates( for _, actionFile := range actionFiles { deps, err := analyzer.AnalyzeActionFile(actionFile) if err != nil { - output.Warning("Error analyzing %s: %v", actionFile, err) + output.Warning(appconstants.ErrErrorAnalyzing, actionFile, err) continue } outdated, err := analyzer.CheckOutdated(deps) if err != nil { - output.Warning("Error checking outdated for %s: %v", actionFile, err) + output.Warning(appconstants.ErrErrorCheckingOutdated, actionFile, err) continue } @@ -1008,7 +1004,7 @@ func applyUpdates( if automatic { output.Info("\n๐Ÿš€ Applying updates...") if err := analyzer.ApplyPinnedUpdates(allUpdates); err != nil { - output.Error("Failed to apply updates: %v", err) + output.Error(appconstants.ErrFailedToApplyUpdates, err) os.Exit(1) } output.Success("โœ… Successfully updated %d dependencies with pinned commit SHAs", len(allUpdates)) @@ -1017,7 +1013,7 @@ func applyUpdates( output.Info("\nโ“ This will modify your action.yml files. Continue? (y/N): ") var response string _, _ = fmt.Scanln(&response) // User input, scan error not critical - if strings.ToLower(response) != "y" && strings.ToLower(response) != "yes" { + if strings.ToLower(response) != "y" && strings.ToLower(response) != appconstants.InputYes { output.Info("Canceled") return @@ -1025,7 +1021,7 @@ func applyUpdates( output.Info("๐Ÿš€ Applying updates...") if err := analyzer.ApplyPinnedUpdates(allUpdates); err != nil { - output.Error("Failed to apply updates: %v", err) + output.Error(appconstants.ErrFailedToApplyUpdates, err) os.Exit(1) } output.Success("โœ… Successfully updated %d dependencies", len(allUpdates)) @@ -1046,7 +1042,7 @@ func cacheClearHandler(_ *cobra.Command, _ []string) { // Create a cache instance cacheInstance, err := cache.NewCache(cache.DefaultConfig()) if err != nil { - output.Error("Failed to access cache: %v", err) + output.Error(appconstants.ErrFailedToAccessCache, err) os.Exit(1) } @@ -1064,7 +1060,7 @@ func cacheStatsHandler(_ *cobra.Command, _ []string) { // Create a cache instance cacheInstance, err := cache.NewCache(cache.DefaultConfig()) if err != nil { - output.Error("Failed to access cache: %v", err) + output.Error(appconstants.ErrFailedToAccessCache, err) os.Exit(1) } @@ -1090,14 +1086,14 @@ func cachePathHandler(_ *cobra.Command, _ []string) { // Create a cache instance cacheInstance, err := cache.NewCache(cache.DefaultConfig()) if err != nil { - output.Error("Failed to access cache: %v", err) + output.Error(appconstants.ErrFailedToAccessCache, err) os.Exit(1) } stats := cacheInstance.Stats() cachePath, ok := stats["cache_dir"].(string) if !ok { - cachePath = "unknown" + cachePath = appconstants.ScopeUnknown } output.Bold("Cache Directory:") @@ -1123,8 +1119,8 @@ func configWizardHandler(cmd *cobra.Command, _ []string) { } // Get export format and output path - format, _ := cmd.Flags().GetString("format") - outputPath, _ := cmd.Flags().GetString("output") + format, _ := cmd.Flags().GetString(appconstants.FlagFormat) + outputPath, _ := cmd.Flags().GetString(appconstants.FlagOutput) // Create exporter and export configuration exporter := wizard.NewConfigExporter(output) diff --git a/main_test.go b/main_test.go index e4ee243..3681751 100644 --- a/main_test.go +++ b/main_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + "github.com/ivuorinen/gh-action-readme/appconstants" "github.com/ivuorinen/gh-action-readme/internal" "github.com/ivuorinen/gh-action-readme/internal/wizard" "github.com/ivuorinen/gh-action-readme/testutil" @@ -51,8 +52,7 @@ func TestCLICommands(t *testing.T) { args: []string{"gen", "--output-format", "md"}, setupFunc: func(t *testing.T, tmpDir string) { t.Helper() - actionPath := filepath.Join(tmpDir, "action.yml") - testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture("actions/javascript/simple.yml")) + createTestActionFile(t, tmpDir, appconstants.TestFixtureJavaScriptSimple) }, wantExit: 0, }, @@ -61,8 +61,7 @@ func TestCLICommands(t *testing.T) { args: []string{"gen", "--theme", "github", "--output-format", "json"}, setupFunc: func(t *testing.T, tmpDir string) { t.Helper() - actionPath := filepath.Join(tmpDir, "action.yml") - testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture("actions/javascript/simple.yml")) + createTestActionFile(t, tmpDir, appconstants.TestFixtureJavaScriptSimple) }, wantExit: 0, }, @@ -77,8 +76,7 @@ func TestCLICommands(t *testing.T) { args: []string{"validate"}, setupFunc: func(t *testing.T, tmpDir string) { t.Helper() - actionPath := filepath.Join(tmpDir, "action.yml") - testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture("actions/javascript/simple.yml")) + createTestActionFile(t, tmpDir, appconstants.TestFixtureJavaScriptSimple) }, wantExit: 0, wantStdout: "All validations passed successfully", @@ -88,12 +86,7 @@ func TestCLICommands(t *testing.T) { args: []string{"validate"}, setupFunc: func(t *testing.T, tmpDir string) { t.Helper() - actionPath := filepath.Join(tmpDir, "action.yml") - testutil.WriteTestFile( - t, - actionPath, - testutil.MustReadFixture("actions/invalid/missing-description.yml"), - ) + createTestActionFile(t, tmpDir, appconstants.TestFixtureInvalidMissingDescription) }, wantExit: 1, }, @@ -132,8 +125,8 @@ func TestCLICommands(t *testing.T) { args: []string{"deps", "list"}, setupFunc: func(t *testing.T, tmpDir string) { t.Helper() - 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)) }, wantExit: 0, }, @@ -169,44 +162,8 @@ func TestCLICommands(t *testing.T) { } // Run the command in the temporary directory - cmd := exec.Command(binaryPath, tt.args...) // #nosec G204 -- controlled test input - cmd.Dir = tmpDir - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - err := cmd.Run() - - // Check exit code - exitCode := 0 - if err != nil { - if exitError, ok := err.(*exec.ExitError); ok { - exitCode = exitError.ExitCode() - } else { - t.Fatalf("unexpected error running command: %v", err) - } - } - - if exitCode != tt.wantExit { - t.Errorf("expected exit code %d, got %d", tt.wantExit, exitCode) - t.Logf("stdout: %s", stdout.String()) - t.Logf("stderr: %s", stderr.String()) - } - - // Check stdout if specified - if tt.wantStdout != "" { - if !strings.Contains(stdout.String(), tt.wantStdout) { - t.Errorf("expected stdout to contain %q, got: %s", tt.wantStdout, stdout.String()) - } - } - - // Check stderr if specified - if tt.wantStderr != "" { - if !strings.Contains(stderr.String(), tt.wantStderr) { - t.Errorf("expected stderr to contain %q, got: %s", tt.wantStderr, stderr.String()) - } - } + result := runTestCommand(binaryPath, tt.args, tmpDir) + assertCommandResult(t, result, tt.wantExit, tt.wantStdout, tt.wantStderr) }) } } @@ -257,32 +214,17 @@ func TestCLIFlags(t *testing.T) { tmpDir, cleanup := testutil.TempDir(t) defer cleanup() - cmd := exec.Command(binaryPath, tt.args...) // #nosec G204 -- controlled test input - cmd.Dir = tmpDir + result := runTestCommand(binaryPath, tt.args, tmpDir) - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - err := cmd.Run() - exitCode := 0 - if err != nil { - if exitError, ok := err.(*exec.ExitError); ok { - exitCode = exitError.ExitCode() - } - } - - if exitCode != tt.wantExit { - t.Errorf("expected exit code %d, got %d", tt.wantExit, exitCode) - t.Logf("stdout: %s", stdout.String()) - t.Logf("stderr: %s", stderr.String()) + if result.exitCode != tt.wantExit { + t.Errorf(appconstants.TestMsgExitCode, tt.wantExit, result.exitCode) + t.Logf(appconstants.TestMsgStdout, result.stdout) + t.Logf(appconstants.TestMsgStderr, result.stderr) } if tt.contains != "" { - output := stdout.String() + stderr.String() - if !strings.Contains(output, tt.contains) { - t.Errorf("expected output to contain %q, got: %s", tt.contains, output) - } + // For contains check, look in both stdout and stderr + assertCommandResult(t, result, tt.wantExit, tt.contains, "") } }) } @@ -297,14 +239,8 @@ func TestCLIRecursiveFlag(t *testing.T) { defer cleanup() // Create nested directory structure with action files - subDir := filepath.Join(tmpDir, "subdir") - _ = os.MkdirAll(subDir, 0750) // #nosec G301 -- test directory permissions - - // Write action files - testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), - testutil.MustReadFixture("actions/javascript/simple.yml")) - testutil.WriteTestFile(t, filepath.Join(subDir, "action.yml"), - testutil.MustReadFixture("actions/composite/basic.yml")) + testutil.WriteActionFixture(t, tmpDir, appconstants.TestFixtureJavaScriptSimple) + testutil.CreateActionSubdir(t, tmpDir, appconstants.TestDirSubdir, appconstants.TestFixtureCompositeBasic) tests := []struct { name string @@ -328,31 +264,12 @@ func TestCLIRecursiveFlag(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - cmd := exec.Command(binaryPath, tt.args...) // #nosec G204 -- controlled test input - cmd.Dir = tmpDir - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - err := cmd.Run() - exitCode := 0 - if err != nil { - if exitError, ok := err.(*exec.ExitError); ok { - exitCode = exitError.ExitCode() - } - } - - if exitCode != tt.wantExit { - t.Errorf("expected exit code %d, got %d", tt.wantExit, exitCode) - t.Logf("stdout: %s", stdout.String()) - t.Logf("stderr: %s", stderr.String()) - } + result := runTestCommand(binaryPath, tt.args, tmpDir) + assertCommandResult(t, result, tt.wantExit, "", "") // For recursive tests, check that appropriate number of files were processed // This is a simple heuristic - could be made more sophisticated - output := stdout.String() - if tt.minFiles > 1 && !strings.Contains(output, "subdir") { + if tt.minFiles > 1 && !strings.Contains(result.stdout, appconstants.TestDirSubdir) { t.Errorf("expected recursive processing to include subdirectory") } }) @@ -376,8 +293,7 @@ func TestCLIErrorHandling(t *testing.T) { args: []string{"gen", "--output-dir", "/root/restricted"}, setupFunc: func(t *testing.T, tmpDir string) { t.Helper() - testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), - testutil.MustReadFixture("actions/javascript/simple.yml")) + createTestActionFile(t, tmpDir, appconstants.TestFixtureJavaScriptSimple) }, wantExit: 1, wantError: "encountered 1 errors during batch processing", @@ -387,7 +303,11 @@ func TestCLIErrorHandling(t *testing.T) { args: []string{"validate"}, setupFunc: func(t *testing.T, tmpDir string) { t.Helper() - testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), "invalid: yaml: content: [") + testutil.WriteTestFile( + t, + filepath.Join(tmpDir, appconstants.TestPathActionYML), + "invalid: yaml: content: [", + ) }, wantExit: 1, }, @@ -396,8 +316,7 @@ func TestCLIErrorHandling(t *testing.T) { args: []string{"gen", "--output-format", "unknown"}, setupFunc: func(t *testing.T, tmpDir string) { t.Helper() - testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), - testutil.MustReadFixture("actions/javascript/simple.yml")) + createTestActionFile(t, tmpDir, appconstants.TestFixtureJavaScriptSimple) }, wantExit: 1, }, @@ -406,8 +325,7 @@ func TestCLIErrorHandling(t *testing.T) { args: []string{"gen", "--theme", "nonexistent-theme"}, setupFunc: func(t *testing.T, tmpDir string) { t.Helper() - testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), - testutil.MustReadFixture("actions/javascript/simple.yml")) + createTestActionFile(t, tmpDir, appconstants.TestFixtureJavaScriptSimple) }, wantExit: 1, }, @@ -422,29 +340,16 @@ func TestCLIErrorHandling(t *testing.T) { tt.setupFunc(t, tmpDir) } - cmd := exec.Command(binaryPath, tt.args...) // #nosec G204 -- controlled test input - cmd.Dir = tmpDir + result := runTestCommand(binaryPath, tt.args, tmpDir) - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - err := cmd.Run() - exitCode := 0 - if err != nil { - if exitError, ok := err.(*exec.ExitError); ok { - exitCode = exitError.ExitCode() - } - } - - if exitCode != tt.wantExit { - t.Errorf("expected exit code %d, got %d", tt.wantExit, exitCode) - t.Logf("stdout: %s", stdout.String()) - t.Logf("stderr: %s", stderr.String()) + if result.exitCode != tt.wantExit { + t.Errorf(appconstants.TestMsgExitCode, tt.wantExit, result.exitCode) + t.Logf(appconstants.TestMsgStdout, result.stdout) + t.Logf(appconstants.TestMsgStderr, result.stderr) } if tt.wantError != "" { - output := stdout.String() + stderr.String() + output := result.stdout + result.stderr if !strings.Contains(strings.ToLower(output), strings.ToLower(tt.wantError)) { t.Errorf("expected error containing %q, got: %s", tt.wantError, output) } @@ -481,23 +386,7 @@ func TestCLIConfigInitialization(t *testing.T) { // Check if config file was created (note: uses .yaml extension, not .yml) expectedConfigPath := filepath.Join(tmpDir, "gh-action-readme", "config.yaml") - if _, err := os.Stat(expectedConfigPath); os.IsNotExist(err) { - t.Errorf("config file was not created at expected path: %s", expectedConfigPath) - // List what was actually created to help debug - if entries, err := os.ReadDir(tmpDir); err == nil { - t.Logf("Contents of tmpDir %s:", tmpDir) - for _, entry := range entries { - t.Logf(" %s", entry.Name()) - if entry.IsDir() { - if subEntries, err := os.ReadDir(filepath.Join(tmpDir, entry.Name())); err == nil { - for _, sub := range subEntries { - t.Logf(" %s", sub.Name()) - } - } - } - } - } - } + testutil.AssertFileExists(t, expectedConfigPath) } // Unit Tests for Helper Functions @@ -557,9 +446,9 @@ func TestResolveExportFormat(t *testing.T) { format string expected wizard.ExportFormat }{ - {"json format", formatJSON, wizard.FormatJSON}, - {"toml format", formatTOML, wizard.FormatTOML}, - {"yaml format", formatYAML, wizard.FormatYAML}, + {"json format", appconstants.OutputFormatJSON, wizard.FormatJSON}, + {"toml format", appconstants.OutputFormatTOML, wizard.FormatTOML}, + {"yaml format", appconstants.OutputFormatYAML, wizard.FormatYAML}, {"default format", "unknown", wizard.FormatYAML}, {"empty format", "", wizard.FormatYAML}, } @@ -662,3 +551,69 @@ func TestNewSchemaCmd(t *testing.T) { t.Error("expected command to have a Run or RunE function") } } + +// cmdResult holds the results of a command execution. +type cmdResult struct { + stdout string + stderr string + exitCode int +} + +// runTestCommand executes a command with the given args in the specified directory. +// It returns the stdout, stderr, and exit code. +func runTestCommand(binaryPath string, args []string, dir string) cmdResult { + cmd := exec.Command(binaryPath, args...) // #nosec G204 -- controlled test input + cmd.Dir = dir + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + exitCode := 0 + if err != nil { + if exitError, ok := err.(*exec.ExitError); ok { + exitCode = exitError.ExitCode() + } + } + + return cmdResult{ + stdout: stdout.String(), + stderr: stderr.String(), + exitCode: exitCode, + } +} + +// createTestActionFile is a helper that creates a test action file from a fixture. +// It writes the specified fixture to action.yml in the given temporary directory. +func createTestActionFile(t *testing.T, tmpDir, fixture string) { + t.Helper() + actionPath := filepath.Join(tmpDir, appconstants.TestPathActionYML) + testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture(fixture)) +} + +// assertCommandResult is a helper that asserts the result of a command execution. +// It checks the exit code, and optionally checks for expected content in stdout and stderr. +func assertCommandResult(t *testing.T, result cmdResult, wantExit int, wantStdout, wantStderr string) { + t.Helper() + + if result.exitCode != wantExit { + t.Errorf(appconstants.TestMsgExitCode, wantExit, result.exitCode) + t.Logf(appconstants.TestMsgStdout, result.stdout) + t.Logf(appconstants.TestMsgStderr, result.stderr) + } + + // Check stdout if specified + if wantStdout != "" { + if !strings.Contains(result.stdout, wantStdout) { + t.Errorf("expected stdout to contain %q, got: %s", wantStdout, result.stdout) + } + } + + // Check stderr if specified + if wantStderr != "" { + if !strings.Contains(result.stderr, wantStderr) { + t.Errorf("expected stderr to contain %q, got: %s", wantStderr, result.stderr) + } + } +} diff --git a/templates_embed/embed.go b/templates_embed/embed.go index 35387b3..91745c7 100644 --- a/templates_embed/embed.go +++ b/templates_embed/embed.go @@ -11,6 +11,8 @@ import ( "os" "path/filepath" "strings" + + "github.com/ivuorinen/gh-action-readme/appconstants" ) // embeddedTemplates contains all template files embedded in the binary @@ -24,8 +26,8 @@ func GetEmbeddedTemplate(templatePath string) ([]byte, error) { cleanPath := strings.TrimPrefix(filepath.ToSlash(templatePath), "/") // If path doesn't start with templates/, prepend it - if !strings.HasPrefix(cleanPath, "templates/") { - cleanPath = "templates/" + cleanPath + if !strings.HasPrefix(cleanPath, appconstants.DirTemplates) { + cleanPath = appconstants.DirTemplates + cleanPath } return embeddedTemplates.ReadFile(cleanPath) @@ -39,8 +41,8 @@ func GetEmbeddedTemplateFS() fs.FS { // IsEmbeddedTemplateAvailable checks if a template exists in the embedded filesystem. func IsEmbeddedTemplateAvailable(templatePath string) bool { cleanPath := strings.TrimPrefix(filepath.ToSlash(templatePath), "/") - if !strings.HasPrefix(cleanPath, "templates/") { - cleanPath = "templates/" + cleanPath + if !strings.HasPrefix(cleanPath, appconstants.DirTemplates) { + cleanPath = appconstants.DirTemplates + cleanPath } _, err := embeddedTemplates.ReadFile(cleanPath) diff --git a/testdata/composite-action/action.yml b/testdata/composite-action/action.yml index 5be7976..9c82fbc 100644 --- a/testdata/composite-action/action.yml +++ b/testdata/composite-action/action.yml @@ -18,13 +18,13 @@ runs: using: composite steps: - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 0 token: ${{ github.token }} - name: Setup Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 with: node-version: ${{ inputs.node-version }} cache: 'npm' @@ -44,10 +44,22 @@ runs: NODE_ENV: test - name: Build project - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + shell: bash id: build - with: - node-version: ${{ inputs.node-version }} + run: | + set -u + cd ${{ inputs.working-directory }} + npm run build + # Capture exit code immediately to avoid fragility with intervening commands + build_exit_code=$? + # Write result to GITHUB_OUTPUT based on captured exit code + # Note: We do not use 'set -e' because we need to handle build failures + # gracefully and report them via the output rather than failing the step + if [ $build_exit_code -eq 0 ]; then + echo "result=success" >> $GITHUB_OUTPUT + else + echo "result=failure" >> $GITHUB_OUTPUT + fi branding: icon: package diff --git a/testutil/fixtures.go b/testutil/fixtures.go index f5f523e..d1562ad 100644 --- a/testutil/fixtures.go +++ b/testutil/fixtures.go @@ -10,6 +10,8 @@ import ( "sync" "github.com/goccy/go-yaml" + + "github.com/ivuorinen/gh-action-readme/appconstants" ) // fixtureCache provides thread-safe caching of fixture content. @@ -48,12 +50,12 @@ func mustReadFixture(filename string) string { // Load from disk _, currentFile, _, ok := runtime.Caller(0) if !ok { - panic("failed to get current file path") + panic(appconstants.ErrFailedToGetCurrentFilePath) } // Get the project root (go up from testutil/fixtures.go to project root) projectRoot := filepath.Dir(filepath.Dir(currentFile)) - fixturePath := filepath.Join(projectRoot, "testdata", "yaml-fixtures", filename) + fixturePath := filepath.Join(projectRoot, appconstants.DirTestdata, appconstants.DirYAMLFixtures, filename) contentBytes, err := os.ReadFile(fixturePath) // #nosec G304 -- test fixture path from project structure if err != nil { @@ -68,28 +70,20 @@ func mustReadFixture(filename string) string { return content } -// Constants for fixture management. -const ( - // YmlExtension represents the standard YAML file extension. - YmlExtension = ".yml" - // YamlExtension represents the alternative YAML file extension. - YamlExtension = ".yaml" -) - // ActionType represents the type of GitHub Action being tested. type ActionType string const ( // ActionTypeJavaScript represents JavaScript-based GitHub Actions that run on Node.js. - ActionTypeJavaScript ActionType = "javascript" + ActionTypeJavaScript ActionType = ActionType(appconstants.ActionTypeJavaScript) // ActionTypeComposite represents composite GitHub Actions that combine multiple steps. - ActionTypeComposite ActionType = "composite" + ActionTypeComposite ActionType = ActionType(appconstants.ActionTypeComposite) // ActionTypeDocker represents Docker-based GitHub Actions that run in containers. - ActionTypeDocker ActionType = "docker" + ActionTypeDocker ActionType = ActionType(appconstants.ActionTypeDocker) // ActionTypeInvalid represents invalid or malformed GitHub Actions for testing error scenarios. - ActionTypeInvalid ActionType = "invalid" + ActionTypeInvalid ActionType = ActionType(appconstants.ActionTypeInvalid) // ActionTypeMinimal represents minimal GitHub Actions with basic configuration. - ActionTypeMinimal ActionType = "minimal" + ActionTypeMinimal ActionType = ActionType(appconstants.ActionTypeMinimal) ) // TestScenario represents a structured test scenario with metadata. @@ -338,11 +332,11 @@ var PackageJSONContent = func() string { result += " \"scripts\": {\n" result += " \"test\": \"jest\",\n" result += " \"build\": \"webpack\"\n" - result += " },\n" + result += appconstants.JSONCloseBrace result += " \"dependencies\": {\n" result += " \"@actions/core\": \"^1.10.0\",\n" result += " \"@actions/github\": \"^5.1.1\"\n" - result += " },\n" + result += appconstants.JSONCloseBrace result += " \"devDependencies\": {\n" result += " \"jest\": \"^29.0.0\",\n" result += " \"webpack\": \"^5.0.0\"\n" @@ -356,12 +350,12 @@ var PackageJSONContent = func() string { func NewFixtureManager() *FixtureManager { _, currentFile, _, ok := runtime.Caller(0) if !ok { - panic("failed to get current file path") + panic(appconstants.ErrFailedToGetCurrentFilePath) } // Get the project root (go up from testutil/fixtures.go to project root) projectRoot := filepath.Dir(filepath.Dir(currentFile)) - basePath := filepath.Join(projectRoot, "testdata", "yaml-fixtures") + basePath := filepath.Join(projectRoot, appconstants.DirTestdata, appconstants.DirYAMLFixtures) return &FixtureManager{ basePath: basePath, @@ -449,8 +443,10 @@ func (fm *FixtureManager) LoadActionFixture(name string) (*ActionFixture, error) // LoadConfigFixture loads a configuration fixture. func (fm *FixtureManager) LoadConfigFixture(name string) (*ConfigFixture, error) { configPath := filepath.Join(fm.basePath, "configs", name) - if !strings.HasSuffix(configPath, YmlExtension) && !strings.HasSuffix(configPath, YamlExtension) { - configPath += YmlExtension + hasYMLExt := strings.HasSuffix(configPath, appconstants.ActionFileExtYML) + hasYAMLExt := strings.HasSuffix(configPath, appconstants.ActionFileExtYAML) + if !hasYMLExt && !hasYAMLExt { + configPath += appconstants.ActionFileExtYML } content, err := os.ReadFile(configPath) // #nosec G304 -- test fixture path from project structure @@ -537,8 +533,10 @@ func (fm *FixtureManager) resolveFixturePath(name string) string { // ensureYamlExtension adds YAML extension if not present. func (fm *FixtureManager) ensureYamlExtension(path string) string { - if !strings.HasSuffix(path, YmlExtension) && !strings.HasSuffix(path, YamlExtension) { - path += YmlExtension + hasYMLExt := strings.HasSuffix(path, appconstants.ActionFileExtYML) + hasYAMLExt := strings.HasSuffix(path, appconstants.ActionFileExtYAML) + if !hasYMLExt && !hasYAMLExt { + path += appconstants.ActionFileExtYML } return path @@ -626,7 +624,7 @@ func (fm *FixtureManager) determineActionTypeByContent(content string) ActionTyp // determineConfigType determines the type of configuration fixture. func (fm *FixtureManager) determineConfigType(name string) string { if strings.Contains(name, "global") { - return "global" + return appconstants.ScopeGlobal } if strings.Contains(name, "repo") { return "repo-specific" @@ -730,7 +728,9 @@ func (fm *FixtureManager) scenarioMatchesTags(scenario *TestScenario, tags []str // createDefaultScenarios creates a default scenarios file. func (fm *FixtureManager) createDefaultScenarios(scenarioFile string) error { // Ensure the directory exists - if err := os.MkdirAll(filepath.Dir(scenarioFile), 0750); err != nil { // #nosec G301 -- test directory permissions + scenarioDir := filepath.Dir(scenarioFile) + // #nosec G301 -- test directory permissions + if err := os.MkdirAll(scenarioDir, appconstants.FilePermDir); err != nil { return fmt.Errorf("failed to create scenarios directory: %w", err) } diff --git a/testutil/fixtures_test.go b/testutil/fixtures_test.go index b5e0888..a182046 100644 --- a/testutil/fixtures_test.go +++ b/testutil/fixtures_test.go @@ -61,23 +61,9 @@ func TestMustReadFixture_Panic(t *testing.T) { t.Parallel() t.Run("missing file panics", func(t *testing.T) { t.Parallel() - defer func() { - if r := recover(); r == nil { - t.Error("expected panic but got none") - } else { - errStr, ok := r.(string) - if !ok { - t.Errorf("expected panic to contain string message, got: %T", r) - - return - } - if !strings.Contains(errStr, "failed to read fixture") { - t.Errorf("expected panic message about fixture reading, got: %v", r) - } - } - }() - - mustReadFixture("nonexistent-file.yml") + ExpectPanic(t, func() { + mustReadFixture("nonexistent-file.yml") + }, "failed to read fixture") }) } diff --git a/testutil/test_suites.go b/testutil/test_suites.go index 191cc88..02b3972 100644 --- a/testutil/test_suites.go +++ b/testutil/test_suites.go @@ -10,11 +10,8 @@ import ( "testing" "github.com/google/go-github/v74/github" -) -// File constants. -const ( - readmeFilename = "README.md" + "github.com/ivuorinen/gh-action-readme/appconstants" ) // TestExecutor is a function type for executing specific types of tests. @@ -355,7 +352,7 @@ func executeTest(t *testing.T, testCase TestCase, ctx *TestContext) *TestResult } // Create temporary action file - actionPath := filepath.Join(ctx.TempDir, "action.yml") + actionPath := filepath.Join(ctx.TempDir, appconstants.ActionFileNameYML) WriteTestFile(t, actionPath, fixture.Content) } @@ -571,23 +568,23 @@ func DetectGeneratedFiles(outputDir string, outputFormat string) []string { if !entry.IsDir() { name := entry.Name() // Skip the action.yml we created for testing - if name == "action.yml" { + if name == appconstants.ActionFileNameYML { continue } // Check if this file matches the expected output format isGenerated := false switch outputFormat { - case "md": - isGenerated = name == readmeFilename - case "html": + case appconstants.OutputFormatMarkdown: + isGenerated = name == appconstants.ReadmeMarkdown + case appconstants.OutputFormatHTML: isGenerated = strings.HasSuffix(name, ".html") - case "json": - isGenerated = name == "action-docs.json" - case "asciidoc": - isGenerated = name == "README.adoc" + case appconstants.OutputFormatJSON: + isGenerated = name == appconstants.ActionDocsJSON + case appconstants.OutputFormatASCIIDoc: + isGenerated = name == appconstants.ReadmeASCIIDoc default: - isGenerated = name == readmeFilename + isGenerated = name == appconstants.ReadmeMarkdown } if isGenerated { @@ -603,7 +600,7 @@ func DetectGeneratedFiles(outputDir string, outputFormat string) []string { func DefaultTestConfig() *TestConfig { return &TestConfig{ Theme: "default", - OutputFormat: "md", + OutputFormat: appconstants.OutputFormatMarkdown, OutputDir: ".", Verbose: false, Quiet: false, @@ -652,7 +649,7 @@ func CreateTemporaryAction(t *testing.T, fixture string) string { // Load the fixture actionFixture, err := LoadActionFixture(fixture) if err != nil { - t.Fatalf("failed to load action fixture %s: %v", fixture, err) + t.Fatalf(appconstants.ErrFailedToLoadActionFixture, fixture, err) } // Create temporary directory @@ -660,7 +657,7 @@ func CreateTemporaryAction(t *testing.T, fixture string) string { t.Cleanup(cleanup) // Write action file - actionPath := filepath.Join(tempDir, "action.yml") + actionPath := filepath.Join(tempDir, appconstants.ActionFileNameYML) WriteTestFile(t, actionPath, actionFixture.Content) return actionPath @@ -673,7 +670,7 @@ func CreateTemporaryActionDir(t *testing.T, fixture string) string { // Load the fixture actionFixture, err := LoadActionFixture(fixture) if err != nil { - t.Fatalf("failed to load action fixture %s: %v", fixture, err) + t.Fatalf(appconstants.ErrFailedToLoadActionFixture, fixture, err) } // Create temporary directory @@ -681,7 +678,7 @@ func CreateTemporaryActionDir(t *testing.T, fixture string) string { t.Cleanup(cleanup) // Write action file - actionPath := filepath.Join(tempDir, "action.yml") + actionPath := filepath.Join(tempDir, appconstants.ActionFileNameYML) WriteTestFile(t, actionPath, actionFixture.Content) return tempDir @@ -841,7 +838,12 @@ func TestAllThemes(t *testing.T, testFunc func(*testing.T, string)) { func TestAllFormats(t *testing.T, testFunc func(*testing.T, string)) { t.Helper() - formats := []string{"md", "html", "json", "asciidoc"} + formats := []string{ + appconstants.OutputFormatMarkdown, + appconstants.OutputFormatHTML, + appconstants.OutputFormatJSON, + appconstants.OutputFormatASCIIDoc, + } for _, format := range formats { format := format // capture loop variable @@ -888,8 +890,7 @@ func CreateGitHubMockSuite(scenarios []string) *MockSuite { func AssertFixtureValid(t *testing.T, fixtureName string) { t.Helper() - fixture, err := LoadActionFixture(fixtureName) - AssertNoError(t, err) + fixture := MustLoadActionFixture(t, fixtureName) if !fixture.IsValid { t.Errorf("fixture %s should be valid but failed validation", fixtureName) @@ -974,18 +975,18 @@ func CreateActionTestCases() []ActionTestCase { // getExpectedFilename returns the expected filename for a given output format. func getExpectedFilename(outputFormat string) string { switch outputFormat { - case "md": - return "README.md" - case "html": + case appconstants.OutputFormatMarkdown: + return appconstants.ReadmeMarkdown + case appconstants.OutputFormatHTML: // HTML files have variable names based on action name, so we'll use a pattern // The DetectGeneratedFiles function will find any .html file return "*.html" - case "json": - return "action-docs.json" - case "asciidoc": - return "README.adoc" + case appconstants.OutputFormatJSON: + return appconstants.ActionDocsJSON + case appconstants.OutputFormatASCIIDoc: + return appconstants.ReadmeASCIIDoc default: - return "README.md" + return appconstants.ReadmeMarkdown } } @@ -993,7 +994,12 @@ func getExpectedFilename(outputFormat string) string { func CreateGeneratorTestCases() []GeneratorTestCase { validFixtures := GetValidFixtures() themes := []string{"default", "github", "minimal", "professional"} - formats := []string{"md", "html", "json", "asciidoc"} + formats := []string{ + appconstants.OutputFormatMarkdown, + appconstants.OutputFormatHTML, + appconstants.OutputFormatJSON, + appconstants.OutputFormatASCIIDoc, + } cases := make([]GeneratorTestCase, 0) diff --git a/testutil/testutil.go b/testutil/testutil.go index 681b399..bdcbe05 100644 --- a/testutil/testutil.go +++ b/testutil/testutil.go @@ -14,6 +14,8 @@ import ( "time" "github.com/google/go-github/v74/github" + + "github.com/ivuorinen/gh-action-readme/appconstants" ) // MockHTTPClient is a mock HTTP client for testing. @@ -91,20 +93,155 @@ func TempDir(t *testing.T) (string, func()) { } } +// CleanupCache provides a standard cache cleanup helper for deferred cleanup. +// It returns a function that closes the cache and fails the test on errors. +func CleanupCache(tb testing.TB, cache interface{ Close() error }) func() { + tb.Helper() + + return func() { + tb.Helper() + if err := cache.Close(); err != nil { + tb.Fatalf("failed to close cache: %v", err) + } + } +} + +// ExpectPanic asserts that the provided function panics with a message containing the expected substring. +// This helper reduces panic recovery test boilerplate from 12-15 lines to 3-4 lines. +func ExpectPanic(t *testing.T, fn func(), expectedSubstring string) { + t.Helper() + defer func() { + if r := recover(); r == nil { + t.Error("expected panic but got none") + } else { + var errStr string + switch v := r.(type) { + case string: + errStr = v + case error: + errStr = v.Error() + default: + errStr = fmt.Sprintf("%v", v) + } + if !strings.Contains(errStr, expectedSubstring) { + t.Errorf("expected panic message containing %q, got: %v", expectedSubstring, r) + } + } + }() + fn() +} + +// MustLoadActionFixture loads an action fixture and fails the test on error. +// This helper consolidates the load + assertion pattern. +func MustLoadActionFixture(t *testing.T, path string) *ActionFixture { + t.Helper() + fixture, err := LoadActionFixture(path) + AssertNoError(t, err) + + return fixture +} + +// LoadAndWriteFixture loads an action fixture and writes it to the specified path. +// This helper reduces the common 3-line pattern to a single line. +func LoadAndWriteFixture(t *testing.T, fixturePath, targetPath string) { + t.Helper() + fixture := MustLoadActionFixture(t, fixturePath) + WriteTestFile(t, targetPath, fixture.Content) +} + // WriteTestFile writes a test file to the given path. func WriteTestFile(t *testing.T, path, content string) { t.Helper() dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0750); err != nil { // #nosec G301 -- test directory permissions + // #nosec G301 -- test directory permissions + if err := os.MkdirAll(dir, appconstants.FilePermDir); err != nil { t.Fatalf("failed to create dir %s: %v", dir, err) } - if err := os.WriteFile(path, []byte(content), 0600); err != nil { // #nosec G306 -- test file permissions + // #nosec G306 -- test file permissions + if err := os.WriteFile(path, []byte(content), appconstants.FilePermDefault); err != nil { t.Fatalf("failed to write test file %s: %v", path, err) } } +// WriteActionFixture writes an action fixture to a standard action.yml file. +func WriteActionFixture(t *testing.T, dir, fixturePath string) string { + t.Helper() + actionPath := filepath.Join(dir, appconstants.TestPathActionYML) + fixture := MustLoadActionFixture(t, fixturePath) + WriteTestFile(t, actionPath, fixture.Content) + + return actionPath +} + +// WriteActionFixtureAs writes an action fixture with a custom filename. +func WriteActionFixtureAs(t *testing.T, dir, filename, fixturePath string) string { + t.Helper() + actionPath := filepath.Join(dir, filename) + fixture := MustLoadActionFixture(t, fixturePath) + WriteTestFile(t, actionPath, fixture.Content) + + return actionPath +} + +// CreateConfigDir creates a standard .config/gh-action-readme directory. +func CreateConfigDir(t *testing.T, baseDir string) string { + t.Helper() + configDir := filepath.Join(baseDir, appconstants.TestDirConfigGhActionReadme) + // #nosec G301 -- test directory permissions + if err := os.MkdirAll(configDir, appconstants.FilePermDir); err != nil { + t.Fatalf("failed to create config dir: %v", err) + } + + return configDir +} + +// WriteConfigFile writes a config file to the standard location. +func WriteConfigFile(t *testing.T, baseDir, content string) string { + t.Helper() + configDir := CreateConfigDir(t, baseDir) + configPath := filepath.Join(configDir, appconstants.ConfigFileNameFull) + WriteTestFile(t, configPath, content) + + return configPath +} + +// CreateActionSubdir creates a subdirectory and writes an action fixture to it. +func CreateActionSubdir(t *testing.T, baseDir, subdirName, fixturePath string) string { + t.Helper() + subDir := filepath.Join(baseDir, subdirName) + // #nosec G301 -- test directory permissions + if err := os.MkdirAll(subDir, appconstants.FilePermDir); err != nil { + t.Fatalf("failed to create subdir: %v", err) + } + + return WriteActionFixture(t, subDir, fixturePath) +} + +// AssertFileExists fails if the file does not exist. +func AssertFileExists(t *testing.T, path string) { + t.Helper() + if _, err := os.Stat(path); os.IsNotExist(err) { + t.Fatalf("expected file to exist: %s", path) + } +} + +// AssertFileNotExists fails if the file exists. +func AssertFileNotExists(t *testing.T, path string) { + t.Helper() + _, err := os.Stat(path) + if err == nil { + // File exists + t.Fatalf("expected file not to exist: %s", path) + } + if err != nil && !os.IsNotExist(err) { + // Error occurred but it's not a "does not exist" error + t.Fatalf("error checking file existence: %v", err) + } + // err != nil && os.IsNotExist(err) - this is the success case +} + // MockColoredOutput captures output for testing. type MockColoredOutput struct { Messages []string @@ -192,14 +329,14 @@ func CreateTestAction(name, description string, inputs map[string]string) string inputsYAML.WriteString(fmt.Sprintf(" %s:\n description: %s\n required: true\n", key, desc)) } - result := fmt.Sprintf("name: %s\n", name) - result += fmt.Sprintf("description: %s\n", description) + result := fmt.Sprintf(appconstants.YAMLFieldName, name) + result += fmt.Sprintf(appconstants.YAMLFieldDescription, description) result += "inputs:\n" result += inputsYAML.String() result += "outputs:\n" result += " result:\n" result += " description: 'The result'\n" - result += "runs:\n" + result += appconstants.YAMLFieldRuns result += " using: 'node20'\n" result += " main: 'index.js'\n" result += "branding:\n" @@ -220,16 +357,17 @@ func SetupTestTemplates(t *testing.T, dir string) { // Create directories for _, theme := range []string{"github", "gitlab", "minimal", "professional"} { themeDir := filepath.Join(themesDir, theme) - if err := os.MkdirAll(themeDir, 0750); err != nil { // #nosec G301 -- test directory permissions + // #nosec G301 -- test directory permissions + if err := os.MkdirAll(themeDir, appconstants.FilePermDir); err != nil { t.Fatalf("failed to create theme dir %s: %v", themeDir, err) } // Write theme template - templatePath := filepath.Join(themeDir, "readme.tmpl") + templatePath := filepath.Join(themeDir, appconstants.TemplateReadme) WriteTestFile(t, templatePath, SimpleTemplate) } // Create default template - defaultTemplatePath := filepath.Join(templatesDir, "readme.tmpl") + defaultTemplatePath := filepath.Join(templatesDir, appconstants.TemplateReadme) WriteTestFile(t, defaultTemplatePath, SimpleTemplate) } @@ -240,9 +378,9 @@ func CreateCompositeAction(name, description string, steps []string) string { stepsYAML.WriteString(fmt.Sprintf(" - name: Step %d\n uses: %s\n", i+1, step)) } - result := fmt.Sprintf("name: %s\n", name) - result += fmt.Sprintf("description: %s\n", description) - result += "runs:\n" + result := fmt.Sprintf(appconstants.YAMLFieldName, name) + result += fmt.Sprintf(appconstants.YAMLFieldDescription, description) + result += appconstants.YAMLFieldRuns result += " using: 'composite'\n" result += " steps:\n" result += stepsYAML.String() @@ -373,6 +511,27 @@ func AssertEqual(t *testing.T, expected, actual any) { } } +// AssertSliceContainsAll fails if any of expectedSubstrings is not found in any item of the slice. +// This is useful for checking that suggestions or messages contain expected content. +func AssertSliceContainsAll(t *testing.T, slice []string, expectedSubstrings []string) { + t.Helper() + + if len(slice) == 0 { + t.Fatal("slice is empty") + } + + allItems := strings.Join(slice, " ") + for _, expected := range expectedSubstrings { + if !strings.Contains(allItems, expected) { + t.Errorf( + "expected to find %q in slice, got:\n%s", + expected, + strings.Join(slice, "\n"), + ) + } + } +} + // NewStringReader creates an io.ReadCloser from a string. func NewStringReader(s string) io.ReadCloser { return io.NopCloser(strings.NewReader(s)) @@ -392,8 +551,8 @@ func GetGitHubTokenHierarchyTests() []GitHubTokenTestCase { Name: "GH_README_GITHUB_TOKEN has highest priority", SetupFunc: func(t *testing.T) func() { t.Helper() - cleanup1 := SetEnv(t, "GH_README_GITHUB_TOKEN", "priority-token") - cleanup2 := SetEnv(t, "GITHUB_TOKEN", "fallback-token") + cleanup1 := SetEnv(t, appconstants.EnvGitHubToken, "priority-token") + cleanup2 := SetEnv(t, appconstants.EnvGitHubTokenStandard, appconstants.TokenFallback) return func() { cleanup1() @@ -406,19 +565,19 @@ func GetGitHubTokenHierarchyTests() []GitHubTokenTestCase { Name: "GITHUB_TOKEN as fallback", SetupFunc: func(t *testing.T) func() { t.Helper() - _ = os.Unsetenv("GH_README_GITHUB_TOKEN") - cleanup := SetEnv(t, "GITHUB_TOKEN", "fallback-token") + _ = os.Unsetenv(appconstants.EnvGitHubToken) + cleanup := SetEnv(t, appconstants.EnvGitHubTokenStandard, appconstants.TokenFallback) return cleanup }, - ExpectedToken: "fallback-token", + ExpectedToken: appconstants.TokenFallback, }, { Name: "no environment variables", SetupFunc: func(t *testing.T) func() { t.Helper() - _ = os.Unsetenv("GH_README_GITHUB_TOKEN") - _ = os.Unsetenv("GITHUB_TOKEN") + _ = os.Unsetenv(appconstants.EnvGitHubToken) + _ = os.Unsetenv(appconstants.EnvGitHubTokenStandard) return func() {} },