mirror of
https://github.com/ivuorinen/gh-action-readme.git
synced 2026-01-26 03:04:10 +00:00
feat: go 1.25.5, dependency updates, renamed internal/errors (#129)
* feat: rename internal/errors to internal/apperrors * fix(tests): clear env values before using in tests * feat: rename internal/errors to internal/apperrors * chore(deps): update go and all dependencies * chore: remove renovate from pre-commit, formatting * chore: sonarcloud fixes * feat: consolidate constants to appconstants/constants.go * chore: sonarcloud fixes * feat: simplification, deduplication, test utils * chore: sonarcloud fixes * chore: sonarcloud fixes * chore: sonarcloud fixes * chore: sonarcloud fixes * chore: clean up * fix: config discovery, const deduplication * chore: fixes
This commit is contained in:
@@ -1,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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
10
.github/workflows/ci.yml
vendored
10
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
12
.github/workflows/codeql.yml
vendored
12
.github/workflows/codeql.yml
vendored
@@ -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}}"
|
||||
|
||||
2
.github/workflows/commitlint.yml
vendored
2
.github/workflows/commitlint.yml
vendored
@@ -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: |
|
||||
|
||||
5
.github/workflows/pr-lint.yml
vendored
5
.github/workflows/pr-lint.yml
vendored
@@ -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
|
||||
|
||||
11
.github/workflows/release.yml
vendored
11
.github/workflows/release.yml
vendored
@@ -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
|
||||
|
||||
87
.github/workflows/security.yml
vendored
87
.github/workflows/security.yml
vendored
@@ -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
|
||||
|
||||
4
.github/workflows/stale.yml
vendored
4
.github/workflows/stale.yml
vendored
@@ -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
|
||||
|
||||
11
.github/workflows/sync-labels.yml
vendored
11
.github/workflows/sync-labels.yml
vendored
@@ -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
1
.gitignore
vendored
@@ -37,3 +37,4 @@ coverage.*
|
||||
|
||||
# Other
|
||||
/megalinter-reports/
|
||||
cr.txt
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.25.4
|
||||
1.25.5
|
||||
|
||||
@@ -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
618
appconstants/constants.go
Normal 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
|
||||
}
|
||||
}
|
||||
72
appconstants/test_constants.go
Normal file
72
appconstants/test_constants.go
Normal 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
10
go.mod
@@ -10,7 +10,7 @@ require (
|
||||
github.com/goccy/go-yaml v1.19.1
|
||||
github.com/gofri/go-github-ratelimit v1.1.1
|
||||
github.com/google/go-github/v74 v74.0.0
|
||||
github.com/schollz/progressbar/v3 v3.18.0
|
||||
github.com/schollz/progressbar/v3 v3.19.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/viper v1.21.0
|
||||
golang.org/x/oauth2 v0.34.0
|
||||
@@ -19,7 +19,7 @@ require (
|
||||
require (
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/google/go-querystring v1.1.0 // indirect
|
||||
github.com/google/go-querystring v1.2.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
@@ -32,8 +32,8 @@ require (
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/term v0.37.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/term v0.38.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
|
||||
)
|
||||
|
||||
31
go.sum
31
go.sum
@@ -13,21 +13,17 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/goccy/go-yaml v1.19.0 h1:EmkZ9RIsX+Uq4DYFowegAuJo8+xdX3T/2dwNPXbxEYE=
|
||||
github.com/goccy/go-yaml v1.19.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/goccy/go-yaml v1.19.1 h1:3rG3+v8pkhRqoQ/88NYNMHYVGYztCOCIZ7UQhu7H+NE=
|
||||
github.com/goccy/go-yaml v1.19.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/gofri/go-github-ratelimit v1.1.1 h1:5TCOtFf45M2PjSYU17txqbiYBEzjOuK1+OhivbW69W0=
|
||||
github.com/gofri/go-github-ratelimit v1.1.1/go.mod h1:wGZlBbzHmIVjwDR3pZgKY7RBTV6gsQWxLVkpfwhcMJM=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-github/v74 v74.0.0 h1:yZcddTUn8DPbj11GxnMrNiAnXH14gNs559AsUpNpPgM=
|
||||
github.com/google/go-github/v74 v74.0.0/go.mod h1:ubn/YdyftV80VPSI26nSJvaEsTOnsjrxG3o9kJhcyak=
|
||||
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
|
||||
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
||||
github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
|
||||
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
@@ -53,14 +49,12 @@ github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/f
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
|
||||
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
|
||||
github.com/schollz/progressbar/v3 v3.18.0 h1:uXdoHABRFmNIjUfte/Ex7WtuyVslrw2wVPQmCN62HpA=
|
||||
github.com/schollz/progressbar/v3 v3.18.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec=
|
||||
github.com/schollz/progressbar/v3 v3.19.0 h1:Ea18xuIRQXLAUidVDox3AbwfUhD0/1IvohyTutOIFoc=
|
||||
github.com/schollz/progressbar/v3 v3.19.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
|
||||
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
@@ -74,18 +68,15 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
|
||||
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
|
||||
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
|
||||
@@ -1,37 +1,31 @@
|
||||
// Package errors provides enhanced error types with contextual information and suggestions.
|
||||
package errors
|
||||
// Package apperrors provides enhanced error types with contextual information and suggestions.
|
||||
package apperrors
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
)
|
||||
|
||||
// ErrorCode represents a category of error for providing specific help.
|
||||
type ErrorCode string
|
||||
|
||||
// Error code constants for categorizing errors.
|
||||
const (
|
||||
ErrCodeFileNotFound ErrorCode = "FILE_NOT_FOUND"
|
||||
ErrCodePermission ErrorCode = "PERMISSION_DENIED"
|
||||
ErrCodeInvalidYAML ErrorCode = "INVALID_YAML"
|
||||
ErrCodeInvalidAction ErrorCode = "INVALID_ACTION"
|
||||
ErrCodeNoActionFiles ErrorCode = "NO_ACTION_FILES"
|
||||
ErrCodeGitHubAPI ErrorCode = "GITHUB_API_ERROR"
|
||||
ErrCodeGitHubRateLimit ErrorCode = "GITHUB_RATE_LIMIT"
|
||||
ErrCodeGitHubAuth ErrorCode = "GITHUB_AUTH_ERROR"
|
||||
ErrCodeConfiguration ErrorCode = "CONFIG_ERROR"
|
||||
ErrCodeValidation ErrorCode = "VALIDATION_ERROR"
|
||||
ErrCodeTemplateRender ErrorCode = "TEMPLATE_ERROR"
|
||||
ErrCodeFileWrite ErrorCode = "FILE_WRITE_ERROR"
|
||||
ErrCodeDependencyAnalysis ErrorCode = "DEPENDENCY_ERROR"
|
||||
ErrCodeCacheAccess ErrorCode = "CACHE_ERROR"
|
||||
ErrCodeUnknown ErrorCode = "UNKNOWN_ERROR"
|
||||
// Sentinel errors for typed error checking.
|
||||
var (
|
||||
// ErrFileNotFound indicates a file was not found.
|
||||
ErrFileNotFound = errors.New("file not found")
|
||||
// ErrPermissionDenied indicates a permission error.
|
||||
ErrPermissionDenied = errors.New("permission denied")
|
||||
// ErrInvalidYAML indicates YAML parsing failed.
|
||||
ErrInvalidYAML = errors.New("invalid YAML")
|
||||
// ErrGitHubAPI indicates a GitHub API error.
|
||||
ErrGitHubAPI = errors.New("GitHub API error")
|
||||
// ErrConfiguration indicates a configuration error.
|
||||
ErrConfiguration = errors.New("configuration error")
|
||||
)
|
||||
|
||||
// ContextualError provides enhanced error information with actionable suggestions.
|
||||
type ContextualError struct {
|
||||
Code ErrorCode
|
||||
Code appconstants.ErrorCode
|
||||
Err error
|
||||
Context string
|
||||
Suggestions []string
|
||||
@@ -98,7 +92,7 @@ func (ce *ContextualError) Is(target error) bool {
|
||||
}
|
||||
|
||||
// New creates a new ContextualError with the given code and message.
|
||||
func New(code ErrorCode, message string) *ContextualError {
|
||||
func New(code appconstants.ErrorCode, message string) *ContextualError {
|
||||
return &ContextualError{
|
||||
Code: code,
|
||||
Err: errors.New(message),
|
||||
@@ -106,22 +100,37 @@ func New(code ErrorCode, message string) *ContextualError {
|
||||
}
|
||||
|
||||
// Wrap wraps an existing error with contextual information.
|
||||
func Wrap(err error, code ErrorCode, context string) *ContextualError {
|
||||
func Wrap(err error, code appconstants.ErrorCode, context string) *ContextualError {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// If already a ContextualError, preserve existing info
|
||||
// If already a ContextualError, preserve existing info by creating a copy
|
||||
if ce, ok := err.(*ContextualError); ok {
|
||||
// Only update if not already set
|
||||
if ce.Code == ErrCodeUnknown {
|
||||
ce.Code = code
|
||||
}
|
||||
if ce.Context == "" {
|
||||
ce.Context = context
|
||||
// Create a copy to avoid mutating the original
|
||||
errCopy := &ContextualError{
|
||||
Code: ce.Code,
|
||||
Err: ce.Err,
|
||||
Context: ce.Context,
|
||||
Suggestions: ce.Suggestions,
|
||||
HelpURL: ce.HelpURL,
|
||||
Details: make(map[string]string),
|
||||
}
|
||||
|
||||
return ce
|
||||
// Copy details map
|
||||
for k, v := range ce.Details {
|
||||
errCopy.Details[k] = v
|
||||
}
|
||||
|
||||
// Only update if not already set
|
||||
if errCopy.Code == appconstants.ErrCodeUnknown {
|
||||
errCopy.Code = code
|
||||
}
|
||||
if errCopy.Context == "" {
|
||||
errCopy.Context = context
|
||||
}
|
||||
|
||||
return errCopy
|
||||
}
|
||||
|
||||
return &ContextualError{
|
||||
@@ -158,24 +167,24 @@ func (ce *ContextualError) WithHelpURL(url string) *ContextualError {
|
||||
}
|
||||
|
||||
// GetHelpURL returns a help URL for the given error code.
|
||||
func GetHelpURL(code ErrorCode) string {
|
||||
func GetHelpURL(code appconstants.ErrorCode) string {
|
||||
baseURL := "https://github.com/ivuorinen/gh-action-readme/blob/main/docs/troubleshooting.md"
|
||||
|
||||
anchors := map[ErrorCode]string{
|
||||
ErrCodeFileNotFound: "#file-not-found",
|
||||
ErrCodePermission: "#permission-denied",
|
||||
ErrCodeInvalidYAML: "#invalid-yaml",
|
||||
ErrCodeInvalidAction: "#invalid-action-file",
|
||||
ErrCodeNoActionFiles: "#no-action-files",
|
||||
ErrCodeGitHubAPI: "#github-api-errors",
|
||||
ErrCodeGitHubRateLimit: "#rate-limit-exceeded",
|
||||
ErrCodeGitHubAuth: "#authentication-errors",
|
||||
ErrCodeConfiguration: "#configuration-errors",
|
||||
ErrCodeValidation: "#validation-errors",
|
||||
ErrCodeTemplateRender: "#template-errors",
|
||||
ErrCodeFileWrite: "#file-write-errors",
|
||||
ErrCodeDependencyAnalysis: "#dependency-analysis",
|
||||
ErrCodeCacheAccess: "#cache-errors",
|
||||
anchors := map[appconstants.ErrorCode]string{
|
||||
appconstants.ErrCodeFileNotFound: "#file-not-found",
|
||||
appconstants.ErrCodePermission: "#permission-denied",
|
||||
appconstants.ErrCodeInvalidYAML: "#invalid-yaml",
|
||||
appconstants.ErrCodeInvalidAction: "#invalid-action-file",
|
||||
appconstants.ErrCodeNoActionFiles: "#no-action-files",
|
||||
appconstants.ErrCodeGitHubAPI: "#github-api-errors",
|
||||
appconstants.ErrCodeGitHubRateLimit: "#rate-limit-exceeded",
|
||||
appconstants.ErrCodeGitHubAuth: "#authentication-errors",
|
||||
appconstants.ErrCodeConfiguration: "#configuration-errors",
|
||||
appconstants.ErrCodeValidation: "#validation-errors",
|
||||
appconstants.ErrCodeTemplateRender: "#template-errors",
|
||||
appconstants.ErrCodeFileWrite: "#file-write-errors",
|
||||
appconstants.ErrCodeDependencyAnalysis: "#dependency-analysis",
|
||||
appconstants.ErrCodeCacheAccess: "#cache-errors",
|
||||
}
|
||||
|
||||
if anchor, ok := anchors[code]; ok {
|
||||
@@ -1,12 +1,21 @@
|
||||
package errors
|
||||
package apperrors
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
func TestContextualError_Error(t *testing.T) {
|
||||
const (
|
||||
testOriginalError = "original error"
|
||||
testMessage = "test message"
|
||||
testContext = "test context"
|
||||
)
|
||||
|
||||
func TestContextualErrorError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
@@ -17,7 +26,7 @@ func TestContextualError_Error(t *testing.T) {
|
||||
{
|
||||
name: "basic error",
|
||||
err: &ContextualError{
|
||||
Code: ErrCodeFileNotFound,
|
||||
Code: appconstants.ErrCodeFileNotFound,
|
||||
Err: errors.New("file not found"),
|
||||
},
|
||||
contains: []string{"file not found", "[FILE_NOT_FOUND]"},
|
||||
@@ -25,7 +34,7 @@ func TestContextualError_Error(t *testing.T) {
|
||||
{
|
||||
name: "error with context",
|
||||
err: &ContextualError{
|
||||
Code: ErrCodeInvalidYAML,
|
||||
Code: appconstants.ErrCodeInvalidYAML,
|
||||
Err: errors.New("invalid syntax"),
|
||||
Context: "parsing action.yml",
|
||||
},
|
||||
@@ -34,7 +43,7 @@ func TestContextualError_Error(t *testing.T) {
|
||||
{
|
||||
name: "error with suggestions",
|
||||
err: &ContextualError{
|
||||
Code: ErrCodeNoActionFiles,
|
||||
Code: appconstants.ErrCodeNoActionFiles,
|
||||
Err: errors.New("no files found"),
|
||||
Suggestions: []string{
|
||||
"Check current directory",
|
||||
@@ -51,7 +60,7 @@ func TestContextualError_Error(t *testing.T) {
|
||||
{
|
||||
name: "error with details",
|
||||
err: &ContextualError{
|
||||
Code: ErrCodeConfiguration,
|
||||
Code: appconstants.ErrCodeConfiguration,
|
||||
Err: errors.New("config error"),
|
||||
Details: map[string]string{
|
||||
"config_path": "/path/to/config",
|
||||
@@ -68,7 +77,7 @@ func TestContextualError_Error(t *testing.T) {
|
||||
{
|
||||
name: "error with help URL",
|
||||
err: &ContextualError{
|
||||
Code: ErrCodeGitHubAPI,
|
||||
Code: appconstants.ErrCodeGitHubAPI,
|
||||
Err: errors.New("API error"),
|
||||
HelpURL: "https://docs.github.com/api",
|
||||
},
|
||||
@@ -80,7 +89,7 @@ func TestContextualError_Error(t *testing.T) {
|
||||
{
|
||||
name: "complete error",
|
||||
err: &ContextualError{
|
||||
Code: ErrCodeValidation,
|
||||
Code: appconstants.ErrCodeValidation,
|
||||
Err: errors.New("validation failed"),
|
||||
Context: "validating action.yml",
|
||||
Details: map[string]string{"file": "action.yml"},
|
||||
@@ -108,26 +117,17 @@ func TestContextualError_Error(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := tt.err.Error()
|
||||
|
||||
for _, expected := range tt.contains {
|
||||
if !strings.Contains(result, expected) {
|
||||
t.Errorf(
|
||||
"Error() result missing expected content:\nExpected to contain: %q\nActual result:\n%s",
|
||||
expected,
|
||||
result,
|
||||
)
|
||||
}
|
||||
}
|
||||
testutil.AssertSliceContainsAll(t, []string{result}, tt.contains)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextualError_Unwrap(t *testing.T) {
|
||||
func TestContextualErrorUnwrap(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
originalErr := errors.New("original error")
|
||||
originalErr := errors.New(testOriginalError)
|
||||
contextualErr := &ContextualError{
|
||||
Code: ErrCodeFileNotFound,
|
||||
Code: appconstants.ErrCodeFileNotFound,
|
||||
Err: originalErr,
|
||||
}
|
||||
|
||||
@@ -136,23 +136,23 @@ func TestContextualError_Unwrap(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextualError_Is(t *testing.T) {
|
||||
func TestContextualErrorIs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
originalErr := errors.New("original error")
|
||||
originalErr := errors.New(testOriginalError)
|
||||
contextualErr := &ContextualError{
|
||||
Code: ErrCodeFileNotFound,
|
||||
Code: appconstants.ErrCodeFileNotFound,
|
||||
Err: originalErr,
|
||||
}
|
||||
|
||||
// Test Is with same error code
|
||||
sameCodeErr := &ContextualError{Code: ErrCodeFileNotFound}
|
||||
sameCodeErr := &ContextualError{Code: appconstants.ErrCodeFileNotFound}
|
||||
if !contextualErr.Is(sameCodeErr) {
|
||||
t.Error("Is() should return true for same error code")
|
||||
}
|
||||
|
||||
// Test Is with different error code
|
||||
differentCodeErr := &ContextualError{Code: ErrCodeInvalidYAML}
|
||||
differentCodeErr := &ContextualError{Code: appconstants.ErrCodeInvalidYAML}
|
||||
if contextualErr.Is(differentCodeErr) {
|
||||
t.Error("Is() should return false for different error code")
|
||||
}
|
||||
@@ -166,59 +166,59 @@ func TestContextualError_Is(t *testing.T) {
|
||||
func TestNew(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := New(ErrCodeFileNotFound, "test message")
|
||||
err := New(appconstants.ErrCodeFileNotFound, testMessage)
|
||||
|
||||
if err.Code != ErrCodeFileNotFound {
|
||||
t.Errorf("New() code = %v, want %v", err.Code, ErrCodeFileNotFound)
|
||||
if err.Code != appconstants.ErrCodeFileNotFound {
|
||||
t.Errorf("New() code = %v, want %v", err.Code, appconstants.ErrCodeFileNotFound)
|
||||
}
|
||||
|
||||
if err.Err.Error() != "test message" {
|
||||
t.Errorf("New() message = %v, want %v", err.Err.Error(), "test message")
|
||||
if err.Err.Error() != testMessage {
|
||||
t.Errorf("New() message = %v, want %v", err.Err.Error(), testMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrap(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
originalErr := errors.New("original error")
|
||||
originalErr := errors.New(testOriginalError)
|
||||
|
||||
// Test wrapping normal error
|
||||
wrapped := Wrap(originalErr, ErrCodeFileNotFound, "test context")
|
||||
if wrapped.Code != ErrCodeFileNotFound {
|
||||
t.Errorf("Wrap() code = %v, want %v", wrapped.Code, ErrCodeFileNotFound)
|
||||
wrapped := Wrap(originalErr, appconstants.ErrCodeFileNotFound, testContext)
|
||||
if wrapped.Code != appconstants.ErrCodeFileNotFound {
|
||||
t.Errorf("Wrap() code = %v, want %v", wrapped.Code, appconstants.ErrCodeFileNotFound)
|
||||
}
|
||||
if wrapped.Context != "test context" {
|
||||
t.Errorf("Wrap() context = %v, want %v", wrapped.Context, "test context")
|
||||
if wrapped.Context != testContext {
|
||||
t.Errorf("Wrap() context = %v, want %v", wrapped.Context, testContext)
|
||||
}
|
||||
if wrapped.Err != originalErr {
|
||||
t.Errorf("Wrap() err = %v, want %v", wrapped.Err, originalErr)
|
||||
}
|
||||
|
||||
// Test wrapping nil error
|
||||
nilWrapped := Wrap(nil, ErrCodeFileNotFound, "test context")
|
||||
nilWrapped := Wrap(nil, appconstants.ErrCodeFileNotFound, testContext)
|
||||
if nilWrapped != nil {
|
||||
t.Error("Wrap(nil) should return nil")
|
||||
}
|
||||
|
||||
// Test wrapping already contextual error
|
||||
contextualErr := &ContextualError{
|
||||
Code: ErrCodeUnknown,
|
||||
Code: appconstants.ErrCodeUnknown,
|
||||
Err: originalErr,
|
||||
Context: "",
|
||||
}
|
||||
rewrapped := Wrap(contextualErr, ErrCodeFileNotFound, "new context")
|
||||
if rewrapped.Code != ErrCodeFileNotFound {
|
||||
t.Error("Wrap() should update code if it was ErrCodeUnknown")
|
||||
rewrapped := Wrap(contextualErr, appconstants.ErrCodeFileNotFound, "new context")
|
||||
if rewrapped.Code != appconstants.ErrCodeFileNotFound {
|
||||
t.Error("Wrap() should update code if it was appconstants.ErrCodeUnknown")
|
||||
}
|
||||
if rewrapped.Context != "new context" {
|
||||
t.Error("Wrap() should update context if it was empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextualError_WithMethods(t *testing.T) {
|
||||
func TestContextualErrorWithMethods(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := New(ErrCodeFileNotFound, "test error")
|
||||
err := New(appconstants.ErrCodeFileNotFound, "test error")
|
||||
|
||||
// Test WithSuggestions
|
||||
err = err.WithSuggestions("suggestion 1", "suggestion 2")
|
||||
@@ -251,13 +251,13 @@ func TestGetHelpURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
code ErrorCode
|
||||
code appconstants.ErrorCode
|
||||
contains string
|
||||
}{
|
||||
{ErrCodeFileNotFound, "#file-not-found"},
|
||||
{ErrCodeInvalidYAML, "#invalid-yaml"},
|
||||
{ErrCodeGitHubAPI, "#github-api-errors"},
|
||||
{ErrCodeUnknown, "troubleshooting.md"}, // Should return base URL
|
||||
{appconstants.ErrCodeFileNotFound, "#file-not-found"},
|
||||
{appconstants.ErrCodeInvalidYAML, "#invalid-yaml"},
|
||||
{appconstants.ErrCodeGitHubAPI, "#github-api-errors"},
|
||||
{appconstants.ErrCodeUnknown, "troubleshooting.md"}, // Should return base URL
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -1,4 +1,4 @@
|
||||
package errors
|
||||
package apperrors
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -6,10 +6,12 @@ import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
)
|
||||
|
||||
// GetSuggestions returns context-aware suggestions for the given error code.
|
||||
func GetSuggestions(code ErrorCode, context map[string]string) []string {
|
||||
func GetSuggestions(code appconstants.ErrorCode, context map[string]string) []string {
|
||||
if handler := getSuggestionHandler(code); handler != nil {
|
||||
return handler(context)
|
||||
}
|
||||
@@ -18,35 +20,31 @@ func GetSuggestions(code ErrorCode, context map[string]string) []string {
|
||||
}
|
||||
|
||||
// getSuggestionHandler returns the appropriate suggestion function for the error code.
|
||||
func getSuggestionHandler(code ErrorCode) func(map[string]string) []string {
|
||||
handlers := map[ErrorCode]func(map[string]string) []string{
|
||||
ErrCodeFileNotFound: getFileNotFoundSuggestions,
|
||||
ErrCodePermission: getPermissionSuggestions,
|
||||
ErrCodeInvalidYAML: getInvalidYAMLSuggestions,
|
||||
ErrCodeInvalidAction: getInvalidActionSuggestions,
|
||||
ErrCodeNoActionFiles: getNoActionFilesSuggestions,
|
||||
ErrCodeGitHubAPI: getGitHubAPISuggestions,
|
||||
ErrCodeConfiguration: getConfigurationSuggestions,
|
||||
ErrCodeValidation: getValidationSuggestions,
|
||||
ErrCodeTemplateRender: getTemplateSuggestions,
|
||||
ErrCodeFileWrite: getFileWriteSuggestions,
|
||||
ErrCodeDependencyAnalysis: getDependencyAnalysisSuggestions,
|
||||
ErrCodeCacheAccess: getCacheAccessSuggestions,
|
||||
func getSuggestionHandler(code appconstants.ErrorCode) func(map[string]string) []string {
|
||||
handlers := map[appconstants.ErrorCode]func(map[string]string) []string{
|
||||
appconstants.ErrCodeFileNotFound: getFileNotFoundSuggestions,
|
||||
appconstants.ErrCodePermission: getPermissionSuggestions,
|
||||
appconstants.ErrCodeInvalidYAML: getInvalidYAMLSuggestions,
|
||||
appconstants.ErrCodeInvalidAction: getInvalidActionSuggestions,
|
||||
appconstants.ErrCodeNoActionFiles: getNoActionFilesSuggestions,
|
||||
appconstants.ErrCodeGitHubAPI: getGitHubAPISuggestions,
|
||||
appconstants.ErrCodeConfiguration: getConfigurationSuggestions,
|
||||
appconstants.ErrCodeValidation: getValidationSuggestions,
|
||||
appconstants.ErrCodeTemplateRender: getTemplateSuggestions,
|
||||
appconstants.ErrCodeFileWrite: getFileWriteSuggestions,
|
||||
appconstants.ErrCodeDependencyAnalysis: getDependencyAnalysisSuggestions,
|
||||
appconstants.ErrCodeCacheAccess: getCacheAccessSuggestions,
|
||||
}
|
||||
|
||||
// Special cases for handlers without context
|
||||
switch code {
|
||||
case ErrCodeGitHubRateLimit:
|
||||
if code == appconstants.ErrCodeGitHubRateLimit {
|
||||
return func(_ map[string]string) []string { return getGitHubRateLimitSuggestions() }
|
||||
case ErrCodeGitHubAuth:
|
||||
}
|
||||
if code == appconstants.ErrCodeGitHubAuth {
|
||||
return func(_ map[string]string) []string { return getGitHubAuthSuggestions() }
|
||||
case ErrCodeFileNotFound, ErrCodePermission, ErrCodeInvalidYAML, ErrCodeInvalidAction,
|
||||
ErrCodeNoActionFiles, ErrCodeGitHubAPI, ErrCodeConfiguration, ErrCodeValidation,
|
||||
ErrCodeTemplateRender, ErrCodeFileWrite, ErrCodeDependencyAnalysis, ErrCodeCacheAccess,
|
||||
ErrCodeUnknown:
|
||||
// These cases are handled by the map above
|
||||
}
|
||||
|
||||
// All other cases are handled by the handlers map
|
||||
return handlers[code]
|
||||
}
|
||||
|
||||
@@ -1,26 +1,44 @@
|
||||
package errors
|
||||
package apperrors
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
// Test helper factories for creating context maps
|
||||
|
||||
func ctxPath(path string) map[string]string {
|
||||
return map[string]string{"path": path}
|
||||
}
|
||||
|
||||
func ctxError(err string) map[string]string {
|
||||
return map[string]string{"error": err}
|
||||
}
|
||||
|
||||
func ctxStatusCode(code string) map[string]string {
|
||||
return map[string]string{"status_code": code}
|
||||
}
|
||||
|
||||
func ctxEmpty() map[string]string {
|
||||
return map[string]string{}
|
||||
}
|
||||
|
||||
func TestGetSuggestions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
code ErrorCode
|
||||
code appconstants.ErrorCode
|
||||
context map[string]string
|
||||
contains []string
|
||||
}{
|
||||
{
|
||||
name: "file not found with path",
|
||||
code: ErrCodeFileNotFound,
|
||||
context: map[string]string{
|
||||
"path": "/path/to/action.yml",
|
||||
},
|
||||
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",
|
||||
@@ -29,10 +47,8 @@ func TestGetSuggestions(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "file not found action file",
|
||||
code: ErrCodeFileNotFound,
|
||||
context: map[string]string{
|
||||
"path": "/project/action.yml",
|
||||
},
|
||||
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",
|
||||
@@ -40,10 +56,8 @@ func TestGetSuggestions(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "permission denied",
|
||||
code: ErrCodePermission,
|
||||
context: map[string]string{
|
||||
"path": "/restricted/file.txt",
|
||||
},
|
||||
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",
|
||||
},
|
||||
@@ -64,10 +78,8 @@ 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)",
|
||||
},
|
||||
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",
|
||||
},
|
||||
@@ -100,10 +112,8 @@ func TestGetSuggestions(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "GitHub API 401 error",
|
||||
code: ErrCodeGitHubAPI,
|
||||
context: map[string]string{
|
||||
"status_code": "401",
|
||||
},
|
||||
code: appconstants.ErrCodeGitHubAPI,
|
||||
context: ctxStatusCode("401"),
|
||||
contains: []string{
|
||||
"Authentication failed",
|
||||
"check your GitHub token",
|
||||
@@ -112,10 +122,8 @@ func TestGetSuggestions(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "GitHub API 403 error",
|
||||
code: ErrCodeGitHubAPI,
|
||||
context: map[string]string{
|
||||
"status_code": "403",
|
||||
},
|
||||
code: appconstants.ErrCodeGitHubAPI,
|
||||
context: ctxStatusCode("403"),
|
||||
contains: []string{
|
||||
"Access forbidden",
|
||||
"check token permissions",
|
||||
@@ -124,10 +132,8 @@ func TestGetSuggestions(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "GitHub API 404 error",
|
||||
code: ErrCodeGitHubAPI,
|
||||
context: map[string]string{
|
||||
"status_code": "404",
|
||||
},
|
||||
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")
|
||||
testutil.AssertSliceContainsAll(t, suggestions, []string{"action.yml, action.yaml", "subdirectory"})
|
||||
}
|
||||
|
||||
if !strings.Contains(allSuggestions, "subdirectory") {
|
||||
t.Error("Should suggest checking subdirectories for action files")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetInvalidYAMLSuggestions_TabError(t *testing.T) {
|
||||
func TestGetInvalidYAMLSuggestionsTabError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
context := map[string]string{
|
||||
@@ -352,15 +321,10 @@ func TestGetInvalidYAMLSuggestions_TabError(t *testing.T) {
|
||||
}
|
||||
|
||||
suggestions := getInvalidYAMLSuggestions(context)
|
||||
allSuggestions := strings.Join(suggestions, " ")
|
||||
|
||||
// Should prioritize tab-specific suggestions when error mentions tabs
|
||||
if !strings.Contains(allSuggestions, "tabs with spaces") {
|
||||
t.Error("Should provide tab-specific suggestions when error mentions tabs")
|
||||
}
|
||||
testutil.AssertSliceContainsAll(t, suggestions, []string{"tabs with spaces"})
|
||||
}
|
||||
|
||||
func TestGetGitHubAPISuggestions_StatusCodes(t *testing.T) {
|
||||
func TestGetGitHubAPISuggestionsStatusCodes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
statusCodes := map[string]string{
|
||||
@@ -375,11 +339,7 @@ func TestGetGitHubAPISuggestions_StatusCodes(t *testing.T) {
|
||||
|
||||
context := map[string]string{"status_code": code}
|
||||
suggestions := getGitHubAPISuggestions(context)
|
||||
allSuggestions := strings.Join(suggestions, " ")
|
||||
|
||||
if !strings.Contains(allSuggestions, expectedText) {
|
||||
t.Errorf("Status code %s suggestions should contain %q", code, expectedText)
|
||||
}
|
||||
testutil.AssertSliceContainsAll(t, suggestions, []string{expectedText})
|
||||
})
|
||||
}
|
||||
}
|
||||
17
internal/cache/cache.go
vendored
17
internal/cache/cache.go
vendored
@@ -10,6 +10,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/adrg/xdg"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
)
|
||||
|
||||
// Entry represents a cached item with TTL support.
|
||||
@@ -53,13 +55,15 @@ func NewCache(config *Config) (*Cache, error) {
|
||||
}
|
||||
|
||||
// Get XDG cache directory
|
||||
cacheDir, err := xdg.CacheFile("gh-action-readme")
|
||||
cacheDir, err := xdg.CacheFile(appconstants.AppName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get XDG cache directory: %w", err)
|
||||
}
|
||||
|
||||
// Ensure cache directory exists
|
||||
if err := os.MkdirAll(filepath.Dir(cacheDir), 0750); err != nil { // #nosec G301 -- cache directory permissions
|
||||
cacheDirParent := filepath.Dir(cacheDir)
|
||||
// #nosec G301 -- cache directory permissions
|
||||
if err := os.MkdirAll(cacheDirParent, appconstants.FilePermDir); err != nil {
|
||||
return nil, fmt.Errorf("failed to create cache directory: %w", err)
|
||||
}
|
||||
|
||||
@@ -145,7 +149,7 @@ func (c *Cache) Clear() error {
|
||||
c.data = make(map[string]Entry)
|
||||
|
||||
// Remove cache file
|
||||
cacheFile := filepath.Join(c.path, "cache.json")
|
||||
cacheFile := filepath.Join(c.path, appconstants.CacheJSON)
|
||||
if err := os.Remove(cacheFile); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("failed to remove cache file: %w", err)
|
||||
}
|
||||
@@ -245,7 +249,7 @@ func (c *Cache) cleanup() {
|
||||
|
||||
// loadFromDisk loads cache data from disk.
|
||||
func (c *Cache) loadFromDisk() error {
|
||||
cacheFile := filepath.Join(c.path, "cache.json")
|
||||
cacheFile := filepath.Join(c.path, appconstants.CacheJSON)
|
||||
|
||||
data, err := os.ReadFile(cacheFile) // #nosec G304 -- cache file path constructed internally
|
||||
if err != nil {
|
||||
@@ -280,8 +284,9 @@ func (c *Cache) saveToDisk() error {
|
||||
return fmt.Errorf("failed to marshal cache data: %w", err)
|
||||
}
|
||||
|
||||
cacheFile := filepath.Join(c.path, "cache.json")
|
||||
if err := os.WriteFile(cacheFile, jsonData, 0600); err != nil { // #nosec G306 -- cache file permissions
|
||||
cacheFile := filepath.Join(c.path, appconstants.CacheJSON)
|
||||
// #nosec G306 -- cache file permissions
|
||||
if err := os.WriteFile(cacheFile, jsonData, appconstants.FilePermDefault); err != nil {
|
||||
return fmt.Errorf("failed to write cache file: %w", err)
|
||||
}
|
||||
|
||||
|
||||
26
internal/cache/cache_test.go
vendored
26
internal/cache/cache_test.go
vendored
@@ -74,7 +74,7 @@ func TestCache_SetAndGet(t *testing.T) {
|
||||
defer cleanup()
|
||||
|
||||
cache := createTestCache(t, tmpDir)
|
||||
defer func() { _ = cache.Close() }()
|
||||
defer testutil.CleanupCache(t, cache)()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -126,7 +126,7 @@ func TestCache_TTL(t *testing.T) {
|
||||
defer cleanup()
|
||||
|
||||
cache := createTestCache(t, tmpDir)
|
||||
defer func() { _ = cache.Close() }()
|
||||
defer testutil.CleanupCache(t, cache)()
|
||||
|
||||
// Set value with short TTL
|
||||
shortTTL := 100 * time.Millisecond
|
||||
@@ -155,7 +155,7 @@ func TestCache_GetOrSet(t *testing.T) {
|
||||
defer cleanup()
|
||||
|
||||
cache := createTestCache(t, tmpDir)
|
||||
defer func() { _ = cache.Close() }()
|
||||
defer testutil.CleanupCache(t, cache)()
|
||||
|
||||
// Use unique key to avoid interference from other tests
|
||||
testKey := fmt.Sprintf("test-key-%d", time.Now().UnixNano())
|
||||
@@ -185,7 +185,7 @@ func TestCache_GetOrSetError(t *testing.T) {
|
||||
defer cleanup()
|
||||
|
||||
cache := createTestCache(t, tmpDir)
|
||||
defer func() { _ = cache.Close() }()
|
||||
defer testutil.CleanupCache(t, cache)()
|
||||
|
||||
// Getter that returns error
|
||||
getter := func() (any, error) {
|
||||
@@ -212,7 +212,7 @@ func TestCache_ConcurrentAccess(t *testing.T) {
|
||||
defer cleanup()
|
||||
|
||||
cache := createTestCache(t, tmpDir)
|
||||
defer func() { _ = cache.Close() }()
|
||||
defer testutil.CleanupCache(t, cache)()
|
||||
|
||||
const numGoroutines = 10
|
||||
const numOperations = 100
|
||||
@@ -272,7 +272,7 @@ func TestCache_Persistence(t *testing.T) {
|
||||
|
||||
// Create new cache instance (should load from disk)
|
||||
cache2 := createTestCache(t, tmpDir)
|
||||
defer func() { _ = cache2.Close() }()
|
||||
defer testutil.CleanupCache(t, cache2)()
|
||||
|
||||
// Value should still exist
|
||||
value, exists := cache2.Get("persistent-key")
|
||||
@@ -287,7 +287,7 @@ func TestCache_Clear(t *testing.T) {
|
||||
defer cleanup()
|
||||
|
||||
cache := createTestCache(t, tmpDir)
|
||||
defer func() { _ = cache.Close() }()
|
||||
defer testutil.CleanupCache(t, cache)()
|
||||
|
||||
// Add some data
|
||||
_ = cache.Set("key1", "value1")
|
||||
@@ -317,7 +317,7 @@ func TestCache_Delete(t *testing.T) {
|
||||
defer cleanup()
|
||||
|
||||
cache := createTestCache(t, tmpDir)
|
||||
defer func() { _ = cache.Close() }()
|
||||
defer testutil.CleanupCache(t, cache)()
|
||||
|
||||
// Add some data
|
||||
_ = cache.Set("key1", "value1")
|
||||
@@ -354,7 +354,7 @@ func TestCache_Stats(t *testing.T) {
|
||||
defer cleanup()
|
||||
|
||||
cache := createTestCache(t, tmpDir)
|
||||
defer func() { _ = cache.Close() }()
|
||||
defer testutil.CleanupCache(t, cache)()
|
||||
|
||||
// Ensure cache starts clean
|
||||
_ = cache.Clear()
|
||||
@@ -412,7 +412,7 @@ func TestCache_CleanupExpiredEntries(t *testing.T) {
|
||||
|
||||
cache, err := NewCache(config)
|
||||
testutil.AssertNoError(t, err)
|
||||
defer func() { _ = cache.Close() }()
|
||||
defer testutil.CleanupCache(t, cache)()
|
||||
|
||||
// Add entry that will expire
|
||||
err = cache.Set("expiring-key", "expiring-value")
|
||||
@@ -465,7 +465,7 @@ func TestCache_ErrorHandling(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cache := tt.setupFunc(t)
|
||||
defer func() { _ = cache.Close() }()
|
||||
defer testutil.CleanupCache(t, cache)()
|
||||
|
||||
tt.testFunc(t, cache)
|
||||
})
|
||||
@@ -477,7 +477,7 @@ func TestCache_AsyncSaveErrorHandling(t *testing.T) {
|
||||
defer cleanup()
|
||||
|
||||
cache := createTestCache(t, tmpDir)
|
||||
defer func() { _ = cache.Close() }()
|
||||
defer testutil.CleanupCache(t, cache)()
|
||||
|
||||
// This tests our new saveToDiskAsync error handling
|
||||
// Set a value to trigger async save
|
||||
@@ -502,7 +502,7 @@ func TestCache_EstimateSize(t *testing.T) {
|
||||
defer cleanup()
|
||||
|
||||
cache := createTestCache(t, tmpDir)
|
||||
defer func() { _ = cache.Close() }()
|
||||
defer testutil.CleanupCache(t, cache)()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/adrg/xdg"
|
||||
"github.com/gofri/go-github-ratelimit/github_ratelimit"
|
||||
@@ -14,6 +13,7 @@ import (
|
||||
"github.com/spf13/viper"
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/git"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/validation"
|
||||
"github.com/ivuorinen/gh-action-readme/templates_embed"
|
||||
@@ -79,13 +79,8 @@ type GitHubClient struct {
|
||||
|
||||
// GetGitHubToken returns the GitHub token from environment variables or config.
|
||||
func GetGitHubToken(config *AppConfig) string {
|
||||
// Priority 1: Tool-specific env var
|
||||
if token := os.Getenv(EnvGitHubToken); token != "" {
|
||||
return token
|
||||
}
|
||||
|
||||
// Priority 2: Standard GitHub env var
|
||||
if token := os.Getenv(EnvGitHubTokenStandard); token != "" {
|
||||
// Priority 1 & 2: Environment variables
|
||||
if token := loadGitHubTokenFromEnv(); token != "" {
|
||||
return token
|
||||
}
|
||||
|
||||
@@ -109,7 +104,7 @@ func NewGitHubClient(token string) (*GitHubClient, error) {
|
||||
// Add rate limiting with proper error handling
|
||||
rateLimiter, err := github_ratelimit.NewRateLimitWaiterClient(tc.Transport)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create rate limiter: %w", err)
|
||||
return nil, fmt.Errorf(appconstants.ErrFailedToCreateRateLimiter, err)
|
||||
}
|
||||
|
||||
client = github.NewClient(rateLimiter)
|
||||
@@ -117,7 +112,7 @@ func NewGitHubClient(token string) (*GitHubClient, error) {
|
||||
// For no token, use basic rate limiter
|
||||
rateLimiter, err := github_ratelimit.NewRateLimitWaiterClient(nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create rate limiter: %w", err)
|
||||
return nil, fmt.Errorf(appconstants.ErrFailedToCreateRateLimiter, err)
|
||||
}
|
||||
client = github.NewClient(rateLimiter)
|
||||
}
|
||||
@@ -180,21 +175,29 @@ func resolveTemplatePath(templatePath string) string {
|
||||
return resolvedPath
|
||||
}
|
||||
|
||||
// resolveAllTemplatePaths resolves all template-related paths in the config.
|
||||
func resolveAllTemplatePaths(config *AppConfig) {
|
||||
config.Template = resolveTemplatePath(config.Template)
|
||||
config.Header = resolveTemplatePath(config.Header)
|
||||
config.Footer = resolveTemplatePath(config.Footer)
|
||||
config.Schema = resolveTemplatePath(config.Schema)
|
||||
}
|
||||
|
||||
// resolveThemeTemplate resolves the template path based on the selected theme.
|
||||
func resolveThemeTemplate(theme string) string {
|
||||
var templatePath string
|
||||
|
||||
switch theme {
|
||||
case ThemeDefault:
|
||||
templatePath = TemplatePathDefault
|
||||
case ThemeGitHub:
|
||||
templatePath = TemplatePathGitHub
|
||||
case ThemeGitLab:
|
||||
templatePath = TemplatePathGitLab
|
||||
case ThemeMinimal:
|
||||
templatePath = TemplatePathMinimal
|
||||
case ThemeProfessional:
|
||||
templatePath = TemplatePathProfessional
|
||||
case appconstants.ThemeDefault:
|
||||
templatePath = appconstants.TemplatePathDefault
|
||||
case appconstants.ThemeGitHub:
|
||||
templatePath = appconstants.TemplatePathGitHub
|
||||
case appconstants.ThemeGitLab:
|
||||
templatePath = appconstants.TemplatePathGitLab
|
||||
case appconstants.ThemeMinimal:
|
||||
templatePath = appconstants.TemplatePathMinimal
|
||||
case appconstants.ThemeProfessional:
|
||||
templatePath = appconstants.TemplatePathProfessional
|
||||
case "":
|
||||
// Empty theme should return empty path
|
||||
return ""
|
||||
@@ -290,25 +293,23 @@ func mergeStringFields(dst *AppConfig, src *AppConfig) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
// 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
|
||||
}
|
||||
for k, v := range src.Permissions {
|
||||
dst.Permissions[k] = v
|
||||
if *dst == nil {
|
||||
*dst = make(map[string]string)
|
||||
}
|
||||
for k, v := range src {
|
||||
(*dst)[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
|
||||
}
|
||||
}
|
||||
// mergeMapFields merges map fields from src to dst if non-empty.
|
||||
func mergeMapFields(dst *AppConfig, src *AppConfig) {
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
// 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)
|
||||
}
|
||||
|
||||
var config AppConfig
|
||||
if err := v.Unmarshal(&config); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal repo config: %w", err)
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
}
|
||||
}
|
||||
|
||||
// No config found, return empty config
|
||||
return &AppConfig{}, nil
|
||||
}
|
||||
|
||||
// LoadActionConfig loads action-level configuration from config.yaml.
|
||||
func LoadActionConfig(actionDir string) (*AppConfig, error) {
|
||||
configPath := filepath.Join(actionDir, "config.yaml")
|
||||
return loadActionConfigInternal(actionDir)
|
||||
}
|
||||
|
||||
// loadActionConfigInternal is the shared internal implementation for action config loading.
|
||||
func loadActionConfigInternal(actionDir string) (*AppConfig, error) {
|
||||
configPath := filepath.Join(actionDir, appconstants.ConfigYAML)
|
||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||
return &AppConfig{}, nil // No action config is fine
|
||||
return &AppConfig{}, nil
|
||||
}
|
||||
|
||||
v := viper.New()
|
||||
v.SetConfigFile(configPath)
|
||||
v.SetConfigType("yaml")
|
||||
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
return nil, fmt.Errorf("failed to read action config %s: %w", configPath, err)
|
||||
}
|
||||
|
||||
var config AppConfig
|
||||
if err := v.Unmarshal(&config); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal action config: %w", err)
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
return loadConfigFromViper(configPath)
|
||||
}
|
||||
|
||||
// DetectRepositoryName detects the repository name from git remote URL.
|
||||
@@ -430,7 +404,7 @@ func LoadConfiguration(configFile, repoRoot, actionDir string) (*AppConfig, erro
|
||||
// 2. Load global config
|
||||
globalConfig, err := InitConfig(configFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load global config: %w", err)
|
||||
return nil, fmt.Errorf(appconstants.ErrFailedToLoadGlobalConfig, err)
|
||||
}
|
||||
MergeConfigs(config, globalConfig, true) // Allow tokens for global config
|
||||
|
||||
@@ -446,7 +420,7 @@ func LoadConfiguration(configFile, repoRoot, actionDir string) (*AppConfig, erro
|
||||
if repoRoot != "" {
|
||||
repoConfig, err := LoadRepoConfig(repoRoot)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load repo config: %w", err)
|
||||
return nil, fmt.Errorf(appconstants.ErrFailedToLoadRepoConfig, err)
|
||||
}
|
||||
MergeConfigs(config, repoConfig, false) // No tokens in repo config
|
||||
}
|
||||
@@ -455,16 +429,14 @@ func LoadConfiguration(configFile, repoRoot, actionDir string) (*AppConfig, erro
|
||||
if actionDir != "" {
|
||||
actionConfig, err := LoadActionConfig(actionDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load action config: %w", err)
|
||||
return nil, fmt.Errorf(appconstants.ErrFailedToLoadActionConfig, err)
|
||||
}
|
||||
MergeConfigs(config, actionConfig, false) // No tokens in action config
|
||||
}
|
||||
|
||||
// 6. Apply environment variable overrides for GitHub token
|
||||
// Check environment variables directly with higher priority
|
||||
if token := os.Getenv(EnvGitHubToken); token != "" {
|
||||
config.GitHubToken = token
|
||||
} else if token := os.Getenv(EnvGitHubTokenStandard); token != "" {
|
||||
if token := loadGitHubTokenFromEnv(); token != "" {
|
||||
config.GitHubToken = token
|
||||
}
|
||||
|
||||
@@ -473,108 +445,46 @@ func LoadConfiguration(configFile, repoRoot, actionDir string) (*AppConfig, erro
|
||||
|
||||
// InitConfig initializes the global configuration using Viper with XDG compliance.
|
||||
func InitConfig(configFile string) (*AppConfig, error) {
|
||||
v := viper.New()
|
||||
|
||||
// Set configuration file name and type
|
||||
v.SetConfigName(ConfigFileName)
|
||||
v.SetConfigType("yaml")
|
||||
|
||||
// Add XDG-compliant configuration directory
|
||||
configDir, err := xdg.ConfigFile("gh-action-readme")
|
||||
v, err := initializeViperInstance()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get XDG config directory: %w", err)
|
||||
}
|
||||
v.AddConfigPath(filepath.Dir(configDir))
|
||||
|
||||
// Add additional search paths
|
||||
v.AddConfigPath(".") // current directory
|
||||
v.AddConfigPath("$HOME/.config/gh-action-readme") // fallback
|
||||
v.AddConfigPath("/etc/gh-action-readme") // system-wide
|
||||
|
||||
// Set environment variable prefix
|
||||
v.SetEnvPrefix("GH_ACTION_README")
|
||||
v.SetEnvKeyReplacer(strings.NewReplacer("-", "_", ".", "_"))
|
||||
v.AutomaticEnv()
|
||||
|
||||
// Set defaults
|
||||
defaults := DefaultAppConfig()
|
||||
v.SetDefault("organization", defaults.Organization)
|
||||
v.SetDefault("repository", defaults.Repository)
|
||||
v.SetDefault("version", defaults.Version)
|
||||
v.SetDefault("theme", defaults.Theme)
|
||||
v.SetDefault("output_format", defaults.OutputFormat)
|
||||
v.SetDefault("output_dir", defaults.OutputDir)
|
||||
v.SetDefault("template", defaults.Template)
|
||||
v.SetDefault("header", defaults.Header)
|
||||
v.SetDefault("footer", defaults.Footer)
|
||||
v.SetDefault("schema", defaults.Schema)
|
||||
v.SetDefault("analyze_dependencies", defaults.AnalyzeDependencies)
|
||||
v.SetDefault("show_security_info", defaults.ShowSecurityInfo)
|
||||
v.SetDefault("verbose", defaults.Verbose)
|
||||
v.SetDefault("quiet", defaults.Quiet)
|
||||
v.SetDefault("defaults.name", defaults.Defaults.Name)
|
||||
v.SetDefault("defaults.description", defaults.Defaults.Description)
|
||||
v.SetDefault("defaults.branding.icon", defaults.Defaults.Branding.Icon)
|
||||
v.SetDefault("defaults.branding.color", defaults.Defaults.Branding.Color)
|
||||
|
||||
// Use specific config file if provided
|
||||
if configFile != "" {
|
||||
v.SetConfigFile(configFile)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Read configuration
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
|
||||
return nil, fmt.Errorf("failed to read config file: %w", err)
|
||||
}
|
||||
// Config file not found is not an error - we'll use defaults and env vars
|
||||
}
|
||||
|
||||
// Unmarshal configuration into struct
|
||||
var config AppConfig
|
||||
if err := v.Unmarshal(&config); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
|
||||
}
|
||||
|
||||
// Resolve template paths relative to binary if they're not absolute
|
||||
config.Template = resolveTemplatePath(config.Template)
|
||||
config.Header = resolveTemplatePath(config.Header)
|
||||
config.Footer = resolveTemplatePath(config.Footer)
|
||||
config.Schema = resolveTemplatePath(config.Schema)
|
||||
|
||||
return &config, nil
|
||||
return loadAndUnmarshalConfig(configFile, v)
|
||||
}
|
||||
|
||||
// WriteDefaultConfig writes a default configuration file to the XDG config directory.
|
||||
func WriteDefaultConfig() error {
|
||||
configFile, err := xdg.ConfigFile("gh-action-readme/config.yaml")
|
||||
configFile, err := xdg.ConfigFile(appconstants.PathXDGConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get XDG config file path: %w", err)
|
||||
return fmt.Errorf(appconstants.ErrFailedToGetXDGConfigFile, err)
|
||||
}
|
||||
|
||||
// Ensure the directory exists
|
||||
if err := os.MkdirAll(filepath.Dir(configFile), 0750); err != nil { // #nosec G301 -- config directory permissions
|
||||
configFileDir := filepath.Dir(configFile)
|
||||
// #nosec G301 -- config directory permissions
|
||||
if err := os.MkdirAll(configFileDir, appconstants.FilePermDir); err != nil {
|
||||
return fmt.Errorf("failed to create config directory: %w", err)
|
||||
}
|
||||
|
||||
v := viper.New()
|
||||
v.SetConfigFile(configFile)
|
||||
v.SetConfigType("yaml")
|
||||
v.SetConfigType(appconstants.OutputFormatYAML)
|
||||
|
||||
// Set default values
|
||||
defaults := DefaultAppConfig()
|
||||
v.Set("theme", defaults.Theme)
|
||||
v.Set("output_format", defaults.OutputFormat)
|
||||
v.Set("output_dir", defaults.OutputDir)
|
||||
v.Set("analyze_dependencies", defaults.AnalyzeDependencies)
|
||||
v.Set("show_security_info", defaults.ShowSecurityInfo)
|
||||
v.Set("verbose", defaults.Verbose)
|
||||
v.Set("quiet", defaults.Quiet)
|
||||
v.Set("template", defaults.Template)
|
||||
v.Set("header", defaults.Header)
|
||||
v.Set("footer", defaults.Footer)
|
||||
v.Set("schema", defaults.Schema)
|
||||
v.Set("defaults", defaults.Defaults)
|
||||
v.Set(appconstants.ConfigKeyTheme, defaults.Theme)
|
||||
v.Set(appconstants.ConfigKeyOutputFormat, defaults.OutputFormat)
|
||||
v.Set(appconstants.ConfigKeyOutputDir, defaults.OutputDir)
|
||||
v.Set(appconstants.ConfigKeyAnalyzeDependencies, defaults.AnalyzeDependencies)
|
||||
v.Set(appconstants.ConfigKeyShowSecurityInfo, defaults.ShowSecurityInfo)
|
||||
v.Set(appconstants.ConfigKeyVerbose, defaults.Verbose)
|
||||
v.Set(appconstants.ConfigKeyQuiet, defaults.Quiet)
|
||||
v.Set(appconstants.ConfigKeyTemplate, defaults.Template)
|
||||
v.Set(appconstants.ConfigKeyHeader, defaults.Header)
|
||||
v.Set(appconstants.ConfigKeyFooter, defaults.Footer)
|
||||
v.Set(appconstants.ConfigKeySchema, defaults.Schema)
|
||||
v.Set(appconstants.ConfigKeyDefaults, defaults.Defaults)
|
||||
|
||||
if err := v.WriteConfig(); err != nil {
|
||||
return fmt.Errorf("failed to write default config: %w", err)
|
||||
@@ -585,9 +495,9 @@ func WriteDefaultConfig() error {
|
||||
|
||||
// GetConfigPath returns the path to the configuration file.
|
||||
func GetConfigPath() (string, error) {
|
||||
configDir, err := xdg.ConfigFile("gh-action-readme/config.yaml")
|
||||
configDir, err := xdg.ConfigFile(appconstants.PathXDGConfig)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get XDG config file path: %w", err)
|
||||
return "", fmt.Errorf(appconstants.ErrFailedToGetXDGConfigFile, err)
|
||||
}
|
||||
|
||||
return configDir, nil
|
||||
|
||||
20
internal/config_helper.go
Normal file
20
internal/config_helper.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// findFirstExistingConfig searches for the first existing config file
|
||||
// from a list of config names within a base directory.
|
||||
// Returns the full path to the first existing config file, or empty string if none exist.
|
||||
func findFirstExistingConfig(basePath string, configNames []string) (string, bool) {
|
||||
for _, name := range configNames {
|
||||
path := filepath.Join(basePath, name)
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return path, true
|
||||
}
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
@@ -123,6 +124,10 @@ func TestLoadConfiguration(t *testing.T) {
|
||||
name: "multi-level config hierarchy",
|
||||
setupFunc: func(t *testing.T, tempDir string) (string, string, string) {
|
||||
t.Helper()
|
||||
// Clear environment variables to ensure config file values are used
|
||||
t.Setenv(appconstants.EnvGitHubTokenStandard, "")
|
||||
t.Setenv(appconstants.EnvGitHubToken, "")
|
||||
|
||||
// Create global config
|
||||
globalConfigDir := filepath.Join(tempDir, ".config", "gh-action-readme")
|
||||
_ = os.MkdirAll(globalConfigDir, 0750) // #nosec G301 -- test directory permissions
|
||||
@@ -331,13 +336,7 @@ func TestWriteDefaultConfig(t *testing.T) {
|
||||
// Check that config file was created
|
||||
configPath, _ := GetConfigPath()
|
||||
t.Logf("Expected config path: %s", configPath)
|
||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||
t.Errorf("config file was not created at: %s", configPath)
|
||||
// List what files were actually created
|
||||
if files, err := os.ReadDir(tmpDir); err == nil {
|
||||
t.Logf("Files in tmpDir: %v", files)
|
||||
}
|
||||
}
|
||||
testutil.AssertFileExists(t, configPath)
|
||||
|
||||
// Verify config file content
|
||||
config, err := InitConfig(configPath)
|
||||
@@ -543,14 +542,14 @@ func TestGetGitHubToken(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Set up environment
|
||||
if tt.toolEnvToken != "" {
|
||||
t.Setenv(EnvGitHubToken, tt.toolEnvToken)
|
||||
t.Setenv(appconstants.EnvGitHubToken, tt.toolEnvToken)
|
||||
} else {
|
||||
t.Setenv(EnvGitHubToken, "")
|
||||
t.Setenv(appconstants.EnvGitHubToken, "")
|
||||
}
|
||||
if tt.stdEnvToken != "" {
|
||||
t.Setenv(EnvGitHubTokenStandard, tt.stdEnvToken)
|
||||
t.Setenv(appconstants.EnvGitHubTokenStandard, tt.stdEnvToken)
|
||||
} else {
|
||||
t.Setenv(EnvGitHubTokenStandard, "")
|
||||
t.Setenv(appconstants.EnvGitHubTokenStandard, "")
|
||||
}
|
||||
|
||||
config := &AppConfig{GitHubToken: tt.configToken}
|
||||
|
||||
@@ -3,33 +3,18 @@ package internal
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/adrg/xdg"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// ConfigurationSource represents different sources of configuration.
|
||||
type ConfigurationSource int
|
||||
|
||||
// Configuration source priority order (lowest to highest priority).
|
||||
const (
|
||||
// SourceDefaults represents default configuration values.
|
||||
SourceDefaults ConfigurationSource = iota
|
||||
SourceGlobal
|
||||
SourceRepoOverride
|
||||
SourceRepoConfig
|
||||
SourceActionConfig
|
||||
SourceEnvironment
|
||||
SourceCLIFlags
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
)
|
||||
|
||||
// ConfigurationLoader handles loading and merging configuration from multiple sources.
|
||||
type ConfigurationLoader struct {
|
||||
// sources tracks which sources are enabled
|
||||
sources map[ConfigurationSource]bool
|
||||
sources map[appconstants.ConfigurationSource]bool
|
||||
// viper instance for global configuration
|
||||
viper *viper.Viper
|
||||
}
|
||||
@@ -41,20 +26,20 @@ type ConfigurationOptions struct {
|
||||
// AllowTokens controls whether security-sensitive fields can be loaded
|
||||
AllowTokens bool
|
||||
// EnabledSources controls which configuration sources are used
|
||||
EnabledSources []ConfigurationSource
|
||||
EnabledSources []appconstants.ConfigurationSource
|
||||
}
|
||||
|
||||
// NewConfigurationLoader creates a new configuration loader with default options.
|
||||
func NewConfigurationLoader() *ConfigurationLoader {
|
||||
return &ConfigurationLoader{
|
||||
sources: map[ConfigurationSource]bool{
|
||||
SourceDefaults: true,
|
||||
SourceGlobal: true,
|
||||
SourceRepoOverride: true,
|
||||
SourceRepoConfig: true,
|
||||
SourceActionConfig: true,
|
||||
SourceEnvironment: true,
|
||||
SourceCLIFlags: false, // CLI flags are applied separately
|
||||
sources: map[appconstants.ConfigurationSource]bool{
|
||||
appconstants.SourceDefaults: true,
|
||||
appconstants.SourceGlobal: true,
|
||||
appconstants.SourceRepoOverride: true,
|
||||
appconstants.SourceRepoConfig: true,
|
||||
appconstants.SourceActionConfig: true,
|
||||
appconstants.SourceEnvironment: true,
|
||||
appconstants.SourceCLIFlags: false, // CLI flags are applied separately
|
||||
},
|
||||
viper: viper.New(),
|
||||
}
|
||||
@@ -63,15 +48,15 @@ func NewConfigurationLoader() *ConfigurationLoader {
|
||||
// NewConfigurationLoaderWithOptions creates a configuration loader with custom options.
|
||||
func NewConfigurationLoaderWithOptions(opts ConfigurationOptions) *ConfigurationLoader {
|
||||
loader := &ConfigurationLoader{
|
||||
sources: make(map[ConfigurationSource]bool),
|
||||
sources: make(map[appconstants.ConfigurationSource]bool),
|
||||
viper: viper.New(),
|
||||
}
|
||||
|
||||
// Set default sources if none specified
|
||||
if len(opts.EnabledSources) == 0 {
|
||||
opts.EnabledSources = []ConfigurationSource{
|
||||
SourceDefaults, SourceGlobal, SourceRepoOverride,
|
||||
SourceRepoConfig, SourceActionConfig, SourceEnvironment,
|
||||
opts.EnabledSources = []appconstants.ConfigurationSource{
|
||||
appconstants.SourceDefaults, appconstants.SourceGlobal, appconstants.SourceRepoOverride,
|
||||
appconstants.SourceRepoConfig, appconstants.SourceActionConfig, appconstants.SourceEnvironment,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,8 +143,8 @@ func containsString(slice []string, str string) bool {
|
||||
}
|
||||
|
||||
// GetConfigurationSources returns the currently enabled configuration sources.
|
||||
func (cl *ConfigurationLoader) GetConfigurationSources() []ConfigurationSource {
|
||||
var sources []ConfigurationSource
|
||||
func (cl *ConfigurationLoader) GetConfigurationSources() []appconstants.ConfigurationSource {
|
||||
var sources []appconstants.ConfigurationSource
|
||||
for source, enabled := range cl.sources {
|
||||
if enabled {
|
||||
sources = append(sources, source)
|
||||
@@ -170,18 +155,18 @@ func (cl *ConfigurationLoader) GetConfigurationSources() []ConfigurationSource {
|
||||
}
|
||||
|
||||
// EnableSource enables a specific configuration source.
|
||||
func (cl *ConfigurationLoader) EnableSource(source ConfigurationSource) {
|
||||
func (cl *ConfigurationLoader) EnableSource(source appconstants.ConfigurationSource) {
|
||||
cl.sources[source] = true
|
||||
}
|
||||
|
||||
// DisableSource disables a specific configuration source.
|
||||
func (cl *ConfigurationLoader) DisableSource(source ConfigurationSource) {
|
||||
func (cl *ConfigurationLoader) DisableSource(source appconstants.ConfigurationSource) {
|
||||
cl.sources[source] = false
|
||||
}
|
||||
|
||||
// loadDefaultsStep loads default configuration values.
|
||||
func (cl *ConfigurationLoader) loadDefaultsStep(config *AppConfig) {
|
||||
if cl.sources[SourceDefaults] {
|
||||
if cl.sources[appconstants.SourceDefaults] {
|
||||
defaults := DefaultAppConfig()
|
||||
*config = *defaults
|
||||
}
|
||||
@@ -189,13 +174,13 @@ func (cl *ConfigurationLoader) loadDefaultsStep(config *AppConfig) {
|
||||
|
||||
// loadGlobalStep loads global configuration.
|
||||
func (cl *ConfigurationLoader) loadGlobalStep(config *AppConfig, configFile string) error {
|
||||
if !cl.sources[SourceGlobal] {
|
||||
if !cl.sources[appconstants.SourceGlobal] {
|
||||
return nil
|
||||
}
|
||||
|
||||
globalConfig, err := cl.loadGlobalConfig(configFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load global config: %w", err)
|
||||
return fmt.Errorf(appconstants.ErrFailedToLoadGlobalConfig, err)
|
||||
}
|
||||
cl.mergeConfigs(config, globalConfig, true) // Allow tokens for global config
|
||||
|
||||
@@ -204,7 +189,7 @@ func (cl *ConfigurationLoader) loadGlobalStep(config *AppConfig, configFile stri
|
||||
|
||||
// loadRepoOverrideStep applies repo-specific overrides from global config.
|
||||
func (cl *ConfigurationLoader) loadRepoOverrideStep(config *AppConfig, repoRoot string) {
|
||||
if !cl.sources[SourceRepoOverride] || repoRoot == "" {
|
||||
if !cl.sources[appconstants.SourceRepoOverride] || repoRoot == "" {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -213,13 +198,13 @@ func (cl *ConfigurationLoader) loadRepoOverrideStep(config *AppConfig, repoRoot
|
||||
|
||||
// loadRepoConfigStep loads repository root configuration.
|
||||
func (cl *ConfigurationLoader) loadRepoConfigStep(config *AppConfig, repoRoot string) error {
|
||||
if !cl.sources[SourceRepoConfig] || repoRoot == "" {
|
||||
if !cl.sources[appconstants.SourceRepoConfig] || repoRoot == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
repoConfig, err := cl.loadRepoConfig(repoRoot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load repo config: %w", err)
|
||||
return fmt.Errorf(appconstants.ErrFailedToLoadRepoConfig, err)
|
||||
}
|
||||
cl.mergeConfigs(config, repoConfig, false) // No tokens in repo config
|
||||
|
||||
@@ -228,13 +213,13 @@ func (cl *ConfigurationLoader) loadRepoConfigStep(config *AppConfig, repoRoot st
|
||||
|
||||
// loadActionConfigStep loads action-specific configuration.
|
||||
func (cl *ConfigurationLoader) loadActionConfigStep(config *AppConfig, actionDir string) error {
|
||||
if !cl.sources[SourceActionConfig] || actionDir == "" {
|
||||
if !cl.sources[appconstants.SourceActionConfig] || actionDir == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
actionConfig, err := cl.loadActionConfig(actionDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load action config: %w", err)
|
||||
return fmt.Errorf(appconstants.ErrFailedToLoadActionConfig, err)
|
||||
}
|
||||
cl.mergeConfigs(config, actionConfig, false) // No tokens in action config
|
||||
|
||||
@@ -243,114 +228,29 @@ func (cl *ConfigurationLoader) loadActionConfigStep(config *AppConfig, actionDir
|
||||
|
||||
// loadEnvironmentStep applies environment variable overrides.
|
||||
func (cl *ConfigurationLoader) loadEnvironmentStep(config *AppConfig) {
|
||||
if cl.sources[SourceEnvironment] {
|
||||
if cl.sources[appconstants.SourceEnvironment] {
|
||||
cl.applyEnvironmentOverrides(config)
|
||||
}
|
||||
}
|
||||
|
||||
// loadGlobalConfig initializes and loads the global configuration using Viper.
|
||||
func (cl *ConfigurationLoader) loadGlobalConfig(configFile string) (*AppConfig, error) {
|
||||
v := viper.New()
|
||||
|
||||
// Set configuration file name and type
|
||||
v.SetConfigName(ConfigFileName)
|
||||
v.SetConfigType("yaml")
|
||||
|
||||
// Add XDG-compliant configuration directory
|
||||
configDir, err := xdg.ConfigFile("gh-action-readme")
|
||||
v, err := initializeViperInstance()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get XDG config directory: %w", err)
|
||||
}
|
||||
v.AddConfigPath(filepath.Dir(configDir))
|
||||
|
||||
// Add additional search paths
|
||||
v.AddConfigPath(".") // current directory
|
||||
v.AddConfigPath("$HOME/.config/gh-action-readme") // fallback
|
||||
v.AddConfigPath("/etc/gh-action-readme") // system-wide
|
||||
|
||||
// Set environment variable prefix
|
||||
v.SetEnvPrefix("GH_ACTION_README")
|
||||
v.SetEnvKeyReplacer(strings.NewReplacer("-", "_", ".", "_"))
|
||||
v.AutomaticEnv()
|
||||
|
||||
// Set defaults
|
||||
cl.setViperDefaults(v)
|
||||
|
||||
// Use specific config file if provided
|
||||
if configFile != "" {
|
||||
v.SetConfigFile(configFile)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Read configuration
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
|
||||
return nil, fmt.Errorf("failed to read config file: %w", err)
|
||||
}
|
||||
// Config file not found is not an error - we'll use defaults and env vars
|
||||
}
|
||||
|
||||
// Unmarshal configuration into struct
|
||||
var config AppConfig
|
||||
if err := v.Unmarshal(&config); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
|
||||
}
|
||||
|
||||
// Resolve template paths relative to binary if they're not absolute
|
||||
config.Template = resolveTemplatePath(config.Template)
|
||||
config.Header = resolveTemplatePath(config.Header)
|
||||
config.Footer = resolveTemplatePath(config.Footer)
|
||||
config.Schema = resolveTemplatePath(config.Schema)
|
||||
|
||||
return &config, nil
|
||||
return loadAndUnmarshalConfig(configFile, v)
|
||||
}
|
||||
|
||||
// loadRepoConfig loads repository-level configuration from hidden config files.
|
||||
func (cl *ConfigurationLoader) loadRepoConfig(repoRoot string) (*AppConfig, error) {
|
||||
// Hidden config file paths in priority order
|
||||
configPaths := []string{
|
||||
".ghreadme.yaml", // Primary hidden config
|
||||
".config/ghreadme.yaml", // Secondary hidden config
|
||||
".github/ghreadme.yaml", // GitHub ecosystem standard
|
||||
}
|
||||
|
||||
for _, configName := range configPaths {
|
||||
configPath := filepath.Join(repoRoot, configName)
|
||||
if _, err := os.Stat(configPath); err == nil {
|
||||
// Config file found, load it
|
||||
return cl.loadConfigFromFile(configPath)
|
||||
}
|
||||
}
|
||||
|
||||
// No config found, return empty config
|
||||
return &AppConfig{}, nil
|
||||
return loadRepoConfigInternal(repoRoot)
|
||||
}
|
||||
|
||||
// loadActionConfig loads action-level configuration from config.yaml.
|
||||
func (cl *ConfigurationLoader) loadActionConfig(actionDir string) (*AppConfig, error) {
|
||||
configPath := filepath.Join(actionDir, "config.yaml")
|
||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||
return &AppConfig{}, nil // No action config is fine
|
||||
}
|
||||
|
||||
return cl.loadConfigFromFile(configPath)
|
||||
}
|
||||
|
||||
// loadConfigFromFile loads configuration from a specific file.
|
||||
func (cl *ConfigurationLoader) loadConfigFromFile(configPath string) (*AppConfig, error) {
|
||||
v := viper.New()
|
||||
v.SetConfigFile(configPath)
|
||||
v.SetConfigType("yaml")
|
||||
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
return nil, fmt.Errorf("failed to read config %s: %w", configPath, err)
|
||||
}
|
||||
|
||||
var config AppConfig
|
||||
if err := v.Unmarshal(&config); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
return loadActionConfigInternal(actionDir)
|
||||
}
|
||||
|
||||
// applyRepoOverrides applies repository-specific overrides from global config.
|
||||
@@ -372,9 +272,7 @@ func (cl *ConfigurationLoader) applyRepoOverrides(config *AppConfig, repoRoot st
|
||||
// applyEnvironmentOverrides applies environment variable overrides.
|
||||
func (cl *ConfigurationLoader) applyEnvironmentOverrides(config *AppConfig) {
|
||||
// Check environment variables directly with higher priority
|
||||
if token := os.Getenv(EnvGitHubToken); token != "" {
|
||||
config.GitHubToken = token
|
||||
} else if token := os.Getenv(EnvGitHubTokenStandard); token != "" {
|
||||
if token := loadGitHubTokenFromEnv(); token != "" {
|
||||
config.GitHubToken = token
|
||||
}
|
||||
}
|
||||
@@ -384,29 +282,6 @@ func (cl *ConfigurationLoader) mergeConfigs(dst *AppConfig, src *AppConfig, allo
|
||||
MergeConfigs(dst, src, allowTokens)
|
||||
}
|
||||
|
||||
// setViperDefaults sets default values in viper.
|
||||
func (cl *ConfigurationLoader) setViperDefaults(v *viper.Viper) {
|
||||
defaults := DefaultAppConfig()
|
||||
v.SetDefault("organization", defaults.Organization)
|
||||
v.SetDefault("repository", defaults.Repository)
|
||||
v.SetDefault("version", defaults.Version)
|
||||
v.SetDefault("theme", defaults.Theme)
|
||||
v.SetDefault("output_format", defaults.OutputFormat)
|
||||
v.SetDefault("output_dir", defaults.OutputDir)
|
||||
v.SetDefault("template", defaults.Template)
|
||||
v.SetDefault("header", defaults.Header)
|
||||
v.SetDefault("footer", defaults.Footer)
|
||||
v.SetDefault("schema", defaults.Schema)
|
||||
v.SetDefault("analyze_dependencies", defaults.AnalyzeDependencies)
|
||||
v.SetDefault("show_security_info", defaults.ShowSecurityInfo)
|
||||
v.SetDefault("verbose", defaults.Verbose)
|
||||
v.SetDefault("quiet", defaults.Quiet)
|
||||
v.SetDefault("defaults.name", defaults.Defaults.Name)
|
||||
v.SetDefault("defaults.description", defaults.Defaults.Description)
|
||||
v.SetDefault("defaults.branding.icon", defaults.Defaults.Branding.Icon)
|
||||
v.SetDefault("defaults.branding.color", defaults.Defaults.Branding.Color)
|
||||
}
|
||||
|
||||
// validateTheme validates that a theme exists and is supported.
|
||||
func (cl *ConfigurationLoader) validateTheme(theme string) error {
|
||||
if theme == "" {
|
||||
@@ -414,8 +289,7 @@ func (cl *ConfigurationLoader) validateTheme(theme string) error {
|
||||
}
|
||||
|
||||
// Check if it's a built-in theme
|
||||
supportedThemes := []string{"default", "github", "gitlab", "minimal", "professional"}
|
||||
if containsString(supportedThemes, theme) {
|
||||
if containsString(appconstants.GetSupportedThemes(), theme) {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -426,27 +300,5 @@ func (cl *ConfigurationLoader) validateTheme(theme string) error {
|
||||
}
|
||||
|
||||
return fmt.Errorf("unsupported theme '%s', must be one of: %s",
|
||||
theme, strings.Join(supportedThemes, ", "))
|
||||
}
|
||||
|
||||
// String returns a string representation of a ConfigurationSource.
|
||||
func (s ConfigurationSource) String() string {
|
||||
switch s {
|
||||
case SourceDefaults:
|
||||
return "defaults"
|
||||
case SourceGlobal:
|
||||
return "global"
|
||||
case SourceRepoOverride:
|
||||
return "repo-override"
|
||||
case SourceRepoConfig:
|
||||
return "repo-config"
|
||||
case SourceActionConfig:
|
||||
return "action-config"
|
||||
case SourceEnvironment:
|
||||
return "environment"
|
||||
case SourceCLIFlags:
|
||||
return "cli-flags"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
theme, strings.Join(appconstants.GetSupportedThemes(), ", "))
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
@@ -21,9 +22,9 @@ func TestNewConfigurationLoader(t *testing.T) {
|
||||
}
|
||||
|
||||
// Check default sources are enabled
|
||||
expectedSources := []ConfigurationSource{
|
||||
SourceDefaults, SourceGlobal, SourceRepoOverride,
|
||||
SourceRepoConfig, SourceActionConfig, SourceEnvironment,
|
||||
expectedSources := []appconstants.ConfigurationSource{
|
||||
appconstants.SourceDefaults, appconstants.SourceGlobal, appconstants.SourceRepoOverride,
|
||||
appconstants.SourceRepoConfig, appconstants.SourceActionConfig, appconstants.SourceEnvironment,
|
||||
}
|
||||
|
||||
for _, source := range expectedSources {
|
||||
@@ -33,7 +34,7 @@ func TestNewConfigurationLoader(t *testing.T) {
|
||||
}
|
||||
|
||||
// CLI flags should be disabled by default
|
||||
if loader.sources[SourceCLIFlags] {
|
||||
if loader.sources[appconstants.SourceCLIFlags] {
|
||||
t.Error("expected CLI flags source to be disabled by default")
|
||||
}
|
||||
}
|
||||
@@ -43,34 +44,41 @@ func TestNewConfigurationLoaderWithOptions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
opts ConfigurationOptions
|
||||
expected []ConfigurationSource
|
||||
expected []appconstants.ConfigurationSource
|
||||
}{
|
||||
{
|
||||
name: "default options",
|
||||
opts: ConfigurationOptions{},
|
||||
expected: []ConfigurationSource{
|
||||
SourceDefaults, SourceGlobal, SourceRepoOverride,
|
||||
SourceRepoConfig, SourceActionConfig, SourceEnvironment,
|
||||
expected: []appconstants.ConfigurationSource{
|
||||
appconstants.SourceDefaults, appconstants.SourceGlobal, appconstants.SourceRepoOverride,
|
||||
appconstants.SourceRepoConfig, appconstants.SourceActionConfig, appconstants.SourceEnvironment,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "custom enabled sources",
|
||||
opts: ConfigurationOptions{
|
||||
EnabledSources: []ConfigurationSource{SourceDefaults, SourceGlobal},
|
||||
EnabledSources: []appconstants.ConfigurationSource{
|
||||
appconstants.SourceDefaults,
|
||||
appconstants.SourceGlobal,
|
||||
},
|
||||
expected: []ConfigurationSource{SourceDefaults, SourceGlobal},
|
||||
},
|
||||
expected: []appconstants.ConfigurationSource{appconstants.SourceDefaults, appconstants.SourceGlobal},
|
||||
},
|
||||
{
|
||||
name: "all sources enabled",
|
||||
opts: ConfigurationOptions{
|
||||
EnabledSources: []ConfigurationSource{
|
||||
SourceDefaults, SourceGlobal, SourceRepoOverride,
|
||||
SourceRepoConfig, SourceActionConfig, SourceEnvironment, SourceCLIFlags,
|
||||
EnabledSources: []appconstants.ConfigurationSource{
|
||||
appconstants.SourceDefaults, appconstants.SourceGlobal,
|
||||
appconstants.SourceRepoOverride, appconstants.SourceRepoConfig,
|
||||
appconstants.SourceActionConfig, appconstants.SourceEnvironment,
|
||||
appconstants.SourceCLIFlags,
|
||||
},
|
||||
},
|
||||
expected: []ConfigurationSource{
|
||||
SourceDefaults, SourceGlobal, SourceRepoOverride,
|
||||
SourceRepoConfig, SourceActionConfig, SourceEnvironment, SourceCLIFlags,
|
||||
expected: []appconstants.ConfigurationSource{
|
||||
appconstants.SourceDefaults, appconstants.SourceGlobal,
|
||||
appconstants.SourceRepoOverride, appconstants.SourceRepoConfig,
|
||||
appconstants.SourceActionConfig, appconstants.SourceEnvironment,
|
||||
appconstants.SourceCLIFlags,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -87,9 +95,11 @@ func TestNewConfigurationLoaderWithOptions(t *testing.T) {
|
||||
}
|
||||
|
||||
// Check that non-expected sources are disabled
|
||||
allSources := []ConfigurationSource{
|
||||
SourceDefaults, SourceGlobal, SourceRepoOverride,
|
||||
SourceRepoConfig, SourceActionConfig, SourceEnvironment, SourceCLIFlags,
|
||||
allSources := []appconstants.ConfigurationSource{
|
||||
appconstants.SourceDefaults, appconstants.SourceGlobal,
|
||||
appconstants.SourceRepoOverride, appconstants.SourceRepoConfig,
|
||||
appconstants.SourceActionConfig, appconstants.SourceEnvironment,
|
||||
appconstants.SourceCLIFlags,
|
||||
}
|
||||
|
||||
for _, source := range allSources {
|
||||
@@ -256,7 +266,10 @@ verbose: true
|
||||
if tt.name == "selective source loading" {
|
||||
// Create loader with only defaults and global sources
|
||||
loader = NewConfigurationLoaderWithOptions(ConfigurationOptions{
|
||||
EnabledSources: []ConfigurationSource{SourceDefaults, SourceGlobal},
|
||||
EnabledSources: []appconstants.ConfigurationSource{
|
||||
appconstants.SourceDefaults,
|
||||
appconstants.SourceGlobal,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
loader = NewConfigurationLoader()
|
||||
@@ -462,15 +475,15 @@ func TestConfigurationLoader_SourceManagement(t *testing.T) {
|
||||
}
|
||||
|
||||
// Test disabling a source
|
||||
loader.DisableSource(SourceGlobal)
|
||||
if loader.sources[SourceGlobal] {
|
||||
t.Error("expected SourceGlobal to be disabled")
|
||||
loader.DisableSource(appconstants.SourceGlobal)
|
||||
if loader.sources[appconstants.SourceGlobal] {
|
||||
t.Error("expected appconstants.SourceGlobal to be disabled")
|
||||
}
|
||||
|
||||
// Test enabling a source
|
||||
loader.EnableSource(SourceCLIFlags)
|
||||
if !loader.sources[SourceCLIFlags] {
|
||||
t.Error("expected SourceCLIFlags to be enabled")
|
||||
loader.EnableSource(appconstants.SourceCLIFlags)
|
||||
if !loader.sources[appconstants.SourceCLIFlags] {
|
||||
t.Error("expected appconstants.SourceCLIFlags to be enabled")
|
||||
}
|
||||
|
||||
// Test updated sources list
|
||||
@@ -484,17 +497,17 @@ func TestConfigurationLoader_SourceManagement(t *testing.T) {
|
||||
func TestConfigurationSource_String(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
source ConfigurationSource
|
||||
source appconstants.ConfigurationSource
|
||||
expected string
|
||||
}{
|
||||
{SourceDefaults, "defaults"},
|
||||
{SourceGlobal, "global"},
|
||||
{SourceRepoOverride, "repo-override"},
|
||||
{SourceRepoConfig, "repo-config"},
|
||||
{SourceActionConfig, "action-config"},
|
||||
{SourceEnvironment, "environment"},
|
||||
{SourceCLIFlags, "cli-flags"},
|
||||
{ConfigurationSource(999), "unknown"},
|
||||
{appconstants.SourceDefaults, "defaults"},
|
||||
{appconstants.SourceGlobal, "global"},
|
||||
{appconstants.SourceRepoOverride, "repo-override"},
|
||||
{appconstants.SourceRepoConfig, "repo-config"},
|
||||
{appconstants.SourceActionConfig, "action-config"},
|
||||
{appconstants.SourceEnvironment, "environment"},
|
||||
{appconstants.SourceCLIFlags, "cli-flags"},
|
||||
{appconstants.ConfigurationSource(999), "unknown"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
// Package internal provides common constants used throughout the application.
|
||||
package internal
|
||||
|
||||
// File extension constants.
|
||||
const (
|
||||
// ActionFileExtYML is the primary action file extension.
|
||||
ActionFileExtYML = ".yml"
|
||||
// ActionFileExtYAML is the alternative action file extension.
|
||||
ActionFileExtYAML = ".yaml"
|
||||
|
||||
// ActionFileNameYML is the primary action file name.
|
||||
ActionFileNameYML = "action.yml"
|
||||
// ActionFileNameYAML is the alternative action file name.
|
||||
ActionFileNameYAML = "action.yaml"
|
||||
)
|
||||
|
||||
// File permission constants.
|
||||
const (
|
||||
// FilePermDefault is the default file permission for created files.
|
||||
FilePermDefault = 0600
|
||||
// FilePermTest is the file permission used in tests.
|
||||
FilePermTest = 0600
|
||||
)
|
||||
|
||||
// Configuration file constants.
|
||||
const (
|
||||
// ConfigFileName is the primary configuration file name.
|
||||
ConfigFileName = "config"
|
||||
// ConfigFileExtYAML is the configuration file extension.
|
||||
ConfigFileExtYAML = ".yaml"
|
||||
// ConfigFileNameFull is the full configuration file name.
|
||||
ConfigFileNameFull = ConfigFileName + ConfigFileExtYAML
|
||||
)
|
||||
|
||||
// Context key constants for maps and data structures.
|
||||
const (
|
||||
// ContextKeyError is used as a key for error information in context maps.
|
||||
ContextKeyError = "error"
|
||||
// ContextKeyTheme is used as a key for theme information.
|
||||
ContextKeyTheme = "theme"
|
||||
// ContextKeyConfig is used as a key for configuration information.
|
||||
ContextKeyConfig = "config"
|
||||
)
|
||||
|
||||
// Common string identifiers.
|
||||
const (
|
||||
// ThemeGitHub is the GitHub theme identifier.
|
||||
ThemeGitHub = "github"
|
||||
// ThemeGitLab is the GitLab theme identifier.
|
||||
ThemeGitLab = "gitlab"
|
||||
// ThemeMinimal is the minimal theme identifier.
|
||||
ThemeMinimal = "minimal"
|
||||
// ThemeProfessional is the professional theme identifier.
|
||||
ThemeProfessional = "professional"
|
||||
// ThemeDefault is the default theme identifier.
|
||||
ThemeDefault = "default"
|
||||
)
|
||||
|
||||
// Environment variable names.
|
||||
const (
|
||||
// EnvGitHubToken is the tool-specific GitHub token environment variable.
|
||||
EnvGitHubToken = "GH_README_GITHUB_TOKEN" // #nosec G101 -- environment variable name, not a credential
|
||||
// EnvGitHubTokenStandard is the standard GitHub token environment variable.
|
||||
EnvGitHubTokenStandard = "GITHUB_TOKEN" // #nosec G101 -- environment variable name, not a credential
|
||||
)
|
||||
|
||||
// Configuration keys and paths.
|
||||
const (
|
||||
// ConfigKeyGitHubToken is the configuration key for GitHub token.
|
||||
ConfigKeyGitHubToken = "github_token"
|
||||
// ConfigKeyTheme is the configuration key for theme.
|
||||
ConfigKeyTheme = "theme"
|
||||
// ConfigKeyOutputFormat is the configuration key for output format.
|
||||
ConfigKeyOutputFormat = "output_format"
|
||||
// ConfigKeyOutputDir is the configuration key for output directory.
|
||||
ConfigKeyOutputDir = "output_dir"
|
||||
// ConfigKeyVerbose is the configuration key for verbose mode.
|
||||
ConfigKeyVerbose = "verbose"
|
||||
// ConfigKeyQuiet is the configuration key for quiet mode.
|
||||
ConfigKeyQuiet = "quiet"
|
||||
// ConfigKeyAnalyzeDependencies is the configuration key for dependency analysis.
|
||||
ConfigKeyAnalyzeDependencies = "analyze_dependencies"
|
||||
// ConfigKeyShowSecurityInfo is the configuration key for security info display.
|
||||
ConfigKeyShowSecurityInfo = "show_security_info"
|
||||
)
|
||||
|
||||
// Template path constants.
|
||||
const (
|
||||
// TemplatePathDefault is the default template path.
|
||||
TemplatePathDefault = "templates/readme.tmpl"
|
||||
// TemplatePathGitHub is the GitHub theme template path.
|
||||
TemplatePathGitHub = "templates/themes/github/readme.tmpl"
|
||||
// TemplatePathGitLab is the GitLab theme template path.
|
||||
TemplatePathGitLab = "templates/themes/gitlab/readme.tmpl"
|
||||
// TemplatePathMinimal is the minimal theme template path.
|
||||
TemplatePathMinimal = "templates/themes/minimal/readme.tmpl"
|
||||
// TemplatePathProfessional is the professional theme template path.
|
||||
TemplatePathProfessional = "templates/themes/professional/readme.tmpl"
|
||||
)
|
||||
|
||||
// Config file search patterns.
|
||||
const (
|
||||
// ConfigFilePatternHidden is the primary hidden config file pattern.
|
||||
ConfigFilePatternHidden = ".ghreadme.yaml"
|
||||
// ConfigFilePatternConfig is the secondary config directory pattern.
|
||||
ConfigFilePatternConfig = ".config/ghreadme.yaml"
|
||||
// ConfigFilePatternGitHub is the GitHub ecosystem config pattern.
|
||||
ConfigFilePatternGitHub = ".github/ghreadme.yaml"
|
||||
)
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/google/go-github/v74/github"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/git"
|
||||
)
|
||||
|
||||
@@ -27,49 +28,6 @@ const (
|
||||
BranchName VersionType = "branch"
|
||||
// LocalPath represents a local file path reference.
|
||||
LocalPath VersionType = "local"
|
||||
|
||||
// Common string constants.
|
||||
compositeUsing = "composite"
|
||||
updateTypeNone = "none"
|
||||
updateTypeMajor = "major"
|
||||
updateTypePatch = "patch"
|
||||
updateTypeMinor = "minor"
|
||||
defaultBranch = "main"
|
||||
|
||||
// Timeout constants.
|
||||
apiCallTimeout = 10 * time.Second
|
||||
cacheDefaultTTL = 1 * time.Hour
|
||||
|
||||
// File permission constants.
|
||||
backupFilePerms = 0600
|
||||
updatedFilePerms = 0600
|
||||
|
||||
// GitHub URL patterns.
|
||||
githubBaseURL = "https://github.com"
|
||||
marketplaceBaseURL = "https://github.com/marketplace/actions/"
|
||||
|
||||
// Version parsing constants.
|
||||
fullSHALength = 40
|
||||
minSHALength = 7
|
||||
versionPartsCount = 3
|
||||
|
||||
// File path patterns.
|
||||
dockerPrefix = "docker://"
|
||||
localPathPrefix = "./"
|
||||
localPathUpPrefix = "../"
|
||||
|
||||
// File extensions.
|
||||
backupExtension = ".backup"
|
||||
|
||||
// Cache key prefixes.
|
||||
cacheKeyLatest = "latest:"
|
||||
cacheKeyRepo = "repo:"
|
||||
|
||||
// YAML structure constants.
|
||||
usesFieldPrefix = "uses: "
|
||||
|
||||
// Special line estimation for script URLs.
|
||||
scriptLineEstimate = 10
|
||||
)
|
||||
|
||||
// Dependency represents a GitHub Action dependency with detailed information.
|
||||
@@ -188,13 +146,16 @@ func (a *Analyzer) CheckOutdated(deps []Dependency) ([]OutdatedDependency, error
|
||||
}
|
||||
|
||||
updateType := a.compareVersions(currentVersion, latestVersion)
|
||||
if updateType != updateTypeNone {
|
||||
if updateType != appconstants.UpdateTypeNone {
|
||||
outdated = append(outdated, OutdatedDependency{
|
||||
Current: dep,
|
||||
LatestVersion: latestVersion,
|
||||
LatestSHA: latestSHA,
|
||||
UpdateType: updateType,
|
||||
IsSecurityUpdate: updateType == updateTypeMajor, // Assume major updates might be security
|
||||
// Don't assume major version bumps are security updates
|
||||
// This should only be set if confirmed by security advisory data
|
||||
// Future enhancement: integrate with GitHub Security Advisories API
|
||||
IsSecurityUpdate: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -252,7 +213,7 @@ func (a *Analyzer) validateAndCheckComposite(
|
||||
action *ActionWithComposite,
|
||||
progressCallback func(current, total int, message string),
|
||||
) ([]Dependency, bool, error) {
|
||||
if action.Runs.Using != compositeUsing {
|
||||
if action.Runs.Using != appconstants.ActionTypeComposite {
|
||||
if err := a.validateActionType(action.Runs.Using); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
@@ -336,13 +297,13 @@ func (a *Analyzer) analyzeActionDependency(step CompositeStep, _ int) (*Dependen
|
||||
|
||||
// Build dependency
|
||||
dep := &Dependency{
|
||||
Name: fmt.Sprintf("%s/%s", owner, repo),
|
||||
Name: fmt.Sprintf(appconstants.URLPatternGitHubRepo, owner, repo),
|
||||
Uses: step.Uses,
|
||||
Version: version,
|
||||
VersionType: versionType,
|
||||
IsPinned: versionType == CommitSHA || (versionType == SemanticVersion && a.isVersionPinned(version)),
|
||||
Author: owner,
|
||||
SourceURL: fmt.Sprintf("%s/%s/%s", githubBaseURL, owner, repo),
|
||||
SourceURL: fmt.Sprintf("%s/%s/%s", appconstants.GitHubBaseURL, owner, repo),
|
||||
IsLocalAction: isLocal,
|
||||
IsShellScript: false,
|
||||
WithParams: a.convertWithParams(step.With),
|
||||
@@ -350,7 +311,7 @@ func (a *Analyzer) analyzeActionDependency(step CompositeStep, _ int) (*Dependen
|
||||
|
||||
// Add marketplace URL for public actions
|
||||
if !isLocal {
|
||||
dep.MarketplaceURL = marketplaceBaseURL + repo
|
||||
dep.MarketplaceURL = fmt.Sprintf("%s%s/%s", appconstants.MarketplaceBaseURL, owner, repo)
|
||||
}
|
||||
|
||||
// Fetch additional metadata from GitHub API if available
|
||||
@@ -375,11 +336,11 @@ func (a *Analyzer) analyzeShellScript(step CompositeStep, stepNumber int) *Depen
|
||||
// This would ideally link to the specific line in the action.yml file
|
||||
scriptURL = fmt.Sprintf(
|
||||
"%s/%s/%s/blob/%s/action.yml#L%d",
|
||||
githubBaseURL,
|
||||
appconstants.GitHubBaseURL,
|
||||
a.RepoInfo.Organization,
|
||||
a.RepoInfo.Repository,
|
||||
a.RepoInfo.DefaultBranch,
|
||||
stepNumber*scriptLineEstimate,
|
||||
stepNumber*appconstants.ScriptLineEstimate,
|
||||
) // Rough estimate
|
||||
}
|
||||
|
||||
@@ -408,11 +369,12 @@ func (a *Analyzer) parseUsesStatement(uses string) (owner, repo, version string,
|
||||
// - ./local-action
|
||||
// - docker://alpine:3.14
|
||||
|
||||
if strings.HasPrefix(uses, localPathPrefix) || strings.HasPrefix(uses, localPathUpPrefix) {
|
||||
if strings.HasPrefix(uses, appconstants.LocalPathPrefix) ||
|
||||
strings.HasPrefix(uses, appconstants.LocalPathUpPrefix) {
|
||||
return "", "", uses, LocalPath
|
||||
}
|
||||
|
||||
if strings.HasPrefix(uses, dockerPrefix) {
|
||||
if strings.HasPrefix(uses, appconstants.DockerPrefix) {
|
||||
return "", "", uses, LocalPath
|
||||
}
|
||||
|
||||
@@ -443,9 +405,9 @@ func (a *Analyzer) parseUsesStatement(uses string) (owner, repo, version string,
|
||||
// isCommitSHA checks if a version string is a commit SHA.
|
||||
func (a *Analyzer) isCommitSHA(version string) bool {
|
||||
// Check if it's a 40-character hex string (full SHA) or 7+ character hex (short SHA)
|
||||
re := regexp.MustCompile(`^[a-f0-9]{7,40}$`)
|
||||
re := regexp.MustCompile(appconstants.RegexGitSHA)
|
||||
|
||||
return len(version) >= minSHALength && re.MatchString(version)
|
||||
return len(version) >= appconstants.MinSHALength && re.MatchString(version)
|
||||
}
|
||||
|
||||
// isSemanticVersion checks if a version string follows semantic versioning.
|
||||
@@ -460,7 +422,7 @@ func (a *Analyzer) isSemanticVersion(version string) bool {
|
||||
func (a *Analyzer) isVersionPinned(version string) bool {
|
||||
// Consider it pinned if it specifies patch version (v1.2.3) or is a commit SHA
|
||||
// Also check for full commit SHAs (40 chars)
|
||||
if len(version) == fullSHALength {
|
||||
if len(version) == appconstants.FullSHALength {
|
||||
return true
|
||||
}
|
||||
re := regexp.MustCompile(`^v?\d+\.\d+\.\d+`)
|
||||
@@ -488,11 +450,11 @@ func (a *Analyzer) getLatestVersion(owner, repo string) (version, sha string, er
|
||||
return "", "", errors.New("GitHub client not available")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), apiCallTimeout)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), appconstants.APICallTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Check cache first
|
||||
cacheKey := cacheKeyLatest + fmt.Sprintf("%s/%s", owner, repo)
|
||||
cacheKey := appconstants.CacheKeyLatest + fmt.Sprintf(appconstants.URLPatternGitHubRepo, owner, repo)
|
||||
if version, sha, found := a.getCachedVersion(cacheKey); found {
|
||||
return version, sha, nil
|
||||
}
|
||||
@@ -578,7 +540,7 @@ func (a *Analyzer) cacheVersion(cacheKey, version, sha string) {
|
||||
}
|
||||
|
||||
versionInfo := map[string]string{"version": version, "sha": sha}
|
||||
_ = a.Cache.SetWithTTL(cacheKey, versionInfo, cacheDefaultTTL)
|
||||
_ = a.Cache.SetWithTTL(cacheKey, versionInfo, appconstants.CacheDefaultTTL)
|
||||
}
|
||||
|
||||
// compareVersions compares two version strings and returns the update type.
|
||||
@@ -587,12 +549,12 @@ func (a *Analyzer) compareVersions(current, latest string) string {
|
||||
latestClean := strings.TrimPrefix(latest, "v")
|
||||
|
||||
if currentClean == latestClean {
|
||||
return updateTypeNone
|
||||
return appconstants.UpdateTypeNone
|
||||
}
|
||||
|
||||
// Special case: floating major version (e.g., "4" -> "4.1.1") should be patch
|
||||
if !strings.Contains(currentClean, ".") && strings.HasPrefix(latestClean, currentClean+".") {
|
||||
return updateTypePatch
|
||||
return appconstants.UpdateTypePatch
|
||||
}
|
||||
|
||||
currentParts := a.parseVersionParts(currentClean)
|
||||
@@ -605,7 +567,7 @@ func (a *Analyzer) compareVersions(current, latest string) string {
|
||||
func (a *Analyzer) parseVersionParts(version string) []string {
|
||||
parts := strings.Split(version, ".")
|
||||
// For floating versions like "v4", treat as "v4.0.0" for comparison
|
||||
for len(parts) < versionPartsCount {
|
||||
for len(parts) < appconstants.VersionPartsCount {
|
||||
parts = append(parts, "0")
|
||||
}
|
||||
|
||||
@@ -615,16 +577,16 @@ func (a *Analyzer) parseVersionParts(version string) []string {
|
||||
// determineUpdateType compares version parts and returns update type.
|
||||
func (a *Analyzer) determineUpdateType(currentParts, latestParts []string) string {
|
||||
if currentParts[0] != latestParts[0] {
|
||||
return updateTypeMajor
|
||||
return appconstants.UpdateTypeMajor
|
||||
}
|
||||
if currentParts[1] != latestParts[1] {
|
||||
return updateTypeMinor
|
||||
return appconstants.UpdateTypeMinor
|
||||
}
|
||||
if currentParts[2] != latestParts[2] {
|
||||
return updateTypePatch
|
||||
return appconstants.UpdateTypePatch
|
||||
}
|
||||
|
||||
return updateTypeNone
|
||||
return appconstants.UpdateTypeNone
|
||||
}
|
||||
|
||||
// updateActionFile applies updates to a single action file.
|
||||
@@ -636,8 +598,8 @@ func (a *Analyzer) updateActionFile(filePath string, updates []PinnedUpdate) err
|
||||
}
|
||||
|
||||
// Create backup
|
||||
backupPath := filePath + backupExtension
|
||||
if err := os.WriteFile(backupPath, content, backupFilePerms); err != nil { // #nosec G306 -- backup file permissions
|
||||
backupPath := filePath + appconstants.BackupExtension
|
||||
if err := os.WriteFile(backupPath, content, appconstants.FilePermDefault); err != nil { // #nosec G306
|
||||
return fmt.Errorf("failed to create backup: %w", err)
|
||||
}
|
||||
|
||||
@@ -649,7 +611,7 @@ func (a *Analyzer) updateActionFile(filePath string, updates []PinnedUpdate) err
|
||||
if strings.Contains(line, update.OldUses) {
|
||||
// Replace the uses statement while preserving indentation
|
||||
indent := strings.Repeat(" ", len(line)-len(strings.TrimLeft(line, " ")))
|
||||
lines[i] = indent + usesFieldPrefix + update.NewUses
|
||||
lines[i] = indent + appconstants.UsesFieldPrefix + update.NewUses
|
||||
update.LineNumber = i + 1 // Store line number for reference
|
||||
|
||||
break
|
||||
@@ -659,8 +621,7 @@ func (a *Analyzer) updateActionFile(filePath string, updates []PinnedUpdate) err
|
||||
|
||||
// Write updated content
|
||||
updatedContent := strings.Join(lines, "\n")
|
||||
if err := os.WriteFile(filePath, []byte(updatedContent), updatedFilePerms); err != nil {
|
||||
// #nosec G306 -- updated file permissions
|
||||
if err := os.WriteFile(filePath, []byte(updatedContent), appconstants.FilePermDefault); err != nil { // #nosec G306
|
||||
return fmt.Errorf("failed to write updated file: %w", err)
|
||||
}
|
||||
|
||||
@@ -689,11 +650,11 @@ func (a *Analyzer) validateActionFile(filePath string) error {
|
||||
|
||||
// enrichWithGitHubData fetches additional information from GitHub API.
|
||||
func (a *Analyzer) enrichWithGitHubData(dep *Dependency, owner, repo string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), apiCallTimeout)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), appconstants.APICallTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Check cache first
|
||||
cacheKey := cacheKeyRepo + fmt.Sprintf("%s/%s", owner, repo)
|
||||
cacheKey := appconstants.CacheKeyRepo + fmt.Sprintf("%s/%s", owner, repo)
|
||||
if a.Cache != nil {
|
||||
if cached, exists := a.Cache.Get(cacheKey); exists {
|
||||
if repository, ok := cached.(*github.Repository); ok {
|
||||
@@ -712,7 +673,7 @@ func (a *Analyzer) enrichWithGitHubData(dep *Dependency, owner, repo string) err
|
||||
|
||||
// Cache the result with 1 hour TTL
|
||||
if a.Cache != nil {
|
||||
_ = a.Cache.SetWithTTL(cacheKey, repository, cacheDefaultTTL) // Ignore cache errors
|
||||
_ = a.Cache.SetWithTTL(cacheKey, repository, appconstants.CacheDefaultTTL) // Ignore cache errors
|
||||
}
|
||||
|
||||
// Enrich dependency with API data
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/google/go-github/v74/github"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/cache"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/git"
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
@@ -28,14 +29,14 @@ func TestAnalyzer_AnalyzeActionFile(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "simple action - no dependencies",
|
||||
actionYML: testutil.MustReadFixture("actions/javascript/simple.yml"),
|
||||
actionYML: testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple),
|
||||
expectError: false,
|
||||
expectDeps: false,
|
||||
expectedLen: 0,
|
||||
},
|
||||
{
|
||||
name: "composite action with dependencies",
|
||||
actionYML: testutil.MustReadFixture("actions/composite/with-dependencies.yml"),
|
||||
actionYML: testutil.MustReadFixture(appconstants.TestFixtureCompositeWithDeps),
|
||||
expectError: false,
|
||||
expectDeps: true,
|
||||
expectedLen: 5, // 3 action dependencies + 2 shell script dependencies
|
||||
@@ -43,14 +44,14 @@ func TestAnalyzer_AnalyzeActionFile(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "docker action - no step dependencies",
|
||||
actionYML: testutil.MustReadFixture("actions/docker/basic.yml"),
|
||||
actionYML: testutil.MustReadFixture(appconstants.TestFixtureDockerBasic),
|
||||
expectError: false,
|
||||
expectDeps: false,
|
||||
expectedLen: 0,
|
||||
},
|
||||
{
|
||||
name: "invalid action file",
|
||||
actionYML: testutil.MustReadFixture("actions/invalid/invalid-using.yml"),
|
||||
actionYML: testutil.MustReadFixture(appconstants.TestFixtureInvalidInvalidUsing),
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
@@ -70,7 +71,7 @@ func TestAnalyzer_AnalyzeActionFile(t *testing.T) {
|
||||
tmpDir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
actionPath := filepath.Join(tmpDir, "action.yml")
|
||||
actionPath := filepath.Join(tmpDir, appconstants.TestPathActionYML)
|
||||
testutil.WriteTestFile(t, actionPath, tt.actionYML)
|
||||
|
||||
// Create analyzer with mock GitHub client
|
||||
@@ -429,9 +430,9 @@ func TestAnalyzer_GeneratePinnedUpdate(t *testing.T) {
|
||||
defer cleanup()
|
||||
|
||||
// Create a test action file with composite steps
|
||||
actionContent := testutil.MustReadFixture("test-composite-action.yml")
|
||||
actionContent := testutil.MustReadFixture(appconstants.TestFixtureTestCompositeAction)
|
||||
|
||||
actionPath := filepath.Join(tmpDir, "action.yml")
|
||||
actionPath := filepath.Join(tmpDir, appconstants.TestPathActionYML)
|
||||
testutil.WriteTestFile(t, actionPath, actionContent)
|
||||
|
||||
// Create analyzer
|
||||
@@ -550,8 +551,8 @@ func TestAnalyzer_WithoutGitHubClient(t *testing.T) {
|
||||
tmpDir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
actionPath := filepath.Join(tmpDir, "action.yml")
|
||||
testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture("actions/composite/basic.yml"))
|
||||
actionPath := filepath.Join(tmpDir, appconstants.TestPathActionYML)
|
||||
testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture(appconstants.TestFixtureCompositeBasic))
|
||||
|
||||
deps, err := analyzer.AnalyzeActionFile(actionPath)
|
||||
|
||||
@@ -586,7 +587,7 @@ func TestNewAnalyzer(t *testing.T) {
|
||||
githubClient := testutil.MockGitHubClient(mockResponses)
|
||||
cacheInstance, err := cache.NewCache(cache.DefaultConfig())
|
||||
testutil.AssertNoError(t, err)
|
||||
defer func() { _ = cacheInstance.Close() }()
|
||||
defer testutil.CleanupCache(t, cacheInstance)()
|
||||
|
||||
repoInfo := git.RepoInfo{
|
||||
Organization: "test-owner",
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"os"
|
||||
|
||||
"github.com/goccy/go-yaml"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
)
|
||||
|
||||
// parseCompositeActionFromFile reads and parses a composite action file.
|
||||
@@ -33,7 +35,7 @@ func (a *Analyzer) parseCompositeAction(actionPath string) (*ActionWithComposite
|
||||
}
|
||||
|
||||
// If this is not a composite action, return empty steps
|
||||
if action.Runs.Using != compositeUsing {
|
||||
if action.Runs.Using != appconstants.ActionTypeComposite {
|
||||
action.Runs.Steps = []CompositeStep{}
|
||||
}
|
||||
|
||||
@@ -47,5 +49,5 @@ func IsCompositeAction(actionPath string) (bool, error) {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return action.Runs.Using == compositeUsing, nil
|
||||
return action.Runs.Using == appconstants.ActionTypeComposite, nil
|
||||
}
|
||||
|
||||
@@ -2,27 +2,12 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/internal/errors"
|
||||
)
|
||||
|
||||
// Error detection constants for automatic error code determination.
|
||||
const (
|
||||
// File system error patterns.
|
||||
errorPatternFileNotFound = "no such file or directory"
|
||||
errorPatternPermission = "permission denied"
|
||||
|
||||
// Content format error patterns.
|
||||
errorPatternYAML = "yaml"
|
||||
|
||||
// Service-specific error patterns.
|
||||
errorPatternGitHub = "github"
|
||||
errorPatternConfig = "config"
|
||||
|
||||
// Exit code constants.
|
||||
exitCodeError = 1
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/apperrors"
|
||||
)
|
||||
|
||||
// ErrorHandler provides centralized error handling and exit management.
|
||||
@@ -38,17 +23,17 @@ func NewErrorHandler(output *ColoredOutput) *ErrorHandler {
|
||||
}
|
||||
|
||||
// HandleError handles contextual errors and exits with appropriate code.
|
||||
func (eh *ErrorHandler) HandleError(err *errors.ContextualError) {
|
||||
func (eh *ErrorHandler) HandleError(err *apperrors.ContextualError) {
|
||||
eh.output.ErrorWithSuggestions(err)
|
||||
os.Exit(exitCodeError)
|
||||
os.Exit(appconstants.ExitCodeError)
|
||||
}
|
||||
|
||||
// HandleFatalError handles fatal errors with contextual information.
|
||||
func (eh *ErrorHandler) HandleFatalError(code errors.ErrorCode, message string, context map[string]string) {
|
||||
suggestions := errors.GetSuggestions(code, context)
|
||||
helpURL := errors.GetHelpURL(code)
|
||||
func (eh *ErrorHandler) HandleFatalError(code appconstants.ErrorCode, message string, context map[string]string) {
|
||||
suggestions := apperrors.GetSuggestions(code, context)
|
||||
helpURL := apperrors.GetHelpURL(code)
|
||||
|
||||
contextualErr := errors.New(code, message).
|
||||
contextualErr := apperrors.New(code, message).
|
||||
WithSuggestions(suggestions...).
|
||||
WithHelpURL(helpURL)
|
||||
|
||||
@@ -61,12 +46,12 @@ func (eh *ErrorHandler) HandleFatalError(code errors.ErrorCode, message string,
|
||||
|
||||
// HandleSimpleError handles simple errors with automatic context detection.
|
||||
func (eh *ErrorHandler) HandleSimpleError(message string, err error) {
|
||||
code := errors.ErrCodeUnknown
|
||||
code := appconstants.ErrCodeUnknown
|
||||
context := make(map[string]string)
|
||||
|
||||
// Try to determine appropriate error code based on error content
|
||||
if err != nil {
|
||||
context[ContextKeyError] = err.Error()
|
||||
context[appconstants.ContextKeyError] = err.Error()
|
||||
code = eh.determineErrorCode(err)
|
||||
}
|
||||
|
||||
@@ -74,22 +59,52 @@ func (eh *ErrorHandler) HandleSimpleError(message string, err error) {
|
||||
}
|
||||
|
||||
// determineErrorCode attempts to determine appropriate error code from error content.
|
||||
func (eh *ErrorHandler) determineErrorCode(err error) errors.ErrorCode {
|
||||
errStr := err.Error()
|
||||
func (eh *ErrorHandler) determineErrorCode(err error) appconstants.ErrorCode {
|
||||
// First try typed error checks using errors.Is against sentinel errors
|
||||
if code := eh.checkTypedError(err); code != appconstants.ErrCodeUnknown {
|
||||
return code
|
||||
}
|
||||
|
||||
// Fallback to string checks only if no typed match found
|
||||
return eh.checkStringPatterns(err.Error())
|
||||
}
|
||||
|
||||
// checkTypedError checks for typed errors using errors.Is.
|
||||
func (eh *ErrorHandler) checkTypedError(err error) appconstants.ErrorCode {
|
||||
if errors.Is(err, apperrors.ErrFileNotFound) || errors.Is(err, os.ErrNotExist) {
|
||||
return appconstants.ErrCodeFileNotFound
|
||||
}
|
||||
if errors.Is(err, apperrors.ErrPermissionDenied) || errors.Is(err, os.ErrPermission) {
|
||||
return appconstants.ErrCodePermission
|
||||
}
|
||||
if errors.Is(err, apperrors.ErrInvalidYAML) {
|
||||
return appconstants.ErrCodeInvalidYAML
|
||||
}
|
||||
if errors.Is(err, apperrors.ErrGitHubAPI) {
|
||||
return appconstants.ErrCodeGitHubAPI
|
||||
}
|
||||
if errors.Is(err, apperrors.ErrConfiguration) {
|
||||
return appconstants.ErrCodeConfiguration
|
||||
}
|
||||
|
||||
return appconstants.ErrCodeUnknown
|
||||
}
|
||||
|
||||
// checkStringPatterns checks error message against string patterns.
|
||||
func (eh *ErrorHandler) checkStringPatterns(errStr string) appconstants.ErrorCode {
|
||||
switch {
|
||||
case contains(errStr, errorPatternFileNotFound):
|
||||
return errors.ErrCodeFileNotFound
|
||||
case contains(errStr, errorPatternPermission):
|
||||
return errors.ErrCodePermission
|
||||
case contains(errStr, errorPatternYAML):
|
||||
return errors.ErrCodeInvalidYAML
|
||||
case contains(errStr, errorPatternGitHub):
|
||||
return errors.ErrCodeGitHubAPI
|
||||
case contains(errStr, errorPatternConfig):
|
||||
return errors.ErrCodeConfiguration
|
||||
case contains(errStr, appconstants.ErrorPatternFileNotFound):
|
||||
return appconstants.ErrCodeFileNotFound
|
||||
case contains(errStr, appconstants.ErrorPatternPermission):
|
||||
return appconstants.ErrCodePermission
|
||||
case contains(errStr, appconstants.ErrorPatternYAML):
|
||||
return appconstants.ErrCodeInvalidYAML
|
||||
case contains(errStr, appconstants.ErrorPatternGitHub):
|
||||
return appconstants.ErrCodeGitHubAPI
|
||||
case contains(errStr, appconstants.ErrorPatternConfig):
|
||||
return appconstants.ErrCodeConfiguration
|
||||
default:
|
||||
return errors.ErrCodeUnknown
|
||||
return appconstants.ErrCodeUnknown
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ package internal
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/internal/errors"
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/apperrors"
|
||||
)
|
||||
|
||||
// SimpleLogger demonstrates a component that only needs basic message logging.
|
||||
@@ -50,7 +51,7 @@ func (fem *FocusedErrorManager) HandleValidationError(file string, missingFields
|
||||
}
|
||||
|
||||
fem.manager.ErrorWithContext(
|
||||
errors.ErrCodeValidation,
|
||||
appconstants.ErrCodeValidation,
|
||||
"Validation failed for "+file,
|
||||
context,
|
||||
)
|
||||
@@ -138,7 +139,7 @@ func (vc *ValidationComponent) ValidateAndReport(item string, isValid bool, err
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if contextualErr, ok := err.(*errors.ContextualError); ok {
|
||||
if contextualErr, ok := err.(*apperrors.ContextualError); ok {
|
||||
vc.errorManager.ErrorWithSuggestions(contextualErr)
|
||||
} else {
|
||||
vc.errorManager.Error("Validation failed for %s: %v", item, err)
|
||||
|
||||
@@ -12,20 +12,12 @@ import (
|
||||
"github.com/google/go-github/v74/github"
|
||||
"github.com/schollz/progressbar/v3"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/cache"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/dependencies"
|
||||
errCodes "github.com/ivuorinen/gh-action-readme/internal/errors"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/git"
|
||||
)
|
||||
|
||||
// Output format constants.
|
||||
const (
|
||||
OutputFormatHTML = "html"
|
||||
OutputFormatMD = "md"
|
||||
OutputFormatJSON = "json"
|
||||
OutputFormatASCIIDoc = "asciidoc"
|
||||
)
|
||||
|
||||
// Generator orchestrates the documentation generation process.
|
||||
// It uses focused interfaces to reduce coupling and improve testability.
|
||||
type Generator struct {
|
||||
@@ -174,13 +166,13 @@ func (g *Generator) DiscoverActionFilesWithValidation(dir string, recursive bool
|
||||
actionFiles, err := g.DiscoverActionFiles(dir, recursive)
|
||||
if err != nil {
|
||||
g.Output.ErrorWithContext(
|
||||
errCodes.ErrCodeFileNotFound,
|
||||
appconstants.ErrCodeFileNotFound,
|
||||
"failed to discover action files for "+context,
|
||||
map[string]string{
|
||||
"directory": dir,
|
||||
"recursive": strconv.FormatBool(recursive),
|
||||
"context": context,
|
||||
ContextKeyError: err.Error(),
|
||||
appconstants.ContextKeyError: err.Error(),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -191,7 +183,7 @@ func (g *Generator) DiscoverActionFilesWithValidation(dir string, recursive bool
|
||||
if len(actionFiles) == 0 {
|
||||
contextMsg := "no GitHub Action files found for " + context
|
||||
g.Output.ErrorWithContext(
|
||||
errCodes.ErrCodeNoActionFiles,
|
||||
appconstants.ErrCodeNoActionFiles,
|
||||
contextMsg,
|
||||
map[string]string{
|
||||
"directory": dir,
|
||||
@@ -257,32 +249,57 @@ func (g *Generator) ValidateFiles(paths []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateMarkdown creates a README.md file using the template.
|
||||
func (g *Generator) generateMarkdown(action *ActionYML, outputDir, actionPath string) error {
|
||||
// Use theme-based template if theme is specified, otherwise use explicit template path
|
||||
templatePath := g.Config.Template
|
||||
// resolveTemplatePathForFormat determines the correct template path
|
||||
// based on the configured theme or custom template path.
|
||||
// If a theme is specified, it takes precedence over the template path.
|
||||
func (g *Generator) resolveTemplatePathForFormat() string {
|
||||
if g.Config.Theme != "" {
|
||||
templatePath = resolveThemeTemplate(g.Config.Theme)
|
||||
return resolveThemeTemplate(g.Config.Theme)
|
||||
}
|
||||
|
||||
opts := TemplateOptions{
|
||||
TemplatePath: templatePath,
|
||||
Format: "md",
|
||||
return g.Config.Template
|
||||
}
|
||||
|
||||
// renderTemplateForAction builds template data and renders it using the specified options.
|
||||
// It finds the repository root for git information, builds comprehensive template data,
|
||||
// and renders the template. Returns the rendered content or an error.
|
||||
func (g *Generator) renderTemplateForAction(
|
||||
action *ActionYML,
|
||||
outputDir string,
|
||||
actionPath string,
|
||||
opts TemplateOptions,
|
||||
) (string, error) {
|
||||
// Find repository root for git information
|
||||
repoRoot, _ := git.FindRepositoryRoot(outputDir)
|
||||
|
||||
// Build comprehensive template data
|
||||
templateData := BuildTemplateData(action, g.Config, repoRoot, actionPath)
|
||||
|
||||
// Render template with data
|
||||
content, err := RenderReadme(templateData, opts)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to render template: %w", err)
|
||||
}
|
||||
|
||||
return content, nil
|
||||
}
|
||||
|
||||
// generateMarkdown creates a README.md file using the template.
|
||||
func (g *Generator) generateMarkdown(action *ActionYML, outputDir, actionPath string) error {
|
||||
templatePath := g.resolveTemplatePathForFormat()
|
||||
|
||||
opts := TemplateOptions{
|
||||
TemplatePath: templatePath,
|
||||
Format: "md",
|
||||
}
|
||||
|
||||
content, err := g.renderTemplateForAction(action, outputDir, actionPath, opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to render markdown template: %w", err)
|
||||
}
|
||||
|
||||
outputPath := g.resolveOutputPath(outputDir, "README.md")
|
||||
if err := os.WriteFile(outputPath, []byte(content), FilePermDefault); err != nil {
|
||||
outputPath := g.resolveOutputPath(outputDir, appconstants.ReadmeMarkdown)
|
||||
if err := os.WriteFile(outputPath, []byte(content), appconstants.FilePermDefault); err != nil {
|
||||
// #nosec G306 -- output file permissions
|
||||
return fmt.Errorf("failed to write README.md to %s: %w", outputPath, err)
|
||||
}
|
||||
@@ -294,11 +311,7 @@ func (g *Generator) generateMarkdown(action *ActionYML, outputDir, actionPath st
|
||||
|
||||
// generateHTML creates an HTML file using the template and optional header/footer.
|
||||
func (g *Generator) generateHTML(action *ActionYML, outputDir, actionPath string) error {
|
||||
// Use theme-based template if theme is specified, otherwise use explicit template path
|
||||
templatePath := g.Config.Template
|
||||
if g.Config.Theme != "" {
|
||||
templatePath = resolveThemeTemplate(g.Config.Theme)
|
||||
}
|
||||
templatePath := g.resolveTemplatePathForFormat()
|
||||
|
||||
opts := TemplateOptions{
|
||||
TemplatePath: templatePath,
|
||||
@@ -307,13 +320,7 @@ func (g *Generator) generateHTML(action *ActionYML, outputDir, actionPath string
|
||||
Format: "html",
|
||||
}
|
||||
|
||||
// Find repository root for git information
|
||||
repoRoot, _ := git.FindRepositoryRoot(outputDir)
|
||||
|
||||
// Build comprehensive template data
|
||||
templateData := BuildTemplateData(action, g.Config, repoRoot, actionPath)
|
||||
|
||||
content, err := RenderReadme(templateData, opts)
|
||||
content, err := g.renderTemplateForAction(action, outputDir, actionPath, opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to render HTML template: %w", err)
|
||||
}
|
||||
@@ -339,7 +346,7 @@ func (g *Generator) generateHTML(action *ActionYML, outputDir, actionPath string
|
||||
func (g *Generator) generateJSON(action *ActionYML, outputDir string) error {
|
||||
writer := NewJSONWriter(g.Config)
|
||||
|
||||
outputPath := g.resolveOutputPath(outputDir, "action-docs.json")
|
||||
outputPath := g.resolveOutputPath(outputDir, appconstants.ActionDocsJSON)
|
||||
if err := writer.Write(action, outputPath); err != nil {
|
||||
return fmt.Errorf("failed to write JSON to %s: %w", outputPath, err)
|
||||
}
|
||||
@@ -351,27 +358,20 @@ func (g *Generator) generateJSON(action *ActionYML, outputDir string) error {
|
||||
|
||||
// generateASCIIDoc creates an AsciiDoc file using the template.
|
||||
func (g *Generator) generateASCIIDoc(action *ActionYML, outputDir, actionPath string) error {
|
||||
// Use AsciiDoc template
|
||||
templatePath := resolveTemplatePath("templates/themes/asciidoc/readme.adoc")
|
||||
templatePath := g.resolveTemplatePathForFormat()
|
||||
|
||||
opts := TemplateOptions{
|
||||
TemplatePath: templatePath,
|
||||
Format: "asciidoc",
|
||||
}
|
||||
|
||||
// Find repository root for git information
|
||||
repoRoot, _ := git.FindRepositoryRoot(outputDir)
|
||||
|
||||
// Build comprehensive template data
|
||||
templateData := BuildTemplateData(action, g.Config, repoRoot, actionPath)
|
||||
|
||||
content, err := RenderReadme(templateData, opts)
|
||||
content, err := g.renderTemplateForAction(action, outputDir, actionPath, opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to render AsciiDoc template: %w", err)
|
||||
}
|
||||
|
||||
outputPath := g.resolveOutputPath(outputDir, "README.adoc")
|
||||
if err := os.WriteFile(outputPath, []byte(content), FilePermDefault); err != nil {
|
||||
outputPath := g.resolveOutputPath(outputDir, appconstants.ReadmeASCIIDoc)
|
||||
if err := os.WriteFile(outputPath, []byte(content), appconstants.FilePermDefault); err != nil {
|
||||
// #nosec G306 -- output file permissions
|
||||
return fmt.Errorf("failed to write AsciiDoc to %s: %w", outputPath, err)
|
||||
}
|
||||
@@ -431,7 +431,8 @@ func (g *Generator) parseAndValidateAction(actionPath string) (*ActionYML, error
|
||||
// Check for critical validation errors that cannot be fixed with defaults
|
||||
for _, field := range validationResult.MissingFields {
|
||||
// All core required fields should cause validation failure
|
||||
if field == "name" || field == "description" || field == "runs" || field == "runs.using" {
|
||||
if field == appconstants.FieldName || field == appconstants.FieldDescription ||
|
||||
field == appconstants.FieldRuns || field == appconstants.FieldRunsUsing {
|
||||
// Required fields missing - cannot be fixed with defaults, must fail
|
||||
return nil, fmt.Errorf(
|
||||
"action file %s has invalid configuration, missing required field(s): %v",
|
||||
@@ -478,13 +479,13 @@ func (g *Generator) resolveOutputPath(outputDir, defaultFilename string) string
|
||||
// generateByFormat generates documentation in the specified format.
|
||||
func (g *Generator) generateByFormat(action *ActionYML, outputDir, actionPath string) error {
|
||||
switch g.Config.OutputFormat {
|
||||
case "md":
|
||||
case appconstants.OutputFormatMarkdown:
|
||||
return g.generateMarkdown(action, outputDir, actionPath)
|
||||
case OutputFormatHTML:
|
||||
case appconstants.OutputFormatHTML:
|
||||
return g.generateHTML(action, outputDir, actionPath)
|
||||
case OutputFormatJSON:
|
||||
case appconstants.OutputFormatJSON:
|
||||
return g.generateJSON(action, outputDir)
|
||||
case OutputFormatASCIIDoc:
|
||||
case appconstants.OutputFormatASCIIDoc:
|
||||
return g.generateASCIIDoc(action, outputDir, actionPath)
|
||||
default:
|
||||
return fmt.Errorf("unsupported output format: %s", g.Config.OutputFormat)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
@@ -47,9 +48,7 @@ func TestGenerator_DiscoverActionFiles(t *testing.T) {
|
||||
name: "single action.yml in root",
|
||||
setupFunc: func(t *testing.T, tmpDir string) {
|
||||
t.Helper()
|
||||
fixture, err := testutil.LoadActionFixture("actions/javascript/simple.yml")
|
||||
testutil.AssertNoError(t, err)
|
||||
testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), fixture.Content)
|
||||
testutil.WriteActionFixture(t, tmpDir, appconstants.TestFixtureJavaScriptSimple)
|
||||
},
|
||||
recursive: false,
|
||||
expectedLen: 1,
|
||||
@@ -58,9 +57,12 @@ func TestGenerator_DiscoverActionFiles(t *testing.T) {
|
||||
name: "action.yaml variant",
|
||||
setupFunc: func(t *testing.T, tmpDir string) {
|
||||
t.Helper()
|
||||
fixture, err := testutil.LoadActionFixture("actions/javascript/simple.yml")
|
||||
testutil.AssertNoError(t, err)
|
||||
testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yaml"), fixture.Content)
|
||||
testutil.WriteActionFixtureAs(
|
||||
t,
|
||||
tmpDir,
|
||||
appconstants.TestPathActionYAML,
|
||||
appconstants.TestFixtureJavaScriptSimple,
|
||||
)
|
||||
},
|
||||
recursive: false,
|
||||
expectedLen: 1,
|
||||
@@ -69,12 +71,13 @@ func TestGenerator_DiscoverActionFiles(t *testing.T) {
|
||||
name: "both yml and yaml files",
|
||||
setupFunc: func(t *testing.T, tmpDir string) {
|
||||
t.Helper()
|
||||
simpleFixture, err := testutil.LoadActionFixture("actions/javascript/simple.yml")
|
||||
testutil.AssertNoError(t, err)
|
||||
minimalFixture, err := testutil.LoadActionFixture("minimal-action.yml")
|
||||
testutil.AssertNoError(t, err)
|
||||
testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), simpleFixture.Content)
|
||||
testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yaml"), minimalFixture.Content)
|
||||
testutil.WriteActionFixture(t, tmpDir, appconstants.TestFixtureJavaScriptSimple)
|
||||
testutil.WriteActionFixtureAs(
|
||||
t,
|
||||
tmpDir,
|
||||
appconstants.TestPathActionYAML,
|
||||
appconstants.TestFixtureMinimalAction,
|
||||
)
|
||||
},
|
||||
recursive: false,
|
||||
expectedLen: 2,
|
||||
@@ -83,14 +86,13 @@ func TestGenerator_DiscoverActionFiles(t *testing.T) {
|
||||
name: "recursive discovery",
|
||||
setupFunc: func(t *testing.T, tmpDir string) {
|
||||
t.Helper()
|
||||
simpleFixture, err := testutil.LoadActionFixture("actions/javascript/simple.yml")
|
||||
testutil.AssertNoError(t, err)
|
||||
compositeFixture, err := testutil.LoadActionFixture("actions/composite/basic.yml")
|
||||
testutil.AssertNoError(t, err)
|
||||
testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), simpleFixture.Content)
|
||||
subDir := filepath.Join(tmpDir, "subdir")
|
||||
_ = os.MkdirAll(subDir, 0750) // #nosec G301 -- test directory permissions
|
||||
testutil.WriteTestFile(t, filepath.Join(subDir, "action.yml"), compositeFixture.Content)
|
||||
testutil.WriteActionFixture(t, tmpDir, appconstants.TestFixtureJavaScriptSimple)
|
||||
testutil.CreateActionSubdir(
|
||||
t,
|
||||
tmpDir,
|
||||
appconstants.TestDirSubdir,
|
||||
appconstants.TestFixtureCompositeBasic,
|
||||
)
|
||||
},
|
||||
recursive: true,
|
||||
expectedLen: 2,
|
||||
@@ -99,14 +101,13 @@ func TestGenerator_DiscoverActionFiles(t *testing.T) {
|
||||
name: "non-recursive skips subdirectories",
|
||||
setupFunc: func(t *testing.T, tmpDir string) {
|
||||
t.Helper()
|
||||
simpleFixture, err := testutil.LoadActionFixture("actions/javascript/simple.yml")
|
||||
testutil.AssertNoError(t, err)
|
||||
compositeFixture, err := testutil.LoadActionFixture("actions/composite/basic.yml")
|
||||
testutil.AssertNoError(t, err)
|
||||
testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), simpleFixture.Content)
|
||||
subDir := filepath.Join(tmpDir, "subdir")
|
||||
_ = os.MkdirAll(subDir, 0750) // #nosec G301 -- test directory permissions
|
||||
testutil.WriteTestFile(t, filepath.Join(subDir, "action.yml"), compositeFixture.Content)
|
||||
testutil.WriteActionFixture(t, tmpDir, appconstants.TestFixtureJavaScriptSimple)
|
||||
testutil.CreateActionSubdir(
|
||||
t,
|
||||
tmpDir,
|
||||
appconstants.TestDirSubdir,
|
||||
appconstants.TestFixtureCompositeBasic,
|
||||
)
|
||||
},
|
||||
recursive: false,
|
||||
expectedLen: 1,
|
||||
@@ -157,11 +158,10 @@ func TestGenerator_DiscoverActionFiles(t *testing.T) {
|
||||
|
||||
// Verify all returned files exist and are action files
|
||||
for _, file := range files {
|
||||
if _, err := os.Stat(file); os.IsNotExist(err) {
|
||||
t.Errorf("discovered file does not exist: %s", file)
|
||||
}
|
||||
testutil.AssertFileExists(t, file)
|
||||
|
||||
if !strings.HasSuffix(file, "action.yml") && !strings.HasSuffix(file, "action.yaml") {
|
||||
if !strings.HasSuffix(file, appconstants.TestPathActionYML) &&
|
||||
!strings.HasSuffix(file, appconstants.TestPathActionYAML) {
|
||||
t.Errorf("discovered file is not an action file: %s", file)
|
||||
}
|
||||
}
|
||||
@@ -180,21 +180,21 @@ func TestGenerator_GenerateFromFile(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "simple action to markdown",
|
||||
actionYML: testutil.MustReadFixture("actions/javascript/simple.yml"),
|
||||
actionYML: testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple),
|
||||
outputFormat: "md",
|
||||
expectError: false,
|
||||
contains: []string{"# Simple JavaScript Action", "A simple JavaScript action for testing"},
|
||||
},
|
||||
{
|
||||
name: "composite action to markdown",
|
||||
actionYML: testutil.MustReadFixture("actions/composite/basic.yml"),
|
||||
actionYML: testutil.MustReadFixture(appconstants.TestFixtureCompositeBasic),
|
||||
outputFormat: "md",
|
||||
expectError: false,
|
||||
contains: []string{"# Basic Composite Action", "A simple composite action with basic steps"},
|
||||
},
|
||||
{
|
||||
name: "action to HTML",
|
||||
actionYML: testutil.MustReadFixture("actions/javascript/simple.yml"),
|
||||
actionYML: testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple),
|
||||
outputFormat: "html",
|
||||
expectError: false,
|
||||
contains: []string{
|
||||
@@ -204,7 +204,7 @@ func TestGenerator_GenerateFromFile(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "action to JSON",
|
||||
actionYML: testutil.MustReadFixture("actions/javascript/simple.yml"),
|
||||
actionYML: testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple),
|
||||
outputFormat: "json",
|
||||
expectError: false,
|
||||
contains: []string{
|
||||
@@ -214,14 +214,14 @@ func TestGenerator_GenerateFromFile(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "invalid action file",
|
||||
actionYML: testutil.MustReadFixture("actions/invalid/invalid-using.yml"),
|
||||
actionYML: testutil.MustReadFixture(appconstants.TestFixtureInvalidInvalidUsing),
|
||||
outputFormat: "md",
|
||||
expectError: true, // Invalid runtime configuration should cause failure
|
||||
contains: []string{},
|
||||
},
|
||||
{
|
||||
name: "unknown output format",
|
||||
actionYML: testutil.MustReadFixture("actions/javascript/simple.yml"),
|
||||
actionYML: testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple),
|
||||
outputFormat: "unknown",
|
||||
expectError: true,
|
||||
},
|
||||
@@ -237,7 +237,7 @@ func TestGenerator_GenerateFromFile(t *testing.T) {
|
||||
testutil.SetupTestTemplates(t, tmpDir)
|
||||
|
||||
// Write action file
|
||||
actionPath := filepath.Join(tmpDir, "action.yml")
|
||||
actionPath := filepath.Join(tmpDir, appconstants.TestPathActionYML)
|
||||
testutil.WriteTestFile(t, actionPath, tt.actionYML)
|
||||
|
||||
// Create generator with explicit template path
|
||||
@@ -338,21 +338,14 @@ func TestGenerator_ProcessBatch(t *testing.T) {
|
||||
setupFunc: func(t *testing.T, tmpDir string) []string {
|
||||
t.Helper()
|
||||
// Create separate directories for each action
|
||||
dir1 := filepath.Join(tmpDir, "action1")
|
||||
dir2 := filepath.Join(tmpDir, "action2")
|
||||
if err := os.MkdirAll(dir1, 0750); err != nil { // #nosec G301 -- test directory permissions
|
||||
t.Fatalf("failed to create dir1: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(dir2, 0750); err != nil { // #nosec G301 -- test directory permissions
|
||||
t.Fatalf("failed to create dir2: %v", err)
|
||||
}
|
||||
dirs := createTestDirs(t, tmpDir, "action1", "action2")
|
||||
|
||||
files := []string{
|
||||
filepath.Join(dir1, "action.yml"),
|
||||
filepath.Join(dir2, "action.yml"),
|
||||
filepath.Join(dirs[0], appconstants.TestPathActionYML),
|
||||
filepath.Join(dirs[1], appconstants.TestPathActionYML),
|
||||
}
|
||||
testutil.WriteTestFile(t, files[0], testutil.MustReadFixture("actions/javascript/simple.yml"))
|
||||
testutil.WriteTestFile(t, files[1], testutil.MustReadFixture("actions/composite/basic.yml"))
|
||||
testutil.WriteTestFile(t, files[0], testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple))
|
||||
testutil.WriteTestFile(t, files[1], testutil.MustReadFixture(appconstants.TestFixtureCompositeBasic))
|
||||
|
||||
return files
|
||||
},
|
||||
@@ -364,21 +357,18 @@ func TestGenerator_ProcessBatch(t *testing.T) {
|
||||
setupFunc: func(t *testing.T, tmpDir string) []string {
|
||||
t.Helper()
|
||||
// Create separate directories for mixed test too
|
||||
dir1 := filepath.Join(tmpDir, "valid-action")
|
||||
dir2 := filepath.Join(tmpDir, "invalid-action")
|
||||
if err := os.MkdirAll(dir1, 0750); err != nil { // #nosec G301 -- test directory permissions
|
||||
t.Fatalf("failed to create dir1: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(dir2, 0750); err != nil { // #nosec G301 -- test directory permissions
|
||||
t.Fatalf("failed to create dir2: %v", err)
|
||||
}
|
||||
dirs := createTestDirs(t, tmpDir, "valid-action", "invalid-action")
|
||||
|
||||
files := []string{
|
||||
filepath.Join(dir1, "action.yml"),
|
||||
filepath.Join(dir2, "action.yml"),
|
||||
filepath.Join(dirs[0], appconstants.TestPathActionYML),
|
||||
filepath.Join(dirs[1], appconstants.TestPathActionYML),
|
||||
}
|
||||
testutil.WriteTestFile(t, files[0], testutil.MustReadFixture("actions/javascript/simple.yml"))
|
||||
testutil.WriteTestFile(t, files[1], testutil.MustReadFixture("actions/invalid/invalid-using.yml"))
|
||||
testutil.WriteTestFile(t, files[0], testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple))
|
||||
testutil.WriteTestFile(
|
||||
t,
|
||||
files[1],
|
||||
testutil.MustReadFixture(appconstants.TestFixtureInvalidInvalidUsing),
|
||||
)
|
||||
|
||||
return files
|
||||
},
|
||||
@@ -462,8 +452,8 @@ func TestGenerator_ValidateFiles(t *testing.T) {
|
||||
filepath.Join(tmpDir, "action1.yml"),
|
||||
filepath.Join(tmpDir, "action2.yml"),
|
||||
}
|
||||
testutil.WriteTestFile(t, files[0], testutil.MustReadFixture("actions/javascript/simple.yml"))
|
||||
testutil.WriteTestFile(t, files[1], testutil.MustReadFixture("minimal-action.yml"))
|
||||
testutil.WriteTestFile(t, files[0], testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple))
|
||||
testutil.WriteTestFile(t, files[1], testutil.MustReadFixture(appconstants.TestFixtureMinimalAction))
|
||||
|
||||
return files
|
||||
},
|
||||
@@ -477,8 +467,12 @@ func TestGenerator_ValidateFiles(t *testing.T) {
|
||||
filepath.Join(tmpDir, "valid.yml"),
|
||||
filepath.Join(tmpDir, "invalid.yml"),
|
||||
}
|
||||
testutil.WriteTestFile(t, files[0], testutil.MustReadFixture("actions/javascript/simple.yml"))
|
||||
testutil.WriteTestFile(t, files[1], testutil.MustReadFixture("actions/invalid/missing-description.yml"))
|
||||
testutil.WriteTestFile(t, files[0], testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple))
|
||||
testutil.WriteTestFile(
|
||||
t,
|
||||
files[1],
|
||||
testutil.MustReadFixture(appconstants.TestFixtureInvalidMissingDescription),
|
||||
)
|
||||
|
||||
return files
|
||||
},
|
||||
@@ -573,8 +567,8 @@ func TestGenerator_WithDifferentThemes(t *testing.T) {
|
||||
// Set up test templates for this theme test
|
||||
testutil.SetupTestTemplates(t, tmpDir)
|
||||
|
||||
actionPath := filepath.Join(tmpDir, "action.yml")
|
||||
testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture("actions/javascript/simple.yml"))
|
||||
actionPath := filepath.Join(tmpDir, appconstants.TestPathActionYML)
|
||||
testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple))
|
||||
|
||||
config := &AppConfig{
|
||||
Theme: theme,
|
||||
@@ -617,8 +611,12 @@ func TestGenerator_ErrorHandling(t *testing.T) {
|
||||
Quiet: true,
|
||||
}
|
||||
generator := NewGenerator(config)
|
||||
actionPath := filepath.Join(tmpDir, "action.yml")
|
||||
testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture("actions/javascript/simple.yml"))
|
||||
actionPath := filepath.Join(tmpDir, appconstants.TestPathActionYML)
|
||||
testutil.WriteTestFile(
|
||||
t,
|
||||
actionPath,
|
||||
testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple),
|
||||
)
|
||||
|
||||
return generator, actionPath
|
||||
},
|
||||
@@ -642,8 +640,12 @@ func TestGenerator_ErrorHandling(t *testing.T) {
|
||||
Template: filepath.Join(tmpDir, "templates", "readme.tmpl"),
|
||||
}
|
||||
generator := NewGenerator(config)
|
||||
actionPath := filepath.Join(tmpDir, "action.yml")
|
||||
testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture("actions/javascript/simple.yml"))
|
||||
actionPath := filepath.Join(tmpDir, appconstants.TestPathActionYML)
|
||||
testutil.WriteTestFile(
|
||||
t,
|
||||
actionPath,
|
||||
testutil.MustReadFixture(appconstants.TestFixtureJavaScriptSimple),
|
||||
)
|
||||
|
||||
return generator, actionPath
|
||||
},
|
||||
@@ -667,3 +669,19 @@ func TestGenerator_ErrorHandling(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// createTestDirs is a helper that creates multiple directories within tmpDir for testing.
|
||||
// Returns the full paths of all created directories.
|
||||
func createTestDirs(t *testing.T, tmpDir string, names ...string) []string {
|
||||
t.Helper()
|
||||
dirs := make([]string, len(names))
|
||||
for i, name := range names {
|
||||
dirPath := filepath.Join(tmpDir, name)
|
||||
if err := os.MkdirAll(dirPath, 0750); err != nil { // #nosec G301 -- test directory permissions
|
||||
t.Fatalf("failed to create directory %s: %v", name, err)
|
||||
}
|
||||
dirs[i] = dirPath
|
||||
}
|
||||
|
||||
return dirs
|
||||
}
|
||||
|
||||
@@ -10,11 +10,8 @@ import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultBranch is the default branch name used as fallback.
|
||||
DefaultBranch = "main"
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
)
|
||||
|
||||
// RepoInfo contains information about a Git repository.
|
||||
@@ -29,7 +26,7 @@ type RepoInfo struct {
|
||||
// GetRepositoryName returns the full repository name in org/repo format.
|
||||
func (r *RepoInfo) GetRepositoryName() string {
|
||||
if r.Organization != "" && r.Repository != "" {
|
||||
return fmt.Sprintf("%s/%s", r.Organization, r.Repository)
|
||||
return fmt.Sprintf(appconstants.URLPatternGitHubRepo, r.Organization, r.Repository)
|
||||
}
|
||||
|
||||
return ""
|
||||
@@ -44,7 +41,7 @@ func FindRepositoryRoot(startPath string) (string, error) {
|
||||
|
||||
// Walk up the directory tree looking for .git
|
||||
for {
|
||||
gitPath := filepath.Join(absPath, ".git")
|
||||
gitPath := filepath.Join(absPath, appconstants.DirGit)
|
||||
if _, err := os.Stat(gitPath); err == nil {
|
||||
return absPath, nil
|
||||
}
|
||||
@@ -65,7 +62,7 @@ func DetectRepository(repoRoot string) (*RepoInfo, error) {
|
||||
}
|
||||
|
||||
// Check if this is actually a git repository
|
||||
gitPath := filepath.Join(repoRoot, ".git")
|
||||
gitPath := filepath.Join(repoRoot, appconstants.DirGit)
|
||||
if _, err := os.Stat(gitPath); os.IsNotExist(err) {
|
||||
return &RepoInfo{IsGitRepo: false}, nil
|
||||
}
|
||||
@@ -100,7 +97,12 @@ func getRemoteURL(repoRoot string) (string, error) {
|
||||
|
||||
// getRemoteURLFromGit uses git command to get remote URL.
|
||||
func getRemoteURLFromGit(repoRoot string) (string, error) {
|
||||
cmd := exec.Command("git", "remote", "get-url", "origin")
|
||||
cmd := exec.Command(
|
||||
appconstants.GitCommand,
|
||||
"remote",
|
||||
"get-url",
|
||||
"origin",
|
||||
) // #nosec G204 -- git command is a constant
|
||||
cmd.Dir = repoRoot
|
||||
|
||||
output, err := cmd.Output()
|
||||
@@ -113,7 +115,7 @@ func getRemoteURLFromGit(repoRoot string) (string, error) {
|
||||
|
||||
// getRemoteURLFromConfig parses .git/config to extract remote URL.
|
||||
func getRemoteURLFromConfig(repoRoot string) (string, error) {
|
||||
configPath := filepath.Join(repoRoot, ".git", "config")
|
||||
configPath := filepath.Join(repoRoot, appconstants.DirGit, appconstants.ConfigFileName)
|
||||
file, err := os.Open(configPath) // #nosec G304 -- git config path constructed from repo root
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to open git config: %w", err)
|
||||
@@ -143,8 +145,8 @@ func getRemoteURLFromConfig(repoRoot string) (string, error) {
|
||||
}
|
||||
|
||||
// Look for url = in origin section
|
||||
if inOriginSection && strings.HasPrefix(line, "url = ") {
|
||||
return strings.TrimPrefix(line, "url = "), nil
|
||||
if inOriginSection && strings.HasPrefix(line, appconstants.GitConfigURL) {
|
||||
return strings.TrimPrefix(line, appconstants.GitConfigURL), nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,13 +161,13 @@ func getDefaultBranch(repoRoot string) string {
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
// Fallback to common default branches
|
||||
for _, branch := range []string{DefaultBranch, "master"} {
|
||||
for _, branch := range []string{appconstants.GitDefaultBranch, "master"} {
|
||||
if branchExists(repoRoot, branch) {
|
||||
return branch
|
||||
}
|
||||
}
|
||||
|
||||
return DefaultBranch // Default fallback
|
||||
return appconstants.GitDefaultBranch // Default fallback
|
||||
}
|
||||
|
||||
// Extract branch name from refs/remotes/origin/HEAD -> refs/remotes/origin/main
|
||||
@@ -174,16 +176,16 @@ func getDefaultBranch(repoRoot string) string {
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
|
||||
return DefaultBranch
|
||||
return appconstants.GitDefaultBranch
|
||||
}
|
||||
|
||||
// branchExists checks if a branch exists in the repository.
|
||||
func branchExists(repoRoot, branch string) bool {
|
||||
cmd := exec.Command(
|
||||
"git",
|
||||
"show-ref",
|
||||
"--verify",
|
||||
"--quiet",
|
||||
appconstants.GitCommand,
|
||||
appconstants.GitShowRef,
|
||||
appconstants.GitVerify,
|
||||
appconstants.GitQuiet,
|
||||
"refs/heads/"+branch,
|
||||
) // #nosec G204 -- branch name validated by git
|
||||
cmd.Dir = repoRoot
|
||||
|
||||
@@ -109,9 +109,7 @@ func TestFindRepositoryRoot(t *testing.T) {
|
||||
|
||||
// Verify the returned path contains a .git directory or file
|
||||
gitPath := filepath.Join(repoRoot, ".git")
|
||||
if _, err := os.Stat(gitPath); os.IsNotExist(err) {
|
||||
t.Errorf("repository root does not contain .git: %s", repoRoot)
|
||||
}
|
||||
testutil.AssertFileExists(t, gitPath)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
25
internal/github_helper.go
Normal file
25
internal/github_helper.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
)
|
||||
|
||||
// loadGitHubTokenFromEnv retrieves the GitHub token from environment variables.
|
||||
// It checks both the tool-specific environment variable (GHREADME_GITHUB_TOKEN)
|
||||
// and the standard GitHub environment variable (GITHUB_TOKEN) in that order.
|
||||
// Returns an empty string if no token is found.
|
||||
func loadGitHubTokenFromEnv() string {
|
||||
// Priority 1: Tool-specific env var
|
||||
if token := os.Getenv(appconstants.EnvGitHubToken); token != "" {
|
||||
return token
|
||||
}
|
||||
|
||||
// Priority 2: Standard GitHub env var
|
||||
if token := os.Getenv(appconstants.EnvGitHubTokenStandard); token != "" {
|
||||
return token
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/internal"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/dependencies"
|
||||
)
|
||||
@@ -11,7 +12,7 @@ import (
|
||||
func CreateAnalyzer(generator *internal.Generator, output *internal.ColoredOutput) *dependencies.Analyzer {
|
||||
analyzer, err := generator.CreateDependencyAnalyzer()
|
||||
if err != nil {
|
||||
output.Warning("Could not create dependency analyzer: %v", err)
|
||||
output.Warning(appconstants.ErrCouldNotCreateDependencyAnalyzer, err)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -28,9 +28,7 @@ func TestGetCurrentDir(t *testing.T) {
|
||||
}
|
||||
|
||||
// Verify the directory actually exists
|
||||
if _, err := os.Stat(currentDir); os.IsNotExist(err) {
|
||||
t.Errorf("current directory does not exist: %s", currentDir)
|
||||
}
|
||||
testutil.AssertFileExists(t, currentDir)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@ import (
|
||||
|
||||
"github.com/schollz/progressbar/v3"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/internal/errors"
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/apperrors"
|
||||
)
|
||||
|
||||
// MessageLogger handles informational output messages.
|
||||
@@ -22,14 +23,14 @@ type MessageLogger interface {
|
||||
// ErrorReporter handles error output and reporting.
|
||||
type ErrorReporter interface {
|
||||
Error(format string, args ...any)
|
||||
ErrorWithSuggestions(err *errors.ContextualError)
|
||||
ErrorWithContext(code errors.ErrorCode, message string, context map[string]string)
|
||||
ErrorWithSuggestions(err *apperrors.ContextualError)
|
||||
ErrorWithContext(code appconstants.ErrorCode, message string, context map[string]string)
|
||||
ErrorWithSimpleFix(message, suggestion string)
|
||||
}
|
||||
|
||||
// ErrorFormatter handles formatting of contextual errors.
|
||||
type ErrorFormatter interface {
|
||||
FormatContextualError(err *errors.ContextualError) string
|
||||
FormatContextualError(err *apperrors.ContextualError) string
|
||||
}
|
||||
|
||||
// ProgressReporter handles progress indication and status updates.
|
||||
|
||||
@@ -8,7 +8,8 @@ import (
|
||||
|
||||
"github.com/schollz/progressbar/v3"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/internal/errors"
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/apperrors"
|
||||
)
|
||||
|
||||
// MockMessageLogger implements MessageLogger for testing.
|
||||
@@ -57,13 +58,13 @@ func (m *MockErrorReporter) Error(format string, args ...any) {
|
||||
m.ErrorCalls = append(m.ErrorCalls, formatMessage(format, args...))
|
||||
}
|
||||
|
||||
func (m *MockErrorReporter) ErrorWithSuggestions(err *errors.ContextualError) {
|
||||
func (m *MockErrorReporter) ErrorWithSuggestions(err *apperrors.ContextualError) {
|
||||
if err != nil {
|
||||
m.ErrorWithSuggestionsCalls = append(m.ErrorWithSuggestionsCalls, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MockErrorReporter) ErrorWithContext(_ errors.ErrorCode, message string, _ map[string]string) {
|
||||
func (m *MockErrorReporter) ErrorWithContext(_ appconstants.ErrorCode, message string, _ map[string]string) {
|
||||
m.ErrorWithContextCalls = append(m.ErrorWithContextCalls, message)
|
||||
}
|
||||
|
||||
@@ -405,16 +406,16 @@ func (m *mockCompleteOutput) Fprintf(w *os.File, format string, args ...any) {
|
||||
m.logger.Fprintf(w, format, args...)
|
||||
}
|
||||
func (m *mockCompleteOutput) Error(format string, args ...any) { m.reporter.Error(format, args...) }
|
||||
func (m *mockCompleteOutput) ErrorWithSuggestions(err *errors.ContextualError) {
|
||||
func (m *mockCompleteOutput) ErrorWithSuggestions(err *apperrors.ContextualError) {
|
||||
m.reporter.ErrorWithSuggestions(err)
|
||||
}
|
||||
func (m *mockCompleteOutput) ErrorWithContext(code errors.ErrorCode, message string, context map[string]string) {
|
||||
func (m *mockCompleteOutput) ErrorWithContext(code appconstants.ErrorCode, message string, context map[string]string) {
|
||||
m.reporter.ErrorWithContext(code, message, context)
|
||||
}
|
||||
func (m *mockCompleteOutput) ErrorWithSimpleFix(message, suggestion string) {
|
||||
m.reporter.ErrorWithSimpleFix(message, suggestion)
|
||||
}
|
||||
func (m *mockCompleteOutput) FormatContextualError(err *errors.ContextualError) string {
|
||||
func (m *mockCompleteOutput) FormatContextualError(err *apperrors.ContextualError) string {
|
||||
return m.formatter.FormatContextualError(err)
|
||||
}
|
||||
func (m *mockCompleteOutput) Progress(format string, args ...any) {
|
||||
@@ -444,7 +445,7 @@ type MockErrorFormatter struct {
|
||||
FormatContextualErrorCalls []string
|
||||
}
|
||||
|
||||
func (m *MockErrorFormatter) FormatContextualError(err *errors.ContextualError) string {
|
||||
func (m *MockErrorFormatter) FormatContextualError(err *apperrors.ContextualError) string {
|
||||
if err != nil {
|
||||
formatted := err.Error()
|
||||
m.FormatContextualErrorCalls = append(m.FormatContextualErrorCalls, formatted)
|
||||
@@ -462,15 +463,15 @@ type mockErrorManager struct {
|
||||
}
|
||||
|
||||
func (m *mockErrorManager) Error(format string, args ...any) { m.reporter.Error(format, args...) }
|
||||
func (m *mockErrorManager) ErrorWithSuggestions(err *errors.ContextualError) {
|
||||
func (m *mockErrorManager) ErrorWithSuggestions(err *apperrors.ContextualError) {
|
||||
m.reporter.ErrorWithSuggestions(err)
|
||||
}
|
||||
func (m *mockErrorManager) ErrorWithContext(code errors.ErrorCode, message string, context map[string]string) {
|
||||
func (m *mockErrorManager) ErrorWithContext(code appconstants.ErrorCode, message string, context map[string]string) {
|
||||
m.reporter.ErrorWithContext(code, message, context)
|
||||
}
|
||||
func (m *mockErrorManager) ErrorWithSimpleFix(message, suggestion string) {
|
||||
m.reporter.ErrorWithSimpleFix(message, suggestion)
|
||||
}
|
||||
func (m *mockErrorManager) FormatContextualError(err *errors.ContextualError) string {
|
||||
func (m *mockErrorManager) FormatContextualError(err *apperrors.ContextualError) string {
|
||||
return m.formatter.FormatContextualError(err)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
)
|
||||
|
||||
// getVersion returns the current version - can be overridden at build time.
|
||||
@@ -119,7 +121,7 @@ func (jw *JSONWriter) Write(action *ActionYML, outputPath string) error {
|
||||
}
|
||||
|
||||
// Write to file
|
||||
return os.WriteFile(outputPath, data, FilePermDefault) // #nosec G306 -- JSON output file permissions
|
||||
return os.WriteFile(outputPath, data, appconstants.FilePermDefault) // #nosec G306 -- JSON output file permissions
|
||||
}
|
||||
|
||||
// convertToJSONOutput converts ActionYML to structured JSON output.
|
||||
|
||||
@@ -7,7 +7,8 @@ import (
|
||||
|
||||
"github.com/fatih/color"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/internal/errors"
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/apperrors"
|
||||
)
|
||||
|
||||
// ColoredOutput provides methods for colored terminal output.
|
||||
@@ -123,7 +124,7 @@ func (co *ColoredOutput) Fprintf(w *os.File, format string, args ...any) {
|
||||
}
|
||||
|
||||
// ErrorWithSuggestions prints a ContextualError with suggestions and help.
|
||||
func (co *ColoredOutput) ErrorWithSuggestions(err *errors.ContextualError) {
|
||||
func (co *ColoredOutput) ErrorWithSuggestions(err *apperrors.ContextualError) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
@@ -138,14 +139,14 @@ func (co *ColoredOutput) ErrorWithSuggestions(err *errors.ContextualError) {
|
||||
|
||||
// ErrorWithContext creates and prints a contextual error with suggestions.
|
||||
func (co *ColoredOutput) ErrorWithContext(
|
||||
code errors.ErrorCode,
|
||||
code appconstants.ErrorCode,
|
||||
message string,
|
||||
context map[string]string,
|
||||
) {
|
||||
suggestions := errors.GetSuggestions(code, context)
|
||||
helpURL := errors.GetHelpURL(code)
|
||||
suggestions := apperrors.GetSuggestions(code, context)
|
||||
helpURL := apperrors.GetHelpURL(code)
|
||||
|
||||
contextualErr := errors.New(code, message).
|
||||
contextualErr := apperrors.New(code, message).
|
||||
WithSuggestions(suggestions...).
|
||||
WithHelpURL(helpURL)
|
||||
|
||||
@@ -158,14 +159,14 @@ func (co *ColoredOutput) ErrorWithContext(
|
||||
|
||||
// ErrorWithSimpleFix prints an error with a simple suggestion.
|
||||
func (co *ColoredOutput) ErrorWithSimpleFix(message, suggestion string) {
|
||||
contextualErr := errors.New(errors.ErrCodeUnknown, message).
|
||||
contextualErr := apperrors.New(appconstants.ErrCodeUnknown, message).
|
||||
WithSuggestions(suggestion)
|
||||
|
||||
co.ErrorWithSuggestions(contextualErr)
|
||||
}
|
||||
|
||||
// FormatContextualError formats a ContextualError for display.
|
||||
func (co *ColoredOutput) FormatContextualError(err *errors.ContextualError) string {
|
||||
func (co *ColoredOutput) FormatContextualError(err *apperrors.ContextualError) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
@@ -194,7 +195,7 @@ func (co *ColoredOutput) FormatContextualError(err *errors.ContextualError) stri
|
||||
}
|
||||
|
||||
// formatMainError formats the main error message with code.
|
||||
func (co *ColoredOutput) formatMainError(err *errors.ContextualError) string {
|
||||
func (co *ColoredOutput) formatMainError(err *apperrors.ContextualError) string {
|
||||
mainMsg := fmt.Sprintf("%s [%s]", err.Error(), err.Code)
|
||||
if co.NoColor {
|
||||
return "❌ " + mainMsg
|
||||
@@ -208,16 +209,16 @@ func (co *ColoredOutput) formatDetailsSection(details map[string]string) []strin
|
||||
var parts []string
|
||||
|
||||
if co.NoColor {
|
||||
parts = append(parts, "\nDetails:")
|
||||
parts = append(parts, appconstants.SectionDetails)
|
||||
} else {
|
||||
parts = append(parts, color.New(color.Bold).Sprint("\nDetails:"))
|
||||
parts = append(parts, color.New(color.Bold).Sprint(appconstants.SectionDetails))
|
||||
}
|
||||
|
||||
for key, value := range details {
|
||||
if co.NoColor {
|
||||
parts = append(parts, fmt.Sprintf(" %s: %s", key, value))
|
||||
parts = append(parts, fmt.Sprintf(appconstants.FormatDetailKeyValue, key, value))
|
||||
} else {
|
||||
parts = append(parts, fmt.Sprintf(" %s: %s",
|
||||
parts = append(parts, fmt.Sprintf(appconstants.FormatDetailKeyValue,
|
||||
color.CyanString(key),
|
||||
color.WhiteString(value)))
|
||||
}
|
||||
@@ -231,9 +232,9 @@ func (co *ColoredOutput) formatSuggestionsSection(suggestions []string) []string
|
||||
var parts []string
|
||||
|
||||
if co.NoColor {
|
||||
parts = append(parts, "\nSuggestions:")
|
||||
parts = append(parts, appconstants.SectionSuggestions)
|
||||
} else {
|
||||
parts = append(parts, color.New(color.Bold).Sprint("\nSuggestions:"))
|
||||
parts = append(parts, color.New(color.Bold).Sprint(appconstants.SectionSuggestions))
|
||||
}
|
||||
|
||||
for _, suggestion := range suggestions {
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/goccy/go-yaml"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
)
|
||||
|
||||
// ActionYML models the action.yml metadata (fields are updateable as schema evolves).
|
||||
@@ -78,7 +80,7 @@ func DiscoverActionFiles(dir string, recursive bool) ([]string, error) {
|
||||
|
||||
// Check for action.yml or action.yaml files
|
||||
filename := strings.ToLower(info.Name())
|
||||
if filename == "action.yml" || filename == "action.yaml" {
|
||||
if filename == appconstants.ActionFileNameYML || filename == appconstants.ActionFileNameYAML {
|
||||
actionFiles = append(actionFiles, path)
|
||||
}
|
||||
|
||||
@@ -89,7 +91,7 @@ func DiscoverActionFiles(dir string, recursive bool) ([]string, error) {
|
||||
}
|
||||
} else {
|
||||
// Check only the specified directory
|
||||
for _, filename := range []string{"action.yml", "action.yaml"} {
|
||||
for _, filename := range []string{appconstants.ActionFileNameYML, appconstants.ActionFileNameYAML} {
|
||||
path := filepath.Join(dir, filename)
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
actionFiles = append(actionFiles, path)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/google/go-github/v74/github"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/cache"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/dependencies"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/git"
|
||||
@@ -14,12 +15,6 @@ import (
|
||||
"github.com/ivuorinen/gh-action-readme/templates_embed"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultOrgPlaceholder = "your-org"
|
||||
defaultRepoPlaceholder = "your-repo"
|
||||
defaultUsesPlaceholder = "your-org/your-action@v1"
|
||||
)
|
||||
|
||||
// TemplateOptions defines options for rendering templates.
|
||||
type TemplateOptions struct {
|
||||
TemplatePath string
|
||||
@@ -71,7 +66,7 @@ func getGitOrg(data any) string {
|
||||
}
|
||||
}
|
||||
|
||||
return defaultOrgPlaceholder
|
||||
return appconstants.DefaultOrgPlaceholder
|
||||
}
|
||||
|
||||
// getGitRepo returns the Git repository name from template data.
|
||||
@@ -85,21 +80,21 @@ func getGitRepo(data any) string {
|
||||
}
|
||||
}
|
||||
|
||||
return defaultRepoPlaceholder
|
||||
return appconstants.DefaultRepoPlaceholder
|
||||
}
|
||||
|
||||
// getGitUsesString returns a complete uses string for the action.
|
||||
func getGitUsesString(data any) string {
|
||||
td, ok := data.(*TemplateData)
|
||||
if !ok {
|
||||
return defaultUsesPlaceholder
|
||||
return appconstants.DefaultUsesPlaceholder
|
||||
}
|
||||
|
||||
org := strings.TrimSpace(getGitOrg(data))
|
||||
repo := strings.TrimSpace(getGitRepo(data))
|
||||
|
||||
if !isValidOrgRepo(org, repo) {
|
||||
return defaultUsesPlaceholder
|
||||
return appconstants.DefaultUsesPlaceholder
|
||||
}
|
||||
|
||||
version := formatVersion(getActionVersion(data))
|
||||
@@ -109,7 +104,9 @@ func getGitUsesString(data any) string {
|
||||
|
||||
// isValidOrgRepo checks if org and repo are valid.
|
||||
func isValidOrgRepo(org, repo string) bool {
|
||||
return org != "" && repo != "" && org != defaultOrgPlaceholder && repo != defaultRepoPlaceholder
|
||||
return org != "" && repo != "" &&
|
||||
org != appconstants.DefaultOrgPlaceholder &&
|
||||
repo != appconstants.DefaultRepoPlaceholder
|
||||
}
|
||||
|
||||
// formatVersion ensures version has proper @ prefix.
|
||||
@@ -129,7 +126,7 @@ func formatVersion(version string) string {
|
||||
func buildUsesString(td *TemplateData, org, repo, version string) string {
|
||||
// Use the validation package's FormatUsesStatement for consistency
|
||||
if org == "" || repo == "" {
|
||||
return defaultUsesPlaceholder
|
||||
return appconstants.DefaultUsesPlaceholder
|
||||
}
|
||||
|
||||
// For actions within subdirectories, include the action name
|
||||
@@ -235,8 +232,8 @@ func RenderReadme(action any, opts TemplateOptions) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
var tmpl *template.Template
|
||||
if opts.Format == OutputFormatHTML {
|
||||
tmpl, err = template.New("readme").Funcs(templateFuncs()).Parse(string(tmplContent))
|
||||
if opts.Format == appconstants.OutputFormatHTML {
|
||||
tmpl, err = template.New(appconstants.TemplateNameReadme).Funcs(templateFuncs()).Parse(string(tmplContent))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -260,7 +257,7 @@ func RenderReadme(action any, opts TemplateOptions) (string, error) {
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
tmpl, err = template.New("readme").Funcs(templateFuncs()).Parse(string(tmplContent))
|
||||
tmpl, err = template.New(appconstants.TemplateNameReadme).Funcs(templateFuncs()).Parse(string(tmplContent))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ import (
|
||||
|
||||
"github.com/schollz/progressbar/v3"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/internal/errors"
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/apperrors"
|
||||
)
|
||||
|
||||
// NullOutput is a no-op implementation of CompleteOutput for testing.
|
||||
@@ -57,11 +58,13 @@ func (no *NullOutput) Printf(_ string, _ ...any) {}
|
||||
func (no *NullOutput) Fprintf(_ *os.File, _ string, _ ...any) {}
|
||||
|
||||
// ErrorWithSuggestions is a no-op.
|
||||
func (no *NullOutput) ErrorWithSuggestions(_ *errors.ContextualError) {}
|
||||
func (no *NullOutput) ErrorWithSuggestions(_ *apperrors.ContextualError) {
|
||||
// Intentionally empty - no-op implementation for testing
|
||||
}
|
||||
|
||||
// ErrorWithContext is a no-op.
|
||||
func (no *NullOutput) ErrorWithContext(
|
||||
_ errors.ErrorCode,
|
||||
_ appconstants.ErrorCode,
|
||||
_ string,
|
||||
_ map[string]string,
|
||||
) {
|
||||
@@ -71,7 +74,7 @@ func (no *NullOutput) ErrorWithContext(
|
||||
func (no *NullOutput) ErrorWithSimpleFix(_, _ string) {}
|
||||
|
||||
// FormatContextualError returns empty string.
|
||||
func (no *NullOutput) FormatContextualError(_ *errors.ContextualError) string {
|
||||
func (no *NullOutput) FormatContextualError(_ *apperrors.ContextualError) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
|
||||
@@ -6,13 +6,14 @@ import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/git"
|
||||
)
|
||||
|
||||
// IsCommitSHA checks if a version string is a commit SHA.
|
||||
func IsCommitSHA(version string) bool {
|
||||
// Check if it's a 40-character hex string (full SHA) or 7+ character hex (short SHA)
|
||||
re := regexp.MustCompile(`^[a-f0-9]{7,40}$`)
|
||||
re := regexp.MustCompile(appconstants.RegexGitSHA)
|
||||
|
||||
return len(version) >= 7 && re.MatchString(version)
|
||||
}
|
||||
@@ -34,10 +35,10 @@ func IsVersionPinned(version string) bool {
|
||||
// ValidateGitBranch checks if a branch exists in the given repository.
|
||||
func ValidateGitBranch(repoRoot, branch string) bool {
|
||||
cmd := exec.Command(
|
||||
"git",
|
||||
"show-ref",
|
||||
"--verify",
|
||||
"--quiet",
|
||||
appconstants.GitCommand,
|
||||
appconstants.GitShowRef,
|
||||
appconstants.GitVerify,
|
||||
appconstants.GitQuiet,
|
||||
"refs/heads/"+branch,
|
||||
) // #nosec G204 -- branch name validated by git
|
||||
cmd.Dir = repoRoot
|
||||
@@ -54,7 +55,7 @@ func ValidateActionYMLPath(path string) error {
|
||||
|
||||
// Check if it's an action.yml or action.yaml file
|
||||
filename := filepath.Base(path)
|
||||
if filename != "action.yml" && filename != "action.yaml" {
|
||||
if filename != appconstants.ActionFileNameYML && filename != appconstants.ActionFileNameYAML {
|
||||
return os.ErrInvalid
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
@@ -21,10 +22,8 @@ func TestValidateActionYMLPath(t *testing.T) {
|
||||
name: "valid action.yml file",
|
||||
setupFunc: func(t *testing.T, tmpDir string) string {
|
||||
t.Helper()
|
||||
actionPath := filepath.Join(tmpDir, "action.yml")
|
||||
testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture("actions/javascript/simple.yml"))
|
||||
|
||||
return actionPath
|
||||
return testutil.WriteActionFixture(t, tmpDir, appconstants.TestFixtureJavaScriptSimple)
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
@@ -32,10 +31,8 @@ func TestValidateActionYMLPath(t *testing.T) {
|
||||
name: "valid action.yaml file",
|
||||
setupFunc: func(t *testing.T, tmpDir string) string {
|
||||
t.Helper()
|
||||
actionPath := filepath.Join(tmpDir, "action.yaml")
|
||||
testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture("minimal-action.yml"))
|
||||
|
||||
return actionPath
|
||||
return testutil.WriteActionFixtureAs(t, tmpDir, "action.yaml", appconstants.TestFixtureMinimalAction)
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
@@ -50,10 +47,8 @@ func TestValidateActionYMLPath(t *testing.T) {
|
||||
name: "file with wrong extension",
|
||||
setupFunc: func(t *testing.T, tmpDir string) string {
|
||||
t.Helper()
|
||||
actionPath := filepath.Join(tmpDir, "action.txt")
|
||||
testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture("actions/javascript/simple.yml"))
|
||||
|
||||
return actionPath
|
||||
return testutil.WriteActionFixtureAs(t, tmpDir, "action.txt", appconstants.TestFixtureJavaScriptSimple)
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
@@ -522,9 +517,7 @@ func TestGetBinaryDir(t *testing.T) {
|
||||
}
|
||||
|
||||
// Verify the directory exists
|
||||
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
||||
t.Errorf("binary directory does not exist: %s", dir)
|
||||
}
|
||||
testutil.AssertFileExists(t, dir)
|
||||
}
|
||||
|
||||
func TestEnsureAbsolutePath(t *testing.T) {
|
||||
|
||||
@@ -3,6 +3,8 @@ package internal
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
)
|
||||
|
||||
// ValidationResult holds the results of action.yml validation.
|
||||
@@ -18,18 +20,18 @@ func ValidateActionYML(action *ActionYML) ValidationResult {
|
||||
|
||||
// Validate required fields with helpful suggestions
|
||||
if action.Name == "" {
|
||||
result.MissingFields = append(result.MissingFields, "name")
|
||||
result.MissingFields = append(result.MissingFields, appconstants.FieldName)
|
||||
result.Suggestions = append(result.Suggestions, "Add 'name: Your Action Name' to describe your action")
|
||||
}
|
||||
if action.Description == "" {
|
||||
result.MissingFields = append(result.MissingFields, "description")
|
||||
result.MissingFields = append(result.MissingFields, appconstants.FieldDescription)
|
||||
result.Suggestions = append(
|
||||
result.Suggestions,
|
||||
"Add 'description: Brief description of what your action does' for better documentation",
|
||||
)
|
||||
}
|
||||
if len(action.Runs) == 0 {
|
||||
result.MissingFields = append(result.MissingFields, "runs")
|
||||
result.MissingFields = append(result.MissingFields, appconstants.FieldRuns)
|
||||
result.Suggestions = append(
|
||||
result.Suggestions,
|
||||
"Add 'runs:' section with 'using: node20' or 'using: docker' and specify the main file",
|
||||
@@ -38,14 +40,14 @@ func ValidateActionYML(action *ActionYML) ValidationResult {
|
||||
// Validate the runs section content
|
||||
if using, ok := action.Runs["using"].(string); ok {
|
||||
if !isValidRuntime(using) {
|
||||
result.MissingFields = append(result.MissingFields, "runs.using")
|
||||
result.MissingFields = append(result.MissingFields, appconstants.FieldRunsUsing)
|
||||
result.Suggestions = append(
|
||||
result.Suggestions,
|
||||
fmt.Sprintf("Invalid runtime '%s'. Valid runtimes: node12, node16, node20, docker, composite", using),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
result.MissingFields = append(result.MissingFields, "runs.using")
|
||||
result.MissingFields = append(result.MissingFields, appconstants.FieldRunsUsing)
|
||||
result.Suggestions = append(
|
||||
result.Suggestions,
|
||||
"Missing 'using' field in runs section. Specify 'using: node20', 'using: docker', or 'using: composite'",
|
||||
|
||||
122
internal/viper_helper.go
Normal file
122
internal/viper_helper.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/adrg/xdg"
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
)
|
||||
|
||||
// initializeViperInstance creates and configures a new viper instance with standard settings.
|
||||
// This includes XDG-compliant configuration paths, environment variable support,
|
||||
// and standard search paths for configuration files.
|
||||
func initializeViperInstance() (*viper.Viper, error) {
|
||||
v := viper.New()
|
||||
|
||||
// Set configuration file name and type
|
||||
v.SetConfigName(appconstants.ConfigFileName)
|
||||
v.SetConfigType(appconstants.OutputFormatYAML)
|
||||
|
||||
// Add XDG-compliant configuration directory
|
||||
configDir, err := xdg.ConfigFile(appconstants.PathXDGConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(appconstants.ErrFailedToGetXDGConfigDir, err)
|
||||
}
|
||||
v.AddConfigPath(filepath.Dir(configDir))
|
||||
|
||||
// Add additional search paths
|
||||
v.AddConfigPath(".") // current directory
|
||||
|
||||
// Expand home directory for fallback config path
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
v.AddConfigPath(filepath.Join(home, ".config", appconstants.AppName)) // fallback
|
||||
}
|
||||
|
||||
v.AddConfigPath(appconstants.PathEtcConfig) // system-wide
|
||||
|
||||
// Set environment variable prefix
|
||||
v.SetEnvPrefix(appconstants.EnvPrefix)
|
||||
v.SetEnvKeyReplacer(strings.NewReplacer("-", "_", ".", "_"))
|
||||
v.AutomaticEnv()
|
||||
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// setConfigDefaults sets all default configuration values in the viper instance.
|
||||
// This ensures consistent default values across all configuration loading scenarios.
|
||||
func setConfigDefaults(v *viper.Viper, defaults *AppConfig) {
|
||||
v.SetDefault(appconstants.ConfigKeyOrganization, defaults.Organization)
|
||||
v.SetDefault(appconstants.ConfigKeyRepository, defaults.Repository)
|
||||
v.SetDefault(appconstants.ConfigKeyVersion, defaults.Version)
|
||||
v.SetDefault(appconstants.ConfigKeyTheme, defaults.Theme)
|
||||
v.SetDefault(appconstants.ConfigKeyOutputFormat, defaults.OutputFormat)
|
||||
v.SetDefault(appconstants.ConfigKeyOutputDir, defaults.OutputDir)
|
||||
v.SetDefault(appconstants.ConfigKeyTemplate, defaults.Template)
|
||||
v.SetDefault(appconstants.ConfigKeyHeader, defaults.Header)
|
||||
v.SetDefault(appconstants.ConfigKeyFooter, defaults.Footer)
|
||||
v.SetDefault(appconstants.ConfigKeySchema, defaults.Schema)
|
||||
v.SetDefault(appconstants.ConfigKeyAnalyzeDependencies, defaults.AnalyzeDependencies)
|
||||
v.SetDefault(appconstants.ConfigKeyShowSecurityInfo, defaults.ShowSecurityInfo)
|
||||
v.SetDefault(appconstants.ConfigKeyVerbose, defaults.Verbose)
|
||||
v.SetDefault(appconstants.ConfigKeyQuiet, defaults.Quiet)
|
||||
v.SetDefault(appconstants.ConfigKeyDefaultsName, defaults.Defaults.Name)
|
||||
v.SetDefault(appconstants.ConfigKeyDefaultsDescription, defaults.Defaults.Description)
|
||||
v.SetDefault(appconstants.ConfigKeyDefaultsBrandingIcon, defaults.Defaults.Branding.Icon)
|
||||
v.SetDefault(appconstants.ConfigKeyDefaultsBrandingColor, defaults.Defaults.Branding.Color)
|
||||
}
|
||||
|
||||
// loadConfigFromViper loads an AppConfig from a specified YAML config file using viper.
|
||||
func loadConfigFromViper(configPath string) (*AppConfig, error) {
|
||||
v := viper.New()
|
||||
v.SetConfigFile(configPath)
|
||||
v.SetConfigType(appconstants.OutputFormatYAML)
|
||||
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
return nil, fmt.Errorf("failed to read config %s: %w", configPath, err)
|
||||
}
|
||||
|
||||
var config AppConfig
|
||||
if err := v.Unmarshal(&config); err != nil {
|
||||
return nil, fmt.Errorf(appconstants.ErrFailedToUnmarshalConfig, err)
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// loadAndUnmarshalConfig initializes viper with defaults, reads config file,
|
||||
// and unmarshals into AppConfig with proper error handling.
|
||||
// Returns *AppConfig with resolved template paths.
|
||||
func loadAndUnmarshalConfig(configFile string, v *viper.Viper) (*AppConfig, error) {
|
||||
// Set defaults
|
||||
defaults := DefaultAppConfig()
|
||||
setConfigDefaults(v, defaults)
|
||||
|
||||
// Use specific config file if provided
|
||||
if configFile != "" {
|
||||
v.SetConfigFile(configFile)
|
||||
}
|
||||
|
||||
// Read configuration
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
|
||||
return nil, fmt.Errorf(appconstants.ErrFailedToReadConfigFile, err)
|
||||
}
|
||||
// Config file not found is not an error - we'll use defaults and env vars
|
||||
}
|
||||
|
||||
// Unmarshal configuration into struct
|
||||
var config AppConfig
|
||||
if err := v.Unmarshal(&config); err != nil {
|
||||
return nil, fmt.Errorf(appconstants.ErrFailedToUnmarshalConfig, err)
|
||||
}
|
||||
|
||||
// Resolve template paths relative to binary if they're not absolute
|
||||
resolveAllTemplatePaths(&config)
|
||||
|
||||
return &config, nil
|
||||
}
|
||||
@@ -11,17 +11,12 @@ import (
|
||||
|
||||
"github.com/goccy/go-yaml"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/internal"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/git"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/helpers"
|
||||
)
|
||||
|
||||
const (
|
||||
// Language constants to avoid repetition.
|
||||
langJavaScriptTypeScript = "JavaScript/TypeScript"
|
||||
langGo = "Go"
|
||||
)
|
||||
|
||||
// ProjectDetector handles auto-detection of project settings.
|
||||
type ProjectDetector struct {
|
||||
output *internal.ColoredOutput
|
||||
@@ -33,7 +28,7 @@ type ProjectDetector struct {
|
||||
func NewProjectDetector(output *internal.ColoredOutput) (*ProjectDetector, error) {
|
||||
currentDir, err := helpers.GetCurrentDir()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get current directory: %w", err)
|
||||
return nil, fmt.Errorf(appconstants.ErrFailedToGetCurrentDir, err)
|
||||
}
|
||||
|
||||
return &ProjectDetector{
|
||||
@@ -172,7 +167,7 @@ func (d *ProjectDetector) detectVersion() string {
|
||||
|
||||
// detectVersionFromPackageJSON detects version from package.json.
|
||||
func (d *ProjectDetector) detectVersionFromPackageJSON() string {
|
||||
packageJSONPath := filepath.Join(d.currentDir, "package.json")
|
||||
packageJSONPath := filepath.Join(d.currentDir, appconstants.PackageJSON)
|
||||
data, err := os.ReadFile(packageJSONPath) // #nosec G304 -- path is constructed from current directory
|
||||
if err != nil {
|
||||
return ""
|
||||
@@ -264,7 +259,7 @@ func (d *ProjectDetector) handleDirectory(info os.FileInfo) error {
|
||||
func (d *ProjectDetector) findActionFilesInDirectory(dir string) ([]string, error) {
|
||||
var actionFiles []string
|
||||
|
||||
for _, filename := range []string{"action.yml", "action.yaml"} {
|
||||
for _, filename := range []string{appconstants.ActionFileNameYML, appconstants.ActionFileNameYAML} {
|
||||
actionPath := filepath.Join(dir, filename)
|
||||
if _, err := os.Stat(actionPath); err == nil {
|
||||
actionFiles = append(actionFiles, actionPath)
|
||||
@@ -276,7 +271,7 @@ func (d *ProjectDetector) findActionFilesInDirectory(dir string) ([]string, erro
|
||||
|
||||
// isActionFile checks if a filename is an action file.
|
||||
func (d *ProjectDetector) isActionFile(filename string) bool {
|
||||
return filename == "action.yml" || filename == "action.yaml"
|
||||
return filename == appconstants.ActionFileNameYML || filename == appconstants.ActionFileNameYAML
|
||||
}
|
||||
|
||||
// analyzeActionFile analyzes an action file to extract characteristics.
|
||||
@@ -315,7 +310,7 @@ func (d *ProjectDetector) analyzeRunsSection(action map[string]any, settings *De
|
||||
}
|
||||
|
||||
// Check if it's a composite action
|
||||
if using, ok := runs["using"].(string); ok && using == "composite" {
|
||||
if using, ok := runs["using"].(string); ok && using == appconstants.ActionTypeComposite {
|
||||
settings.HasCompositeAction = true
|
||||
}
|
||||
|
||||
@@ -377,17 +372,17 @@ func (d *ProjectDetector) analyzeProjectFiles() map[string]string {
|
||||
// detectLanguageFromFile detects programming language from filename.
|
||||
func (d *ProjectDetector) detectLanguageFromFile(filename string, characteristics map[string]string) {
|
||||
switch filename {
|
||||
case "package.json":
|
||||
characteristics["language"] = langJavaScriptTypeScript
|
||||
case appconstants.PackageJSON:
|
||||
characteristics["language"] = appconstants.LangJavaScriptTypeScript
|
||||
characteristics["type"] = "Node.js Project"
|
||||
case "go.mod":
|
||||
characteristics["language"] = langGo
|
||||
characteristics["language"] = appconstants.LangGo
|
||||
characteristics["type"] = "Go Module"
|
||||
case "Cargo.toml":
|
||||
characteristics["language"] = "Rust"
|
||||
characteristics["type"] = "Rust Project"
|
||||
case "pyproject.toml", "requirements.txt":
|
||||
characteristics["language"] = "Python"
|
||||
characteristics["language"] = appconstants.LangPython
|
||||
characteristics["type"] = "Python Project"
|
||||
case "Gemfile":
|
||||
characteristics["language"] = "Ruby"
|
||||
@@ -447,11 +442,11 @@ func (d *ProjectDetector) suggestTheme(settings *DetectedSettings) {
|
||||
case settings.HasCompositeAction:
|
||||
settings.SuggestedTheme = "professional"
|
||||
case settings.HasDockerfile:
|
||||
settings.SuggestedTheme = "github"
|
||||
case settings.Language == langGo:
|
||||
settings.SuggestedTheme = "minimal"
|
||||
settings.SuggestedTheme = appconstants.ThemeGitHub
|
||||
case settings.Language == appconstants.LangGo:
|
||||
settings.SuggestedTheme = appconstants.ThemeMinimal
|
||||
case settings.Framework != "":
|
||||
settings.SuggestedTheme = "github"
|
||||
settings.SuggestedTheme = appconstants.ThemeGitHub
|
||||
default:
|
||||
settings.SuggestedTheme = "default"
|
||||
}
|
||||
@@ -464,9 +459,9 @@ func (d *ProjectDetector) suggestRunsOn(settings *DetectedSettings) {
|
||||
}
|
||||
|
||||
switch settings.Language {
|
||||
case langJavaScriptTypeScript:
|
||||
case appconstants.LangJavaScriptTypeScript:
|
||||
settings.SuggestedRunsOn = []string{"ubuntu-latest", "windows-latest", "macos-latest"}
|
||||
case langGo, "Python":
|
||||
case appconstants.LangGo, appconstants.LangPython:
|
||||
settings.SuggestedRunsOn = []string{"ubuntu-latest"}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/goccy/go-yaml"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/internal"
|
||||
)
|
||||
|
||||
@@ -17,11 +18,11 @@ type ExportFormat string
|
||||
|
||||
const (
|
||||
// FormatYAML exports configuration as YAML.
|
||||
FormatYAML ExportFormat = "yaml"
|
||||
FormatYAML ExportFormat = appconstants.OutputFormatYAML
|
||||
// FormatJSON exports configuration as JSON.
|
||||
FormatJSON ExportFormat = "json"
|
||||
FormatJSON ExportFormat = appconstants.OutputFormatJSON
|
||||
// FormatTOML exports configuration as TOML.
|
||||
FormatTOML ExportFormat = "toml"
|
||||
FormatTOML ExportFormat = appconstants.OutputFormatTOML
|
||||
)
|
||||
|
||||
// ConfigExporter handles exporting configuration to various formats.
|
||||
@@ -39,7 +40,9 @@ func NewConfigExporter(output *internal.ColoredOutput) *ConfigExporter {
|
||||
// ExportConfig exports the configuration to the specified format and path.
|
||||
func (e *ConfigExporter) ExportConfig(config *internal.AppConfig, format ExportFormat, outputPath string) error {
|
||||
// Create output directory if it doesn't exist
|
||||
if err := os.MkdirAll(filepath.Dir(outputPath), 0750); err != nil { // #nosec G301 -- output directory permissions
|
||||
outputDir := filepath.Dir(outputPath)
|
||||
// #nosec G301 -- output directory permissions
|
||||
if err := os.MkdirAll(outputDir, appconstants.FilePermDir); err != nil {
|
||||
return fmt.Errorf("failed to create output directory: %w", err)
|
||||
}
|
||||
|
||||
@@ -71,7 +74,7 @@ func (e *ConfigExporter) GetDefaultOutputPath(format ExportFormat) (string, erro
|
||||
|
||||
switch format {
|
||||
case FormatYAML:
|
||||
return filepath.Join(dir, "config.yaml"), nil
|
||||
return filepath.Join(dir, appconstants.ConfigYAML), nil
|
||||
case FormatJSON:
|
||||
return filepath.Join(dir, "config.json"), nil
|
||||
case FormatTOML:
|
||||
@@ -97,14 +100,14 @@ func (e *ConfigExporter) exportYAML(config *internal.AppConfig, outputPath strin
|
||||
encoder := yaml.NewEncoder(file, yaml.Indent(2))
|
||||
|
||||
// Add header comment
|
||||
_, _ = file.WriteString("# gh-action-readme configuration file\n")
|
||||
_, _ = file.WriteString("# Generated by the interactive configuration wizard\n\n")
|
||||
_, _ = file.WriteString(appconstants.MsgConfigHeader)
|
||||
_, _ = file.WriteString(appconstants.MsgConfigWizardHeader)
|
||||
|
||||
if err := encoder.Encode(exportConfig); err != nil {
|
||||
return fmt.Errorf("failed to encode YAML: %w", err)
|
||||
}
|
||||
|
||||
e.output.Success("Configuration exported to: %s", outputPath)
|
||||
e.output.Success(appconstants.MsgConfigurationExportedTo, outputPath)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -129,7 +132,7 @@ func (e *ConfigExporter) exportJSON(config *internal.AppConfig, outputPath strin
|
||||
return fmt.Errorf("failed to encode JSON: %w", err)
|
||||
}
|
||||
|
||||
e.output.Success("Configuration exported to: %s", outputPath)
|
||||
e.output.Success(appconstants.MsgConfigurationExportedTo, outputPath)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -149,13 +152,13 @@ func (e *ConfigExporter) exportTOML(config *internal.AppConfig, outputPath strin
|
||||
}()
|
||||
|
||||
// Write TOML header
|
||||
_, _ = file.WriteString("# gh-action-readme configuration file\n")
|
||||
_, _ = file.WriteString("# Generated by the interactive configuration wizard\n\n")
|
||||
_, _ = file.WriteString(appconstants.MsgConfigHeader)
|
||||
_, _ = file.WriteString(appconstants.MsgConfigWizardHeader)
|
||||
|
||||
// Basic TOML export (simplified version)
|
||||
e.writeTOMLConfig(file, exportConfig)
|
||||
|
||||
e.output.Success("Configuration exported to: %s", outputPath)
|
||||
e.output.Success(appconstants.MsgConfigurationExportedTo, outputPath)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -270,7 +273,7 @@ func (e *ConfigExporter) writePermissionsSection(file *os.File, config *internal
|
||||
|
||||
_, _ = fmt.Fprintf(file, "\n[permissions]\n")
|
||||
for key, value := range config.Permissions {
|
||||
_, _ = fmt.Fprintf(file, "%s = %q\n", key, value)
|
||||
_, _ = fmt.Fprintf(file, appconstants.FormatEnvVar, key, value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,6 +285,6 @@ func (e *ConfigExporter) writeVariablesSection(file *os.File, config *internal.A
|
||||
|
||||
_, _ = fmt.Fprintf(file, "\n[variables]\n")
|
||||
for key, value := range config.Variables {
|
||||
_, _ = fmt.Fprintf(file, "%s = %q\n", key, value)
|
||||
_, _ = fmt.Fprintf(file, appconstants.FormatEnvVar, key, value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/goccy/go-yaml"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/internal"
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
func TestConfigExporter_ExportConfig(t *testing.T) {
|
||||
@@ -68,7 +69,7 @@ func testYAMLExport(exporter *ConfigExporter, config *internal.AppConfig) func(*
|
||||
t.Fatalf("ExportConfig() error = %v", err)
|
||||
}
|
||||
|
||||
verifyFileExists(t, outputPath)
|
||||
testutil.AssertFileExists(t, outputPath)
|
||||
verifyYAMLContent(t, outputPath, config)
|
||||
}
|
||||
}
|
||||
@@ -85,7 +86,7 @@ func testJSONExport(exporter *ConfigExporter, config *internal.AppConfig) func(*
|
||||
t.Fatalf("ExportConfig() error = %v", err)
|
||||
}
|
||||
|
||||
verifyFileExists(t, outputPath)
|
||||
testutil.AssertFileExists(t, outputPath)
|
||||
verifyJSONContent(t, outputPath, config)
|
||||
}
|
||||
}
|
||||
@@ -102,19 +103,11 @@ func testTOMLExport(exporter *ConfigExporter, config *internal.AppConfig) func(*
|
||||
t.Fatalf("ExportConfig() error = %v", err)
|
||||
}
|
||||
|
||||
verifyFileExists(t, outputPath)
|
||||
testutil.AssertFileExists(t, outputPath)
|
||||
verifyTOMLContent(t, outputPath)
|
||||
}
|
||||
}
|
||||
|
||||
// verifyFileExists checks that a file exists at the given path.
|
||||
func verifyFileExists(t *testing.T, outputPath string) {
|
||||
t.Helper()
|
||||
if _, err := os.Stat(outputPath); os.IsNotExist(err) {
|
||||
t.Fatal("Expected output file to exist")
|
||||
}
|
||||
}
|
||||
|
||||
// verifyYAMLContent verifies YAML content is valid and contains expected data.
|
||||
func verifyYAMLContent(t *testing.T, outputPath string, expected *internal.AppConfig) {
|
||||
t.Helper()
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/internal"
|
||||
)
|
||||
|
||||
@@ -88,11 +89,11 @@ func (v *ConfigValidator) ValidateField(fieldName, value string) *ValidationResu
|
||||
v.validateRepository(value, result)
|
||||
case "version":
|
||||
v.validateVersion(value, result)
|
||||
case "theme":
|
||||
case appconstants.ConfigKeyTheme:
|
||||
v.validateTheme(value, result)
|
||||
case "output_format":
|
||||
case appconstants.ConfigKeyOutputFormat:
|
||||
v.validateOutputFormat(value, result)
|
||||
case "output_dir":
|
||||
case appconstants.ConfigKeyOutputDir:
|
||||
v.validateOutputDir(value, result)
|
||||
case "github_token":
|
||||
v.validateGitHubToken(value, result)
|
||||
@@ -129,7 +130,7 @@ func (v *ConfigValidator) DisplayValidationResult(result *ValidationResult) {
|
||||
|
||||
// Display suggestions
|
||||
if len(result.Suggestions) > 0 {
|
||||
v.output.Info("\nSuggestions:")
|
||||
v.output.Info(appconstants.SectionSuggestions)
|
||||
for _, suggestion := range result.Suggestions {
|
||||
v.output.Printf(" 💡 %s", suggestion)
|
||||
}
|
||||
@@ -485,8 +486,8 @@ func (v *ConfigValidator) isValidGitHubToken(token string) bool {
|
||||
// GitHub personal access tokens start with ghp_ or github_pat_
|
||||
// Classic tokens are 40 characters after the prefix
|
||||
// Fine-grained tokens have different formats
|
||||
return strings.HasPrefix(token, "ghp_") ||
|
||||
strings.HasPrefix(token, "github_pat_") ||
|
||||
return strings.HasPrefix(token, appconstants.TokenPrefixGitHubPersonal) ||
|
||||
strings.HasPrefix(token, appconstants.TokenPrefixGitHubPAT) ||
|
||||
strings.HasPrefix(token, "gho_") ||
|
||||
strings.HasPrefix(token, "ghu_") ||
|
||||
strings.HasPrefix(token, "ghs_") ||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/internal"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/git"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/helpers"
|
||||
@@ -72,7 +73,7 @@ func (w *ConfigWizard) detectProjectSettings() error {
|
||||
// Detect current directory
|
||||
currentDir, err := helpers.GetCurrentDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current directory: %w", err)
|
||||
return fmt.Errorf(appconstants.ErrFailedToGetCurrentDir, err)
|
||||
}
|
||||
|
||||
w.actionDir = currentDir
|
||||
@@ -180,7 +181,7 @@ func (w *ConfigWizard) displayThemeOptions(themes []struct {
|
||||
for i, theme := range themes {
|
||||
marker := " "
|
||||
if theme.name == w.config.Theme {
|
||||
marker = "►"
|
||||
marker = appconstants.SymbolArrow
|
||||
}
|
||||
w.output.Printf(" %s %d. %s - %s", marker, i+1, theme.name, theme.desc)
|
||||
}
|
||||
@@ -191,7 +192,7 @@ func (w *ConfigWizard) displayFormatOptions(formats []string) {
|
||||
for i, format := range formats {
|
||||
marker := " "
|
||||
if format == w.config.OutputFormat {
|
||||
marker = "►"
|
||||
marker = appconstants.SymbolArrow
|
||||
}
|
||||
w.output.Printf(" %s %d. %s", marker, i+1, format)
|
||||
}
|
||||
@@ -247,7 +248,9 @@ func (w *ConfigWizard) configureGitHubIntegration() {
|
||||
token := w.promptSensitive("Enter your GitHub token (or press Enter to skip)")
|
||||
if token != "" {
|
||||
// Validate token format (basic check)
|
||||
if strings.HasPrefix(token, "ghp_") || strings.HasPrefix(token, "github_pat_") {
|
||||
hasPersonalPrefix := strings.HasPrefix(token, appconstants.TokenPrefixGitHubPersonal)
|
||||
hasPATPrefix := strings.HasPrefix(token, appconstants.TokenPrefixGitHubPAT)
|
||||
if hasPersonalPrefix || hasPATPrefix {
|
||||
w.config.GitHubToken = token
|
||||
w.output.Success("GitHub token configured ✓")
|
||||
} else {
|
||||
@@ -297,9 +300,9 @@ func (w *ConfigWizard) confirmConfiguration() error {
|
||||
// promptWithDefault prompts for input with a default value.
|
||||
func (w *ConfigWizard) promptWithDefault(prompt, defaultValue string) string {
|
||||
if defaultValue != "" {
|
||||
w.output.Printf("%s [%s]: ", prompt, defaultValue)
|
||||
w.output.Printf(appconstants.FormatPromptDefault, prompt, defaultValue)
|
||||
} else {
|
||||
w.output.Printf("%s: ", prompt)
|
||||
w.output.Printf(appconstants.FormatPrompt, prompt)
|
||||
}
|
||||
|
||||
if w.scanner.Scan() {
|
||||
@@ -316,7 +319,7 @@ func (w *ConfigWizard) promptWithDefault(prompt, defaultValue string) string {
|
||||
|
||||
// promptSensitive prompts for sensitive input (like tokens) without echoing.
|
||||
func (w *ConfigWizard) promptSensitive(prompt string) string {
|
||||
w.output.Printf("%s: ", prompt)
|
||||
w.output.Printf(appconstants.FormatPrompt, prompt)
|
||||
if w.scanner.Scan() {
|
||||
return strings.TrimSpace(w.scanner.Text())
|
||||
}
|
||||
@@ -331,12 +334,12 @@ func (w *ConfigWizard) promptYesNo(prompt string, defaultValue bool) bool {
|
||||
defaultStr = "Y/n"
|
||||
}
|
||||
|
||||
w.output.Printf("%s [%s]: ", prompt, defaultStr)
|
||||
w.output.Printf(appconstants.FormatPromptDefault, prompt, defaultStr)
|
||||
|
||||
if w.scanner.Scan() {
|
||||
input := strings.ToLower(strings.TrimSpace(w.scanner.Text()))
|
||||
switch input {
|
||||
case "y", "yes":
|
||||
case "y", appconstants.InputYes:
|
||||
return true
|
||||
case "n", "no":
|
||||
return false
|
||||
|
||||
120
main.go
120
main.go
@@ -12,21 +12,15 @@ import (
|
||||
"github.com/schollz/progressbar/v3"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/internal"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/apperrors"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/cache"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/dependencies"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/errors"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/helpers"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/wizard"
|
||||
)
|
||||
|
||||
const (
|
||||
// Export format constants.
|
||||
formatJSON = "json"
|
||||
formatTOML = "toml"
|
||||
formatYAML = "yaml"
|
||||
)
|
||||
|
||||
var (
|
||||
// Version information (set by GoReleaser).
|
||||
version = "dev"
|
||||
@@ -69,9 +63,9 @@ func formatSize(totalSize int64) string {
|
||||
// resolveExportFormat converts a format string to wizard.ExportFormat.
|
||||
func resolveExportFormat(format string) wizard.ExportFormat {
|
||||
switch format {
|
||||
case formatJSON:
|
||||
case appconstants.OutputFormatJSON:
|
||||
return wizard.FormatJSON
|
||||
case formatTOML:
|
||||
case appconstants.OutputFormatTOML:
|
||||
return wizard.FormatTOML
|
||||
default:
|
||||
return wizard.FormatYAML
|
||||
@@ -105,9 +99,11 @@ func main() {
|
||||
}
|
||||
|
||||
// Global flags
|
||||
rootCmd.PersistentFlags().StringVar(&configFile, "config", "", "config file (default: XDG config directory)")
|
||||
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output")
|
||||
rootCmd.PersistentFlags().BoolVarP(&quiet, "quiet", "q", false, "quiet output (overrides verbose)")
|
||||
configDesc := "config file (default: XDG config directory)"
|
||||
rootCmd.PersistentFlags().StringVar(&configFile, appconstants.ContextKeyConfig, "", configDesc)
|
||||
rootCmd.PersistentFlags().BoolVarP(&verbose, appconstants.ConfigKeyVerbose, "v", false, "verbose output")
|
||||
quietDesc := "quiet output (overrides verbose)"
|
||||
rootCmd.PersistentFlags().BoolVarP(&quiet, appconstants.ConfigKeyQuiet, "q", false, quietDesc)
|
||||
|
||||
rootCmd.AddCommand(newGenCmd())
|
||||
rootCmd.AddCommand(newValidateCmd())
|
||||
@@ -117,7 +113,7 @@ func main() {
|
||||
Short: "Print the version number",
|
||||
Long: "Print the version number and build information",
|
||||
Run: func(cmd *cobra.Command, _ []string) {
|
||||
verbose, _ := cmd.Flags().GetBool("verbose")
|
||||
verbose, _ := cmd.Flags().GetBool(appconstants.ConfigKeyVerbose)
|
||||
if verbose {
|
||||
fmt.Printf("gh-action-readme version %s\n", version)
|
||||
fmt.Printf(" commit: %s\n", commit)
|
||||
@@ -182,11 +178,11 @@ Examples:
|
||||
Run: genHandler,
|
||||
}
|
||||
|
||||
cmd.Flags().StringP("output-format", "f", "md", "output format: md, html, json, asciidoc")
|
||||
cmd.Flags().StringP("output-dir", "o", ".", "output directory")
|
||||
cmd.Flags().StringP("output", "", "", "custom output filename (overrides default naming)")
|
||||
cmd.Flags().StringP("theme", "t", "", "template theme: github, gitlab, minimal, professional")
|
||||
cmd.Flags().BoolP("recursive", "r", false, "search for action.yml files recursively")
|
||||
cmd.Flags().StringP(appconstants.FlagOutputFormat, "f", "md", "output format: md, html, json, asciidoc")
|
||||
cmd.Flags().StringP(appconstants.FlagOutputDir, "o", ".", "output directory")
|
||||
cmd.Flags().StringP(appconstants.FlagOutput, "", "", "custom output filename (overrides default naming)")
|
||||
cmd.Flags().StringP(appconstants.ConfigKeyTheme, "t", "", "template theme: github, gitlab, minimal, professional")
|
||||
cmd.Flags().BoolP(appconstants.FlagRecursive, "r", false, "search for action.yml files recursively")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -218,7 +214,7 @@ func genHandler(cmd *cobra.Command, args []string) {
|
||||
var err error
|
||||
targetPath, err = helpers.GetCurrentDir()
|
||||
if err != nil {
|
||||
output.Error("Error getting current directory: %v", err)
|
||||
output.Error(appconstants.ErrErrorGettingCurrentDir, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -244,7 +240,7 @@ func genHandler(cmd *cobra.Command, args []string) {
|
||||
// Target is a directory
|
||||
workingDir = absTargetPath
|
||||
generator := internal.NewGenerator(globalConfig) // Temporary generator for discovery
|
||||
recursive, _ := cmd.Flags().GetBool("recursive")
|
||||
recursive, _ := cmd.Flags().GetBool(appconstants.FlagRecursive)
|
||||
actionFiles, err = generator.DiscoverActionFilesWithValidation(
|
||||
workingDir,
|
||||
recursive,
|
||||
@@ -306,10 +302,10 @@ func applyGlobalFlags(config *internal.AppConfig) {
|
||||
|
||||
// applyCommandFlags applies command-specific flags.
|
||||
func applyCommandFlags(cmd *cobra.Command, config *internal.AppConfig) {
|
||||
outputFormat, _ := cmd.Flags().GetString("output-format")
|
||||
outputDir, _ := cmd.Flags().GetString("output-dir")
|
||||
outputFilename, _ := cmd.Flags().GetString("output")
|
||||
theme, _ := cmd.Flags().GetString("theme")
|
||||
outputFormat, _ := cmd.Flags().GetString(appconstants.FlagOutputFormat)
|
||||
outputDir, _ := cmd.Flags().GetString(appconstants.FlagOutputDir)
|
||||
outputFilename, _ := cmd.Flags().GetString(appconstants.FlagOutput)
|
||||
theme, _ := cmd.Flags().GetString(appconstants.ConfigKeyTheme)
|
||||
|
||||
if outputFormat != "md" {
|
||||
config.OutputFormat = outputFormat
|
||||
@@ -363,11 +359,11 @@ func validateHandler(_ *cobra.Command, _ []string) {
|
||||
// Validate the discovered files
|
||||
if err := generator.ValidateFiles(actionFiles); err != nil {
|
||||
generator.Output.ErrorWithContext(
|
||||
errors.ErrCodeValidation,
|
||||
appconstants.ErrCodeValidation,
|
||||
"validation failed",
|
||||
map[string]string{
|
||||
"files_count": strconv.Itoa(len(actionFiles)),
|
||||
internal.ContextKeyError: err.Error(),
|
||||
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)
|
||||
|
||||
255
main_test.go
255
main_test.go
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/internal"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/wizard"
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
@@ -51,8 +52,7 @@ func TestCLICommands(t *testing.T) {
|
||||
args: []string{"gen", "--output-format", "md"},
|
||||
setupFunc: func(t *testing.T, tmpDir string) {
|
||||
t.Helper()
|
||||
actionPath := filepath.Join(tmpDir, "action.yml")
|
||||
testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture("actions/javascript/simple.yml"))
|
||||
createTestActionFile(t, tmpDir, appconstants.TestFixtureJavaScriptSimple)
|
||||
},
|
||||
wantExit: 0,
|
||||
},
|
||||
@@ -61,8 +61,7 @@ func TestCLICommands(t *testing.T) {
|
||||
args: []string{"gen", "--theme", "github", "--output-format", "json"},
|
||||
setupFunc: func(t *testing.T, tmpDir string) {
|
||||
t.Helper()
|
||||
actionPath := filepath.Join(tmpDir, "action.yml")
|
||||
testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture("actions/javascript/simple.yml"))
|
||||
createTestActionFile(t, tmpDir, appconstants.TestFixtureJavaScriptSimple)
|
||||
},
|
||||
wantExit: 0,
|
||||
},
|
||||
@@ -77,8 +76,7 @@ func TestCLICommands(t *testing.T) {
|
||||
args: []string{"validate"},
|
||||
setupFunc: func(t *testing.T, tmpDir string) {
|
||||
t.Helper()
|
||||
actionPath := filepath.Join(tmpDir, "action.yml")
|
||||
testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture("actions/javascript/simple.yml"))
|
||||
createTestActionFile(t, tmpDir, appconstants.TestFixtureJavaScriptSimple)
|
||||
},
|
||||
wantExit: 0,
|
||||
wantStdout: "All validations passed successfully",
|
||||
@@ -88,12 +86,7 @@ func TestCLICommands(t *testing.T) {
|
||||
args: []string{"validate"},
|
||||
setupFunc: func(t *testing.T, tmpDir string) {
|
||||
t.Helper()
|
||||
actionPath := filepath.Join(tmpDir, "action.yml")
|
||||
testutil.WriteTestFile(
|
||||
t,
|
||||
actionPath,
|
||||
testutil.MustReadFixture("actions/invalid/missing-description.yml"),
|
||||
)
|
||||
createTestActionFile(t, tmpDir, appconstants.TestFixtureInvalidMissingDescription)
|
||||
},
|
||||
wantExit: 1,
|
||||
},
|
||||
@@ -132,8 +125,8 @@ func TestCLICommands(t *testing.T) {
|
||||
args: []string{"deps", "list"},
|
||||
setupFunc: func(t *testing.T, tmpDir string) {
|
||||
t.Helper()
|
||||
actionPath := filepath.Join(tmpDir, "action.yml")
|
||||
testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture("actions/composite/basic.yml"))
|
||||
actionPath := filepath.Join(tmpDir, appconstants.TestPathActionYML)
|
||||
testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture(appconstants.TestFixtureCompositeBasic))
|
||||
},
|
||||
wantExit: 0,
|
||||
},
|
||||
@@ -169,44 +162,8 @@ func TestCLICommands(t *testing.T) {
|
||||
}
|
||||
|
||||
// Run the command in the temporary directory
|
||||
cmd := exec.Command(binaryPath, tt.args...) // #nosec G204 -- controlled test input
|
||||
cmd.Dir = tmpDir
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
err := cmd.Run()
|
||||
|
||||
// Check exit code
|
||||
exitCode := 0
|
||||
if err != nil {
|
||||
if exitError, ok := err.(*exec.ExitError); ok {
|
||||
exitCode = exitError.ExitCode()
|
||||
} else {
|
||||
t.Fatalf("unexpected error running command: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if exitCode != tt.wantExit {
|
||||
t.Errorf("expected exit code %d, got %d", tt.wantExit, exitCode)
|
||||
t.Logf("stdout: %s", stdout.String())
|
||||
t.Logf("stderr: %s", stderr.String())
|
||||
}
|
||||
|
||||
// Check stdout if specified
|
||||
if tt.wantStdout != "" {
|
||||
if !strings.Contains(stdout.String(), tt.wantStdout) {
|
||||
t.Errorf("expected stdout to contain %q, got: %s", tt.wantStdout, stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Check stderr if specified
|
||||
if tt.wantStderr != "" {
|
||||
if !strings.Contains(stderr.String(), tt.wantStderr) {
|
||||
t.Errorf("expected stderr to contain %q, got: %s", tt.wantStderr, stderr.String())
|
||||
}
|
||||
}
|
||||
result := runTestCommand(binaryPath, tt.args, tmpDir)
|
||||
assertCommandResult(t, result, tt.wantExit, tt.wantStdout, tt.wantStderr)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -257,32 +214,17 @@ func TestCLIFlags(t *testing.T) {
|
||||
tmpDir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
cmd := exec.Command(binaryPath, tt.args...) // #nosec G204 -- controlled test input
|
||||
cmd.Dir = tmpDir
|
||||
result := runTestCommand(binaryPath, tt.args, tmpDir)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
err := cmd.Run()
|
||||
exitCode := 0
|
||||
if err != nil {
|
||||
if exitError, ok := err.(*exec.ExitError); ok {
|
||||
exitCode = exitError.ExitCode()
|
||||
}
|
||||
}
|
||||
|
||||
if exitCode != tt.wantExit {
|
||||
t.Errorf("expected exit code %d, got %d", tt.wantExit, exitCode)
|
||||
t.Logf("stdout: %s", stdout.String())
|
||||
t.Logf("stderr: %s", stderr.String())
|
||||
if result.exitCode != tt.wantExit {
|
||||
t.Errorf(appconstants.TestMsgExitCode, tt.wantExit, result.exitCode)
|
||||
t.Logf(appconstants.TestMsgStdout, result.stdout)
|
||||
t.Logf(appconstants.TestMsgStderr, result.stderr)
|
||||
}
|
||||
|
||||
if tt.contains != "" {
|
||||
output := stdout.String() + stderr.String()
|
||||
if !strings.Contains(output, tt.contains) {
|
||||
t.Errorf("expected output to contain %q, got: %s", tt.contains, output)
|
||||
}
|
||||
// For contains check, look in both stdout and stderr
|
||||
assertCommandResult(t, result, tt.wantExit, tt.contains, "")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -297,14 +239,8 @@ func TestCLIRecursiveFlag(t *testing.T) {
|
||||
defer cleanup()
|
||||
|
||||
// Create nested directory structure with action files
|
||||
subDir := filepath.Join(tmpDir, "subdir")
|
||||
_ = os.MkdirAll(subDir, 0750) // #nosec G301 -- test directory permissions
|
||||
|
||||
// Write action files
|
||||
testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"),
|
||||
testutil.MustReadFixture("actions/javascript/simple.yml"))
|
||||
testutil.WriteTestFile(t, filepath.Join(subDir, "action.yml"),
|
||||
testutil.MustReadFixture("actions/composite/basic.yml"))
|
||||
testutil.WriteActionFixture(t, tmpDir, appconstants.TestFixtureJavaScriptSimple)
|
||||
testutil.CreateActionSubdir(t, tmpDir, appconstants.TestDirSubdir, appconstants.TestFixtureCompositeBasic)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -328,31 +264,12 @@ func TestCLIRecursiveFlag(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := exec.Command(binaryPath, tt.args...) // #nosec G204 -- controlled test input
|
||||
cmd.Dir = tmpDir
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
err := cmd.Run()
|
||||
exitCode := 0
|
||||
if err != nil {
|
||||
if exitError, ok := err.(*exec.ExitError); ok {
|
||||
exitCode = exitError.ExitCode()
|
||||
}
|
||||
}
|
||||
|
||||
if exitCode != tt.wantExit {
|
||||
t.Errorf("expected exit code %d, got %d", tt.wantExit, exitCode)
|
||||
t.Logf("stdout: %s", stdout.String())
|
||||
t.Logf("stderr: %s", stderr.String())
|
||||
}
|
||||
result := runTestCommand(binaryPath, tt.args, tmpDir)
|
||||
assertCommandResult(t, result, tt.wantExit, "", "")
|
||||
|
||||
// For recursive tests, check that appropriate number of files were processed
|
||||
// This is a simple heuristic - could be made more sophisticated
|
||||
output := stdout.String()
|
||||
if tt.minFiles > 1 && !strings.Contains(output, "subdir") {
|
||||
if tt.minFiles > 1 && !strings.Contains(result.stdout, appconstants.TestDirSubdir) {
|
||||
t.Errorf("expected recursive processing to include subdirectory")
|
||||
}
|
||||
})
|
||||
@@ -376,8 +293,7 @@ func TestCLIErrorHandling(t *testing.T) {
|
||||
args: []string{"gen", "--output-dir", "/root/restricted"},
|
||||
setupFunc: func(t *testing.T, tmpDir string) {
|
||||
t.Helper()
|
||||
testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"),
|
||||
testutil.MustReadFixture("actions/javascript/simple.yml"))
|
||||
createTestActionFile(t, tmpDir, appconstants.TestFixtureJavaScriptSimple)
|
||||
},
|
||||
wantExit: 1,
|
||||
wantError: "encountered 1 errors during batch processing",
|
||||
@@ -387,7 +303,11 @@ func TestCLIErrorHandling(t *testing.T) {
|
||||
args: []string{"validate"},
|
||||
setupFunc: func(t *testing.T, tmpDir string) {
|
||||
t.Helper()
|
||||
testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), "invalid: yaml: content: [")
|
||||
testutil.WriteTestFile(
|
||||
t,
|
||||
filepath.Join(tmpDir, appconstants.TestPathActionYML),
|
||||
"invalid: yaml: content: [",
|
||||
)
|
||||
},
|
||||
wantExit: 1,
|
||||
},
|
||||
@@ -396,8 +316,7 @@ func TestCLIErrorHandling(t *testing.T) {
|
||||
args: []string{"gen", "--output-format", "unknown"},
|
||||
setupFunc: func(t *testing.T, tmpDir string) {
|
||||
t.Helper()
|
||||
testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"),
|
||||
testutil.MustReadFixture("actions/javascript/simple.yml"))
|
||||
createTestActionFile(t, tmpDir, appconstants.TestFixtureJavaScriptSimple)
|
||||
},
|
||||
wantExit: 1,
|
||||
},
|
||||
@@ -406,8 +325,7 @@ func TestCLIErrorHandling(t *testing.T) {
|
||||
args: []string{"gen", "--theme", "nonexistent-theme"},
|
||||
setupFunc: func(t *testing.T, tmpDir string) {
|
||||
t.Helper()
|
||||
testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"),
|
||||
testutil.MustReadFixture("actions/javascript/simple.yml"))
|
||||
createTestActionFile(t, tmpDir, appconstants.TestFixtureJavaScriptSimple)
|
||||
},
|
||||
wantExit: 1,
|
||||
},
|
||||
@@ -422,29 +340,16 @@ func TestCLIErrorHandling(t *testing.T) {
|
||||
tt.setupFunc(t, tmpDir)
|
||||
}
|
||||
|
||||
cmd := exec.Command(binaryPath, tt.args...) // #nosec G204 -- controlled test input
|
||||
cmd.Dir = tmpDir
|
||||
result := runTestCommand(binaryPath, tt.args, tmpDir)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
err := cmd.Run()
|
||||
exitCode := 0
|
||||
if err != nil {
|
||||
if exitError, ok := err.(*exec.ExitError); ok {
|
||||
exitCode = exitError.ExitCode()
|
||||
}
|
||||
}
|
||||
|
||||
if exitCode != tt.wantExit {
|
||||
t.Errorf("expected exit code %d, got %d", tt.wantExit, exitCode)
|
||||
t.Logf("stdout: %s", stdout.String())
|
||||
t.Logf("stderr: %s", stderr.String())
|
||||
if result.exitCode != tt.wantExit {
|
||||
t.Errorf(appconstants.TestMsgExitCode, tt.wantExit, result.exitCode)
|
||||
t.Logf(appconstants.TestMsgStdout, result.stdout)
|
||||
t.Logf(appconstants.TestMsgStderr, result.stderr)
|
||||
}
|
||||
|
||||
if tt.wantError != "" {
|
||||
output := stdout.String() + stderr.String()
|
||||
output := result.stdout + result.stderr
|
||||
if !strings.Contains(strings.ToLower(output), strings.ToLower(tt.wantError)) {
|
||||
t.Errorf("expected error containing %q, got: %s", tt.wantError, output)
|
||||
}
|
||||
@@ -481,23 +386,7 @@ func TestCLIConfigInitialization(t *testing.T) {
|
||||
|
||||
// Check if config file was created (note: uses .yaml extension, not .yml)
|
||||
expectedConfigPath := filepath.Join(tmpDir, "gh-action-readme", "config.yaml")
|
||||
if _, err := os.Stat(expectedConfigPath); os.IsNotExist(err) {
|
||||
t.Errorf("config file was not created at expected path: %s", expectedConfigPath)
|
||||
// List what was actually created to help debug
|
||||
if entries, err := os.ReadDir(tmpDir); err == nil {
|
||||
t.Logf("Contents of tmpDir %s:", tmpDir)
|
||||
for _, entry := range entries {
|
||||
t.Logf(" %s", entry.Name())
|
||||
if entry.IsDir() {
|
||||
if subEntries, err := os.ReadDir(filepath.Join(tmpDir, entry.Name())); err == nil {
|
||||
for _, sub := range subEntries {
|
||||
t.Logf(" %s", sub.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
testutil.AssertFileExists(t, expectedConfigPath)
|
||||
}
|
||||
|
||||
// Unit Tests for Helper Functions
|
||||
@@ -557,9 +446,9 @@ func TestResolveExportFormat(t *testing.T) {
|
||||
format string
|
||||
expected wizard.ExportFormat
|
||||
}{
|
||||
{"json format", formatJSON, wizard.FormatJSON},
|
||||
{"toml format", formatTOML, wizard.FormatTOML},
|
||||
{"yaml format", formatYAML, wizard.FormatYAML},
|
||||
{"json format", appconstants.OutputFormatJSON, wizard.FormatJSON},
|
||||
{"toml format", appconstants.OutputFormatTOML, wizard.FormatTOML},
|
||||
{"yaml format", appconstants.OutputFormatYAML, wizard.FormatYAML},
|
||||
{"default format", "unknown", wizard.FormatYAML},
|
||||
{"empty format", "", wizard.FormatYAML},
|
||||
}
|
||||
@@ -662,3 +551,69 @@ func TestNewSchemaCmd(t *testing.T) {
|
||||
t.Error("expected command to have a Run or RunE function")
|
||||
}
|
||||
}
|
||||
|
||||
// cmdResult holds the results of a command execution.
|
||||
type cmdResult struct {
|
||||
stdout string
|
||||
stderr string
|
||||
exitCode int
|
||||
}
|
||||
|
||||
// runTestCommand executes a command with the given args in the specified directory.
|
||||
// It returns the stdout, stderr, and exit code.
|
||||
func runTestCommand(binaryPath string, args []string, dir string) cmdResult {
|
||||
cmd := exec.Command(binaryPath, args...) // #nosec G204 -- controlled test input
|
||||
cmd.Dir = dir
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
err := cmd.Run()
|
||||
exitCode := 0
|
||||
if err != nil {
|
||||
if exitError, ok := err.(*exec.ExitError); ok {
|
||||
exitCode = exitError.ExitCode()
|
||||
}
|
||||
}
|
||||
|
||||
return cmdResult{
|
||||
stdout: stdout.String(),
|
||||
stderr: stderr.String(),
|
||||
exitCode: exitCode,
|
||||
}
|
||||
}
|
||||
|
||||
// createTestActionFile is a helper that creates a test action file from a fixture.
|
||||
// It writes the specified fixture to action.yml in the given temporary directory.
|
||||
func createTestActionFile(t *testing.T, tmpDir, fixture string) {
|
||||
t.Helper()
|
||||
actionPath := filepath.Join(tmpDir, appconstants.TestPathActionYML)
|
||||
testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture(fixture))
|
||||
}
|
||||
|
||||
// assertCommandResult is a helper that asserts the result of a command execution.
|
||||
// It checks the exit code, and optionally checks for expected content in stdout and stderr.
|
||||
func assertCommandResult(t *testing.T, result cmdResult, wantExit int, wantStdout, wantStderr string) {
|
||||
t.Helper()
|
||||
|
||||
if result.exitCode != wantExit {
|
||||
t.Errorf(appconstants.TestMsgExitCode, wantExit, result.exitCode)
|
||||
t.Logf(appconstants.TestMsgStdout, result.stdout)
|
||||
t.Logf(appconstants.TestMsgStderr, result.stderr)
|
||||
}
|
||||
|
||||
// Check stdout if specified
|
||||
if wantStdout != "" {
|
||||
if !strings.Contains(result.stdout, wantStdout) {
|
||||
t.Errorf("expected stdout to contain %q, got: %s", wantStdout, result.stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// Check stderr if specified
|
||||
if wantStderr != "" {
|
||||
if !strings.Contains(result.stderr, wantStderr) {
|
||||
t.Errorf("expected stderr to contain %q, got: %s", wantStderr, result.stderr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
22
testdata/composite-action/action.yml
vendored
22
testdata/composite-action/action.yml
vendored
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
ExpectPanic(t, func() {
|
||||
mustReadFixture("nonexistent-file.yml")
|
||||
}, "failed to read fixture")
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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() {}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user