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

* feat: rename internal/errors to internal/apperrors

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

* feat: rename internal/errors to internal/apperrors

* chore(deps): update go and all dependencies

* chore: remove renovate from pre-commit, formatting

* chore: sonarcloud fixes

* feat: consolidate constants to appconstants/constants.go

* chore: sonarcloud fixes

* feat: simplification, deduplication, test utils

* chore: sonarcloud fixes

* chore: sonarcloud fixes

* chore: sonarcloud fixes

* chore: sonarcloud fixes

* chore: clean up

* fix: config discovery, const deduplication

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

View File

@@ -1,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"
]
}
}

View File

@@ -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

View File

@@ -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}}"

View File

@@ -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: |

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

1
.gitignore vendored
View File

@@ -37,3 +37,4 @@ coverage.*
# Other
/megalinter-reports/
cr.txt

View File

@@ -1 +1 @@
1.25.4
1.25.5

View File

@@ -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]

618
appconstants/constants.go Normal file
View File

@@ -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
}
}

View File

@@ -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"
)

10
go.mod
View File

@@ -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
)

31
go.sum
View File

@@ -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=

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

20
internal/config_helper.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

25
internal/github_helper.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

122
internal/viper_helper.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

122
main.go
View File

@@ -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)

View File

@@ -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)
}
}
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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")
})
}

View File

@@ -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)

View File

@@ -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() {}
},