mirror of
https://github.com/ivuorinen/gh-action-readme.git
synced 2026-03-18 21:02:19 +00:00
Compare commits
86 Commits
v0.1.0
...
6c2ed2a896
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c2ed2a896 | ||
|
|
9f145dedfe | ||
|
|
78481459f5 | ||
|
|
9e25e0925f | ||
|
|
9c7be8c5d4 | ||
|
|
a75d892747 | ||
| 00044ce374 | |||
|
|
c6426bae19 | ||
|
|
7078aaba50 | ||
| 9bfecc5e6e | |||
| 6291710906 | |||
|
|
fa1ae15a4e | ||
|
|
bc021ab33d | ||
|
|
49faa8f113 | ||
|
|
0333bff9cb | ||
|
|
7ee76d0504 | ||
|
|
db19753586 | ||
|
|
2f6d19a3fc | ||
| ce23f93b74 | |||
| 9534bf9e45 | |||
| 93294f6fd3 | |||
| 5d671a9dc0 | |||
| 253e14a37b | |||
| 0d542555c5 | |||
| 4a656b21ae | |||
|
|
6a47d067c7 | ||
| 7f80105ff5 | |||
|
|
85a439d804 | ||
|
|
49b7a86094 | ||
|
|
4e94ff2fe2 | ||
|
|
792c50a451 | ||
|
|
78af0c4ab6 | ||
|
|
d49cc835bd | ||
|
|
ab6327a9d6 | ||
|
|
feafdd1f91 | ||
|
|
eda8ad9ea5 | ||
|
|
837075823d | ||
|
|
a1ead5d128 | ||
|
|
612770290c | ||
|
|
30b446a325 | ||
|
|
0a2d96e7ca | ||
|
|
671e145189 | ||
|
|
f122e4a7d1 | ||
|
|
0d0474e6c4 | ||
|
|
747bef3aa5 | ||
|
|
9dabb1d23e | ||
|
|
403fa49555 | ||
|
|
7003244e79 | ||
|
|
573349a188 | ||
|
|
348738455c | ||
|
|
7ddd69cec6 | ||
|
|
b6f899d965 | ||
|
|
5cdf8cebba | ||
|
|
f920987792 | ||
|
|
c6c6d343b8 | ||
|
|
86a2009a0c | ||
|
|
63e45153a7 | ||
|
|
2eb7e32173 | ||
|
|
d09c7918cb | ||
|
|
f4222fb6f3 | ||
|
|
728b306b86 | ||
|
|
5a3a48daa9 | ||
|
|
b606c0d403 | ||
|
|
057c356c1e | ||
|
|
aa3fdd9222 | ||
|
|
b5b5da25be | ||
|
|
d18ed12bb2 | ||
|
|
90ba1ffd7a | ||
|
|
365ad47daf | ||
|
|
7104a5c430 | ||
|
|
7649227e0a | ||
|
|
fee65ecca6 | ||
|
|
608916142a | ||
|
|
5dc72764f0 | ||
|
|
11574c6fee | ||
|
|
223e2bc3ae | ||
|
|
4aefb4f4e5 | ||
|
|
6ccdd1813d | ||
|
|
f5b7f96173 | ||
|
|
ad4c6eaddd | ||
|
|
feb41ddbcb | ||
|
|
c433951246 | ||
|
|
25ec0b634f | ||
|
|
cea1c2f246 | ||
|
|
dc6ce2b897 | ||
|
|
d19c49bd48 |
55
.commitlintrc.json
Normal file
55
.commitlintrc.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -34,3 +34,7 @@ tab_width = 2
|
||||
|
||||
[{go.sum,go.mod}]
|
||||
max_line_length = 300
|
||||
|
||||
# Test fixture that intentionally contains trailing whitespace
|
||||
[testdata/yaml-fixtures/configs/permissions/mutation/whitespace-only-value-not-parsed.yaml]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
42
.github/workflows/ci.yml
vendored
42
.github/workflows/ci.yml
vendored
@@ -9,21 +9,27 @@ jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6
|
||||
- name: Install dependencies
|
||||
run: go mod tidy
|
||||
- name: Setup Node.js for EditorConfig tools
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
node-version: '22'
|
||||
fetch-depth: 0
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
|
||||
- name: Run golangci-lint
|
||||
uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0
|
||||
with:
|
||||
version: v2.7.2
|
||||
- name: Setup Node.js for EditorConfig tools
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: "24"
|
||||
- name: Install EditorConfig tools
|
||||
run: npm install -g eclint
|
||||
- name: Check EditorConfig compliance
|
||||
run: eclint check .
|
||||
- name: Run unit tests
|
||||
run: go test ./...
|
||||
- name: Run property-based tests
|
||||
run: make test-property
|
||||
- name: Example Action Readme Generation
|
||||
run: |
|
||||
go run . gen testdata/example-action --output example-README.md
|
||||
@@ -34,29 +40,29 @@ jobs:
|
||||
|
||||
# Generate multiple formats for different actions to demonstrate new functionality
|
||||
echo "Generating documentation for example-action..."
|
||||
go run . gen testdata/example-action/ --output $PWD/docs/example-action.md
|
||||
go run . gen testdata/example-action/ -f html --output $PWD/docs/example-action.html
|
||||
go run . gen testdata/example-action/ -f json --output $PWD/docs/example-action.json
|
||||
go run . gen testdata/example-action/ --output "$PWD/docs/example-action.md"
|
||||
go run . gen testdata/example-action/ -f html --output "$PWD/docs/example-action.html"
|
||||
go run . gen testdata/example-action/ -f json --output "$PWD/docs/example-action.json"
|
||||
|
||||
echo "Generating documentation for composite-action..."
|
||||
go run . gen testdata/composite-action/ --output $PWD/docs/composite-action.md
|
||||
go run . gen testdata/composite-action/ -f html --output $PWD/docs/composite-action.html
|
||||
go run . gen testdata/composite-action/ --output "$PWD/docs/composite-action.md"
|
||||
go run . gen testdata/composite-action/ -f html --output "$PWD/docs/composite-action.html"
|
||||
|
||||
# Test single file targeting
|
||||
echo "Generating from specific action.yml files..."
|
||||
go run . gen testdata/example-action/action.yml --output $PWD/docs/direct-example.md
|
||||
go run . gen testdata/composite-action/action.yml --output $PWD/docs/direct-composite.md
|
||||
go run . gen testdata/example-action/action.yml --output "$PWD/docs/direct-example.md"
|
||||
go run . gen testdata/composite-action/action.yml --output "$PWD/docs/direct-composite.md"
|
||||
|
||||
# Test recursive generation with different themes
|
||||
echo "Testing recursive generation with themes..."
|
||||
go run . gen testdata/ --recursive --theme minimal -f html --output $PWD/docs/all-actions-minimal.html
|
||||
go run . gen testdata/ --recursive --theme professional -f json --output $PWD/docs/all-actions-professional.json
|
||||
go run . gen testdata/ --recursive --theme minimal -f html --output "$PWD/docs/all-actions-minimal.html"
|
||||
go run . gen testdata/ --recursive --theme professional -f json --output "$PWD/docs/all-actions-professional.json"
|
||||
|
||||
# Verify files were generated
|
||||
echo "Verifying generated documentation files..."
|
||||
ls -la docs/
|
||||
- name: Upload Generated Documentation
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
if: always()
|
||||
with:
|
||||
name: generated-documentation
|
||||
|
||||
22
.github/workflows/codeql.yml
vendored
22
.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,22 +25,24 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: ['go']
|
||||
language: ["go"]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5
|
||||
uses: github/codeql-action/init@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: security-and-quality
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5
|
||||
uses: github/codeql-action/autobuild@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5
|
||||
uses: github/codeql-action/analyze@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
|
||||
with:
|
||||
category: '/language:${{matrix.language}}'
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
40
.github/workflows/commitlint.yml
vendored
Normal file
40
.github/workflows/commitlint.yml
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
|
||||
name: Commit Messages
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
commitlint:
|
||||
name: Validate Commit Messages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
- name: Install commitlint
|
||||
run: |
|
||||
npm install --save-dev @commitlint/cli@19.6.1 @commitlint/config-conventional@19.6.0
|
||||
|
||||
- name: Validate current commit (for single commits)
|
||||
if: github.event_name == 'push'
|
||||
run: npx commitlint --from HEAD~1 --to HEAD --verbose
|
||||
|
||||
- name: Validate PR commits
|
||||
if: github.event_name == 'pull_request'
|
||||
run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose
|
||||
12
.github/workflows/pr-lint.yml
vendored
12
.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:
|
||||
@@ -20,11 +21,14 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
statuses: write
|
||||
contents: read
|
||||
actions: write
|
||||
contents: write
|
||||
issues: write
|
||||
packages: read
|
||||
pull-requests: write
|
||||
statuses: write
|
||||
|
||||
steps:
|
||||
- name: Run PR Lint
|
||||
# https://github.com/ivuorinen/actions
|
||||
uses: ivuorinen/actions/pr-lint@646169c13f7457d7f1040c23b722bb663e476786 # 25.10.1
|
||||
uses: ivuorinen/actions/pr-lint@f98ae7cd7d0feb1f9d6b01de0addbb11414cfc73 # v2026.01.21
|
||||
|
||||
23
.github/workflows/release.yml
vendored
23
.github/workflows/release.yml
vendored
@@ -4,9 +4,10 @@ name: Release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
- "v*.*.*"
|
||||
|
||||
permissions: read-all
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
release:
|
||||
@@ -17,33 +18,33 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
|
||||
with:
|
||||
cache: true
|
||||
|
||||
- name: Set up Node.js (for cosign)
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: '22'
|
||||
node-version: "24"
|
||||
|
||||
- name: Install cosign
|
||||
uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0
|
||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
with:
|
||||
cosign-release: 'v2.2.2'
|
||||
cosign-release: "v2.4.0"
|
||||
|
||||
- name: Install syft
|
||||
uses: anchore/sbom-action/download-syft@f8bdd1d8ac5e901a77a92f111440fdb1b593736b # v0.20.6
|
||||
uses: anchore/sbom-action/download-syft@62ad5284b8ced813296287a0b63906cb364b73ee # v0.22.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
113
.github/workflows/security.yml
vendored
113
.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,14 +21,18 @@ jobs:
|
||||
govulncheck:
|
||||
name: Go Vulnerability Check
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
go-version-file: "go.mod"
|
||||
check-latest: true
|
||||
|
||||
- name: Install govulncheck
|
||||
@@ -46,45 +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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
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@51f77329afa6477de8c49fc9c7046c15b9a4e79d # v3
|
||||
uses: github/codeql-action/upload-sarif@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
|
||||
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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
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
|
||||
@@ -92,47 +97,71 @@ 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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
go-version-file: "go.mod"
|
||||
check-latest: true
|
||||
|
||||
- name: Build the bin
|
||||
shell: bash
|
||||
run: make build
|
||||
run: |
|
||||
# Auto-detect platform (matching GoReleaser's structure)
|
||||
PLATFORM="$(go env GOOS)/$(go env GOARCH)"
|
||||
|
||||
# Create platform-specific directory structure
|
||||
mkdir -p "$PLATFORM"
|
||||
|
||||
# Build binary into the platform directory
|
||||
go build -o "$PLATFORM/gh-action-readme" .
|
||||
|
||||
# Verify binary was created
|
||||
ls -lh "$PLATFORM/gh-action-readme"
|
||||
|
||||
# Export platform for Docker build step
|
||||
echo "TARGETPLATFORM=$PLATFORM" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build Docker image
|
||||
run: docker build -t gh-action-readme:test .
|
||||
run: docker build --build-arg TARGETPLATFORM=${{ env.TARGETPLATFORM }} -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@51f77329afa6477de8c49fc9c7046c15b9a4e79d # v3
|
||||
uses: github/codeql-action/upload-sarif@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
|
||||
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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
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@646169c13f7457d7f1040c23b722bb663e476786 # 25.10.1
|
||||
- uses: ivuorinen/actions/stale@f98ae7cd7d0feb1f9d6b01de0addbb11414cfc73 # v2026.01.21
|
||||
|
||||
14
.github/workflows/sync-labels.yml
vendored
14
.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:
|
||||
@@ -34,8 +35,9 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: ⤵️ Checkout Repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: ⤵️ Sync Latest Labels Definitions
|
||||
uses: ivuorinen/actions/sync-labels@646169c13f7457d7f1040c23b722bb663e476786 # 25.10.1
|
||||
uses: ivuorinen/actions/sync-labels@f98ae7cd7d0feb1f9d6b01de0addbb11414cfc73 # v2026.01.21
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -27,9 +27,15 @@ go.sum
|
||||
|
||||
/gh-action-readme
|
||||
*.out
|
||||
actionlint
|
||||
|
||||
# Created readme files
|
||||
testdata/**/*.md
|
||||
testdata/**/*.html
|
||||
testdata/**/*.json
|
||||
coverage.*
|
||||
|
||||
# Other
|
||||
/megalinter-reports/
|
||||
cr.txt
|
||||
pr.txt
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
# Gitleaks ignore patterns
|
||||
# https://github.com/gitleaks/gitleaks
|
||||
#
|
||||
# Format: <commit-hash>:<file-path>:<rule-name>:<line-number>
|
||||
# Or without commit hash for dir scans: <file-path>:<rule-name>:<line-number>
|
||||
|
||||
f9823eef3ec602b92ab4f17b3b4b93b2f219fb6a:internal/wizard/validator_test.go:generic-api-key:195
|
||||
f9823eef3ec602b92ab4f17b3b4b93b2f219fb6a:internal/wizard/validator_test.go:generic-api-key:197
|
||||
@@ -7,3 +10,16 @@ f9823eef3ec602b92ab4f17b3b4b93b2f219fb6a:internal/wizard/validator_test.go:gener
|
||||
f9823eef3ec602b92ab4f17b3b4b93b2f219fb6a:internal/wizard/validator_test.go:generic-api-key:199
|
||||
f9823eef3ec602b92ab4f17b3b4b93b2f219fb6a:internal/wizard/validator_test.go:generic-api-key:200
|
||||
f9823eef3ec602b92ab4f17b3b4b93b2f219fb6a:internal/wizard/validator_test.go:github-pat:195
|
||||
|
||||
# Test tokens (using fingerprint format for dir scans)
|
||||
internal/configuration_loader_test.go:github-pat:141
|
||||
internal/configuration_loader_test.go:github-pat:173
|
||||
internal/wizard/validator_test.go:generic-api-key:204
|
||||
internal/wizard/validator_test.go:generic-api-key:206
|
||||
internal/wizard/validator_test.go:generic-api-key:207
|
||||
internal/wizard/validator_test.go:generic-api-key:208
|
||||
internal/wizard/validator_test.go:generic-api-key:209
|
||||
internal/wizard/validator_test.go:github-pat:204
|
||||
integration_test.go:github-pat:304
|
||||
internal/config_test.go:github-pat:133
|
||||
internal/config_test.go:github-pat:162
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.24.6
|
||||
1.25.5
|
||||
|
||||
@@ -2,6 +2,20 @@
|
||||
# yaml-language-server: $schema=https://golangci-lint.run/jsonschema/golangci.jsonschema.json
|
||||
version: "2"
|
||||
|
||||
# golangci-lint configuration
|
||||
# Aligned with SonarCloud "Sonar way" quality gate
|
||||
# https://docs.sonarsource.com/sonarqube-cloud/standards/managing-quality-gates/
|
||||
#
|
||||
# Key alignments:
|
||||
# - gosec: Aligns with Security Rating A requirement (no vulnerabilities)
|
||||
# - gocyclo (min: 10): Stricter than SonarCloud (not enforced)
|
||||
# - dupl: Aligns with duplicated lines density <= 3%
|
||||
# - lll (120 chars): Stricter than SonarCloud (not enforced)
|
||||
# - Code coverage: See Makefile target 'test-coverage-check' (>= 60%, goal: 80% for new code)
|
||||
#
|
||||
# SonarCloud focuses on new code (last 30 days), local linting checks entire codebase
|
||||
# Local standards are intentionally stricter in some areas (complexity, line length)
|
||||
|
||||
run:
|
||||
timeout: 5m
|
||||
go: "1.24"
|
||||
|
||||
124
.goreleaser.yaml
124
.goreleaser.yaml
@@ -10,8 +10,6 @@ before:
|
||||
hooks:
|
||||
# Run tests before building
|
||||
- go test ./...
|
||||
# Run linter
|
||||
- golangci-lint run
|
||||
# Ensure dependencies are tidy
|
||||
- go mod tidy
|
||||
|
||||
@@ -47,26 +45,25 @@ builds:
|
||||
|
||||
archives:
|
||||
- id: default
|
||||
format: tar.gz
|
||||
formats: [tar.gz]
|
||||
# Use zip for Windows
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
format: zip
|
||||
formats: [zip]
|
||||
name_template: >-
|
||||
{{ .ProjectName }}_ {{- title .Os }}_ {{- if eq .Arch "amd64" }}x86_64 {{- else if eq .Arch "386" }}i386 {{- else }}{{ .Arch }}{{ end }} {{- if .Arm }}v{{ .Arm }}{{ end }}
|
||||
files:
|
||||
- README.md
|
||||
- LICENSE*
|
||||
- CHANGELOG.md
|
||||
- docs/**/*
|
||||
- templates/**/*
|
||||
- schemas/**/*
|
||||
- docs/*.md
|
||||
- schemas/*.json
|
||||
|
||||
checksum:
|
||||
name_template: 'checksums.txt'
|
||||
|
||||
snapshot:
|
||||
name_template: "{{ incpatch .Version }}-next"
|
||||
version_template: "{{ incpatch .Version }}-next"
|
||||
|
||||
changelog:
|
||||
sort: asc
|
||||
@@ -146,29 +143,31 @@ release:
|
||||
Thanks to all contributors who made this release possible!
|
||||
|
||||
# Homebrew tap
|
||||
brews:
|
||||
- name: gh-action-readme
|
||||
homepage: https://github.com/ivuorinen/gh-action-readme
|
||||
description: "Auto-generate beautiful README and HTML documentation for GitHub Actions"
|
||||
license: MIT
|
||||
repository:
|
||||
owner: ivuorinen
|
||||
name: homebrew-tap
|
||||
branch: main
|
||||
token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}"
|
||||
directory: Formula
|
||||
commit_author:
|
||||
name: goreleaserbot
|
||||
email: bot@goreleaser.com
|
||||
commit_msg_template: "Brew formula update for {{ .ProjectName }} version {{ .Tag }}"
|
||||
install: |
|
||||
bin.install "gh-action-readme"
|
||||
|
||||
# Install templates and schemas
|
||||
(share/"gh-action-readme/templates").install Dir["templates/*"]
|
||||
(share/"gh-action-readme/schemas").install Dir["schemas/*"]
|
||||
test: |
|
||||
system "#{bin}/gh-action-readme", "version"
|
||||
# TODO: Re-enable once we can properly support homebrew_casks with data files
|
||||
# or find an alternative packaging solution for templates/schemas
|
||||
# brews:
|
||||
# - name: gh-action-readme
|
||||
# homepage: https://github.com/ivuorinen/gh-action-readme
|
||||
# description: "Auto-generate beautiful README and HTML documentation for GitHub Actions"
|
||||
# license: MIT
|
||||
# repository:
|
||||
# owner: ivuorinen
|
||||
# name: homebrew-tap
|
||||
# branch: main
|
||||
# token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}"
|
||||
# directory: Formula
|
||||
# commit_author:
|
||||
# name: goreleaserbot
|
||||
# email: bot@goreleaser.com
|
||||
# commit_msg_template: "Brew formula update for {{ .ProjectName }} version {{ .Tag }}"
|
||||
# install: |
|
||||
# bin.install "gh-action-readme"
|
||||
#
|
||||
# # Install templates and schemas
|
||||
# (share/"gh-action-readme/templates").install Dir["templates/*"]
|
||||
# (share/"gh-action-readme/schemas").install Dir["schemas/*"]
|
||||
# test: |
|
||||
# system "#{bin}/gh-action-readme", "version"
|
||||
|
||||
# Scoop bucket for Windows (disabled - repository doesn't exist)
|
||||
# scoops:
|
||||
@@ -185,50 +184,25 @@ brews:
|
||||
# email: bot@goreleaser.com
|
||||
# commit_msg_template: "Scoop update for {{ .ProjectName }} version {{ .Tag }}"
|
||||
|
||||
# Docker images
|
||||
dockers:
|
||||
- image_templates:
|
||||
- "ghcr.io/ivuorinen/gh-action-readme:{{ .Version }}-amd64"
|
||||
- "ghcr.io/ivuorinen/gh-action-readme:latest-amd64"
|
||||
# Docker images (using dockers_v2 with multi-platform buildx)
|
||||
dockers_v2:
|
||||
- images:
|
||||
- "ghcr.io/ivuorinen/gh-action-readme"
|
||||
tags:
|
||||
- "{{ .Version }}"
|
||||
- "latest"
|
||||
dockerfile: Dockerfile
|
||||
use: buildx
|
||||
build_flag_templates:
|
||||
- "--pull"
|
||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
||||
- "--label=org.opencontainers.image.source=https://github.com/ivuorinen/gh-action-readme"
|
||||
- "--platform=linux/amd64"
|
||||
goos: linux
|
||||
goarch: amd64
|
||||
|
||||
- image_templates:
|
||||
- "ghcr.io/ivuorinen/gh-action-readme:{{ .Version }}-arm64"
|
||||
- "ghcr.io/ivuorinen/gh-action-readme:latest-arm64"
|
||||
dockerfile: Dockerfile
|
||||
use: buildx
|
||||
build_flag_templates:
|
||||
- "--pull"
|
||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
||||
- "--label=org.opencontainers.image.source=https://github.com/ivuorinen/gh-action-readme"
|
||||
- "--platform=linux/arm64"
|
||||
goos: linux
|
||||
goarch: arm64
|
||||
|
||||
docker_manifests:
|
||||
- name_template: "ghcr.io/ivuorinen/gh-action-readme:{{ .Version }}"
|
||||
image_templates:
|
||||
- "ghcr.io/ivuorinen/gh-action-readme:{{ .Version }}-amd64"
|
||||
- "ghcr.io/ivuorinen/gh-action-readme:{{ .Version }}-arm64"
|
||||
|
||||
- name_template: "ghcr.io/ivuorinen/gh-action-readme:latest"
|
||||
image_templates:
|
||||
- "ghcr.io/ivuorinen/gh-action-readme:latest-amd64"
|
||||
- "ghcr.io/ivuorinen/gh-action-readme:latest-arm64"
|
||||
platforms:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
extra_files:
|
||||
- schemas
|
||||
labels:
|
||||
org.opencontainers.image.created: "{{.Date}}"
|
||||
org.opencontainers.image.title: "{{.ProjectName}}"
|
||||
org.opencontainers.image.revision: "{{.FullCommit}}"
|
||||
org.opencontainers.image.version: "{{.Version}}"
|
||||
org.opencontainers.image.source: "https://github.com/ivuorinen/gh-action-readme"
|
||||
|
||||
# Signing
|
||||
signs:
|
||||
@@ -251,4 +225,4 @@ sboms:
|
||||
|
||||
# Announce
|
||||
announce:
|
||||
skip: '{{gt .Patch 0}}'
|
||||
skip: '{{gt .Patch 0}}'
|
||||
|
||||
@@ -25,36 +25,31 @@ repos:
|
||||
- id: pretty-format-json
|
||||
args: [--autofix, --no-sort-keys]
|
||||
|
||||
# Renovatebot pre-commit hooks
|
||||
- repo: https://github.com/renovatebot/pre-commit-hooks
|
||||
rev: 41.132.5
|
||||
hooks:
|
||||
- id: renovate-config-validator
|
||||
|
||||
# YAML formatting with yamlfmt (replaces yamllint for formatting)
|
||||
- repo: https://github.com/google/yamlfmt
|
||||
rev: v0.17.2
|
||||
rev: v0.21.0
|
||||
hooks:
|
||||
- id: yamlfmt
|
||||
exclude: "^testdata/"
|
||||
|
||||
# Markdown linting with markdownlint-cli2 (excluding legacy files)
|
||||
- repo: https://github.com/DavidAnson/markdownlint-cli2
|
||||
rev: v0.18.1
|
||||
rev: v0.20.0
|
||||
hooks:
|
||||
- id: markdownlint-cli2
|
||||
args: [--fix]
|
||||
exclude: "^testdata/"
|
||||
|
||||
# EditorConfig checking
|
||||
- repo: https://github.com/editorconfig-checker/editorconfig-checker
|
||||
rev: v3.4.0
|
||||
rev: v3.6.0
|
||||
hooks:
|
||||
- id: editorconfig-checker
|
||||
alias: ec
|
||||
|
||||
# Go formatting, imports, and linting
|
||||
- repo: https://github.com/TekWizely/pre-commit-golang
|
||||
rev: v1.0.0-rc.2
|
||||
rev: v1.0.0-rc.4
|
||||
hooks:
|
||||
- id: go-imports-repo
|
||||
args: [-w]
|
||||
@@ -70,7 +65,15 @@ repos:
|
||||
|
||||
# GitHub Actions linting
|
||||
- repo: https://github.com/rhysd/actionlint
|
||||
rev: v1.7.7
|
||||
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
|
||||
hooks:
|
||||
- id: commitlint
|
||||
stages: [commit-msg]
|
||||
additional_dependencies: ["@commitlint/config-conventional"]
|
||||
|
||||
1
.serena/.gitignore
vendored
Normal file
1
.serena/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/cache
|
||||
85
.serena/project.yml
Normal file
85
.serena/project.yml
Normal file
@@ -0,0 +1,85 @@
|
||||
---
|
||||
# list of languages for which language servers are started; choose from:
|
||||
# al bash clojure cpp csharp csharp_omnisharp
|
||||
# dart elixir elm erlang fortran go
|
||||
# haskell java julia kotlin lua markdown
|
||||
# nix perl php python python_jedi r
|
||||
# rego ruby ruby_solargraph rust scala swift
|
||||
# terraform typescript typescript_vts yaml zig
|
||||
# Note:
|
||||
# - For C, use cpp
|
||||
# - For JavaScript, use typescript
|
||||
# Special requirements:
|
||||
# - csharp: Requires the presence of a .sln file in the project folder.
|
||||
# When using multiple languages, the first language server that supports a given file will be used for that file.
|
||||
# The first language is the default language and the respective language server will be used as a fallback.
|
||||
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
|
||||
languages:
|
||||
- go
|
||||
|
||||
# the encoding used by text files in the project
|
||||
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
|
||||
encoding: "utf-8"
|
||||
|
||||
# whether to use the project's gitignore file to ignore files
|
||||
# Added on 2025-04-07
|
||||
ignore_all_files_in_gitignore: true
|
||||
|
||||
# list of additional paths to ignore
|
||||
# same syntax as gitignore, so you can use * and **
|
||||
# Was previously called `ignored_dirs`, please update your config if you are using that.
|
||||
# Added (renamed) on 2025-04-07
|
||||
ignored_paths: []
|
||||
|
||||
# whether the project is in read-only mode
|
||||
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
||||
# Added on 2025-04-18
|
||||
read_only: false
|
||||
|
||||
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
|
||||
# Below is the complete list of tools for convenience.
|
||||
# To make sure you have the latest list of tools, and to view their descriptions,
|
||||
# execute `uv run scripts/print_tool_overview.py`.
|
||||
#
|
||||
# * `activate_project`: Activates a project by name.
|
||||
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
|
||||
# * `create_text_file`: Creates/overwrites a file in the project directory.
|
||||
# * `delete_lines`: Deletes a range of lines within a file.
|
||||
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
|
||||
# * `execute_shell_command`: Executes a shell command.
|
||||
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
|
||||
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
|
||||
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
|
||||
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
|
||||
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
|
||||
# * `initial_instructions`: Gets the initial instructions for the current project.
|
||||
# Should only be used in settings where the system prompt cannot be set,
|
||||
# e.g. in clients you have no control over, like Claude Desktop.
|
||||
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
|
||||
# * `insert_at_line`: Inserts content at a given line in a file.
|
||||
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
|
||||
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
|
||||
# * `list_memories`: Lists memories in Serena's project-specific memory store.
|
||||
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
|
||||
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
|
||||
# * `read_file`: Reads a file within the project directory.
|
||||
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
|
||||
# * `remove_project`: Removes a project from the Serena configuration.
|
||||
# * `replace_lines`: Replaces a range of lines within a file with new content.
|
||||
# * `replace_symbol_body`: Replaces the full definition of a symbol.
|
||||
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
|
||||
# * `search_for_pattern`: Performs a search for a pattern in the project.
|
||||
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
|
||||
# * `switch_modes`: Activates modes by providing a list of their names
|
||||
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
|
||||
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
|
||||
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
|
||||
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
|
||||
excluded_tools: []
|
||||
|
||||
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||
# (contrary to the memories, which are loaded on demand).
|
||||
initial_prompt: ""
|
||||
|
||||
project_name: "gh-action-readme"
|
||||
included_optional_tools: []
|
||||
@@ -135,7 +135,7 @@ Improve documentation and examples:
|
||||
- [ ] Tests added for new features (`make test` passes)
|
||||
- [ ] Documentation updated for user-facing changes
|
||||
- [ ] No security vulnerabilities (`make security` passes)
|
||||
- [ ] Commit messages follow conventional format
|
||||
- [ ] Commit messages follow [conventional commit format](docs/COMMIT_MESSAGES.md)
|
||||
|
||||
### PR Requirements
|
||||
|
||||
@@ -204,7 +204,7 @@ if err != nil {
|
||||
|
||||
### Commit Message Format
|
||||
|
||||
Follow [Conventional Commits](https://conventionalcommits.org/):
|
||||
Follow [Conventional Commits](https://conventionalcommits.org/). See [docs/COMMIT_MESSAGES.md](docs/COMMIT_MESSAGES.md) for detailed guidelines.
|
||||
|
||||
```bash
|
||||
# Feature additions
|
||||
|
||||
16
Dockerfile
16
Dockerfile
@@ -1,15 +1,19 @@
|
||||
# Dockerfile for gh-action-readme
|
||||
FROM scratch
|
||||
|
||||
# Copy the binary from the build context
|
||||
COPY gh-action-readme /usr/local/bin/gh-action-readme
|
||||
# Multi-platform build support
|
||||
# See: https://goreleaser.com/customization/dockers_v2/
|
||||
# GoReleaser organizes binaries in platform subdirectories (linux/amd64/, linux/arm64/)
|
||||
# TARGETPLATFORM arg resolves to the correct platform directory
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
# Copy templates and schemas
|
||||
COPY templates /usr/local/share/gh-action-readme/templates
|
||||
# Copy the binary from the build context (platform-specific)
|
||||
COPY $TARGETPLATFORM/gh-action-readme /usr/local/bin/gh-action-readme
|
||||
|
||||
# Copy schemas (templates are embedded in the binary via go:embed)
|
||||
COPY schemas /usr/local/share/gh-action-readme/schemas
|
||||
|
||||
# Set environment variables for template paths
|
||||
ENV GH_ACTION_README_TEMPLATE_PATH=/usr/local/share/gh-action-readme/templates
|
||||
# Set environment variable for schema path
|
||||
ENV GH_ACTION_README_SCHEMA_PATH=/usr/local/share/gh-action-readme/schemas
|
||||
|
||||
# Set the binary as entrypoint
|
||||
|
||||
80
Makefile
80
Makefile
@@ -1,10 +1,17 @@
|
||||
.PHONY: help test test-coverage test-coverage-html lint build run example \
|
||||
clean readme config-verify security vulncheck audit trivy gitleaks \
|
||||
.PHONY: help test test-quick test-coverage test-coverage-html test-coverage-check \
|
||||
test-mutation test-mutation-parser test-mutation-validation \
|
||||
test-property test-property-validation test-property-parser \
|
||||
lint build run example clean readme config-verify security vulncheck audit trivy gitleaks \
|
||||
editorconfig editorconfig-fix format devtools pre-commit-install pre-commit-update \
|
||||
deps-check deps-update deps-update-all
|
||||
|
||||
all: help
|
||||
|
||||
# Coverage threshold (align with SonarCloud)
|
||||
# Note: SonarCloud checks NEW code coverage (≥80%), this checks overall coverage
|
||||
# Current overall coverage: 72.9% - working towards 80% target
|
||||
COVERAGE_THRESHOLD := 72.0
|
||||
|
||||
help: ## Show this help message
|
||||
@echo "GitHub Action README Generator - Available Make Targets:"
|
||||
@echo ""
|
||||
@@ -22,7 +29,20 @@ help: ## Show this help message
|
||||
@echo " make deps-update # Update dependencies interactively"
|
||||
@echo " make security # Run all security scans"
|
||||
|
||||
test: ## Run all tests
|
||||
test: ## Run all tests (standard and property-based)
|
||||
@echo "Running standard tests..."
|
||||
@go test ./...
|
||||
@echo ""
|
||||
@echo "Running property-based tests..."
|
||||
@$(MAKE) test-property
|
||||
@echo ""
|
||||
@echo "✅ All tests (standard + property) completed successfully!"
|
||||
@echo ""
|
||||
@echo "Note: Mutation tests require go-mutesting (compatible with Go 1.22/1.23 only)."
|
||||
@echo " Run 'make test-mutation' if you have a compatible Go version."
|
||||
@echo " Run 'make test-quick' for fast iteration (unit tests only)."
|
||||
|
||||
test-quick: ## Run only standard unit tests (fast)
|
||||
go test ./...
|
||||
|
||||
test-coverage: ## Run tests with coverage and display in CLI
|
||||
@@ -54,6 +74,60 @@ test-coverage-html: test-coverage ## Generate HTML coverage report and open in b
|
||||
echo "Open coverage.html in your browser to view detailed coverage"; \
|
||||
fi
|
||||
|
||||
test-coverage-check: ## Run tests with coverage check (overall >= 72%)
|
||||
@command -v bc >/dev/null 2>&1 || { \
|
||||
echo "❌ bc command not found. Please install bc (e.g., apt-get install bc, brew install bc)"; \
|
||||
exit 1; \
|
||||
}
|
||||
@echo "Running tests with coverage check..."
|
||||
@go test -cover -coverprofile=coverage.out ./...
|
||||
@total=$$(go tool cover -func=coverage.out | grep total | awk '{print $$3}' | sed 's/%//'); \
|
||||
if [ $$(echo "$$total < $(COVERAGE_THRESHOLD)" | bc) -eq 1 ]; then \
|
||||
echo "❌ Coverage $$total% is below threshold $(COVERAGE_THRESHOLD)%"; \
|
||||
exit 1; \
|
||||
else \
|
||||
echo "✅ Coverage $$total% meets threshold $(COVERAGE_THRESHOLD)%"; \
|
||||
fi
|
||||
|
||||
.PHONY: test-mutation test-mutation-parser test-mutation-validation
|
||||
|
||||
test-mutation: test-mutation-parser test-mutation-validation ## Run all mutation tests
|
||||
|
||||
test-mutation-parser: ## Run mutation tests on parser (permission parsing)
|
||||
@echo "Running mutation tests on parser..."
|
||||
@command -v go-mutesting >/dev/null 2>&1 || { \
|
||||
echo "❌ go-mutesting not found. Installing..."; \
|
||||
go install github.com/zimmski/go-mutesting/cmd/go-mutesting@latest; \
|
||||
}
|
||||
@go-mutesting --do-not-remove internal/parser.go -- \
|
||||
go test -v ./internal -run "TestParse.*Permissions|TestMerge.*Permissions|TestProcess.*Permission"
|
||||
|
||||
test-mutation-validation: ## Run mutation tests on validation (version and strings)
|
||||
@echo "Running mutation tests on validation..."
|
||||
@command -v go-mutesting >/dev/null 2>&1 || { \
|
||||
echo "❌ go-mutesting not found. Installing..."; \
|
||||
go install github.com/zimmski/go-mutesting/cmd/go-mutesting@latest; \
|
||||
}
|
||||
@echo "Testing version validation..."
|
||||
@go-mutesting --do-not-remove internal/validation/validation.go -- \
|
||||
go test -v ./internal/validation -run "TestIsCommitSHA|TestIsSemanticVersion|TestIsVersionPinned"
|
||||
@echo ""
|
||||
@echo "Testing string validation..."
|
||||
@go-mutesting --do-not-remove internal/validation/strings.go -- \
|
||||
go test -v ./internal/validation -run "TestParseGitHubURL|TestSanitize|TestFormat|TestClean"
|
||||
|
||||
.PHONY: test-property test-property-validation test-property-parser
|
||||
|
||||
test-property: test-property-validation test-property-parser ## Run all property-based tests
|
||||
|
||||
test-property-validation: ## Run property tests on validation (strings)
|
||||
@echo "Running property tests on validation..."
|
||||
@go test -v ./internal/validation -run ".*Properties" -timeout 30s
|
||||
|
||||
test-property-parser: ## Run property tests on parser (permission merging)
|
||||
@echo "Running property tests on parser..."
|
||||
@go test -v ./internal -run ".*Properties" -timeout 30s
|
||||
|
||||
lint: editorconfig ## Run all linters via pre-commit
|
||||
@echo "Running all linters via pre-commit..."
|
||||
@command -v pre-commit >/dev/null 2>&1 || \
|
||||
|
||||
59
README.md
59
README.md
@@ -11,6 +11,13 @@
|
||||
[](https://github.com/ivuorinen/gh-action-readme/actions/workflows/security.yml)
|
||||
[](https://github.com/ivuorinen/gh-action-readme/actions/workflows/codeql.yml)
|
||||
|
||||
[](https://sonarcloud.io/summary/new_code?id=ivuorinen_gh-action-readme)
|
||||
[](https://sonarcloud.io/summary/new_code?id=ivuorinen_gh-action-readme)
|
||||
[](https://sonarcloud.io/summary/new_code?id=ivuorinen_gh-action-readme)
|
||||
|
||||
[](https://sonarcloud.io/summary/new_code?id=ivuorinen_gh-action-readme)
|
||||
[](https://sonarcloud.io/summary/new_code?id=ivuorinen_gh-action-readme)
|
||||
|
||||
</div>
|
||||
|
||||
> **The definitive CLI tool for generating beautiful documentation from GitHub Actions `action.yml` files**
|
||||
@@ -28,6 +35,28 @@ Transform your GitHub Actions into professional documentation with multiple them
|
||||
- 📁 **Flexible Targeting** - Directory/file arguments, custom output filenames
|
||||
- 🛡️ **Thread Safe** - Race condition protection, concurrent processing ready
|
||||
|
||||
## 🛡️ Quality Gates
|
||||
|
||||
This project enforces quality standards aligned with [SonarCloud "Sonar way"](https://docs.sonarsource.com/sonarqube-cloud/standards/managing-quality-gates/):
|
||||
|
||||
| Metric | Threshold |
|
||||
| ---------------------- | ------------------- |
|
||||
| Code Coverage | ≥ 80% (new code) |
|
||||
| Duplicated Lines | ≤ 3% (new code) |
|
||||
| Security Rating | A (no issues) |
|
||||
| Reliability Rating | A (no bugs) |
|
||||
| Maintainability Rating | A (tech debt ≤ 5%) |
|
||||
|
||||
**Local Development Checks:**
|
||||
|
||||
```bash
|
||||
make lint # Run all linters (gosec, dupl, gocyclo, etc.)
|
||||
make test-coverage-check # Verify coverage threshold
|
||||
make security # Security scans (gosec, trivy, gitleaks)
|
||||
```
|
||||
|
||||
Local linting enforces additional standards including cyclomatic complexity ≤ 10 and line length ≤ 120 characters.
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Installation
|
||||
@@ -62,6 +91,32 @@ gh-action-readme gen --output-format json --output api-docs.json
|
||||
gh-action-readme gen --recursive --theme professional
|
||||
```
|
||||
|
||||
### Run Without Installing
|
||||
|
||||
For development or one-time usage, you can run directly with `go run`:
|
||||
|
||||
```bash
|
||||
# Run from cloned repository
|
||||
go run . gen
|
||||
|
||||
# Run specific commands
|
||||
go run . gen --theme github
|
||||
go run . validate
|
||||
go run . config show
|
||||
|
||||
# Run with arguments
|
||||
go run . gen testdata/example-action/ --output custom.md
|
||||
```
|
||||
|
||||
Or run remotely without cloning:
|
||||
|
||||
```bash
|
||||
# Run directly from GitHub (requires Go 1.17+)
|
||||
go run github.com/ivuorinen/gh-action-readme@latest gen
|
||||
go run github.com/ivuorinen/gh-action-readme@latest gen --theme professional
|
||||
go run github.com/ivuorinen/gh-action-readme@latest validate
|
||||
```
|
||||
|
||||
## 📋 Examples
|
||||
|
||||
### Input: `action.yml`
|
||||
@@ -138,7 +193,7 @@ gh-action-readme config init
|
||||
gh-action-readme gen --recursive --theme github --output-dir docs/
|
||||
|
||||
# Custom themes
|
||||
cp -r templates/themes/github templates/themes/custom
|
||||
cp -r templates_embed/templates/themes/github templates_embed/templates/themes/custom
|
||||
gh-action-readme gen --theme custom
|
||||
```
|
||||
|
||||
@@ -176,7 +231,7 @@ Contributions welcome! Fork, create feature branch, add tests, submit PR.
|
||||
## 📊 Comparison
|
||||
|
||||
| Feature | gh-action-readme | action-docs | gh-actions-auto-docs |
|
||||
|---------|------------------|-------------|----------------------|
|
||||
| --------- | ------------------ | ------------- | ---------------------- |
|
||||
| **Themes** | 5 themes | 1 basic | 1 basic |
|
||||
| **Output Formats** | 4 formats | 1 format | 1 format |
|
||||
| **Validation** | Smart suggestions | Basic | None |
|
||||
|
||||
841
appconstants/constants.go
Normal file
841
appconstants/constants.go
Normal file
@@ -0,0 +1,841 @@
|
||||
// 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"
|
||||
// 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
|
||||
}
|
||||
|
||||
// supportedOutputFormats lists all available output format names (unexported to prevent modification).
|
||||
var supportedOutputFormats = []string{
|
||||
OutputFormatMarkdown,
|
||||
OutputFormatHTML,
|
||||
OutputFormatJSON,
|
||||
OutputFormatASCIIDoc,
|
||||
}
|
||||
|
||||
// GetSupportedOutputFormats returns a copy of the supported output format names.
|
||||
// Returns a new slice to prevent external modification of the internal list.
|
||||
func GetSupportedOutputFormats() []string {
|
||||
formats := make([]string, len(supportedOutputFormats))
|
||||
copy(formats, supportedOutputFormats)
|
||||
|
||||
return formats
|
||||
}
|
||||
|
||||
// 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"
|
||||
// ConfigKeyUseDefaultBranch is the configuration key for use default branch behavior.
|
||||
ConfigKeyUseDefaultBranch = "use_default_branch"
|
||||
|
||||
// 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"
|
||||
// ConfigKeyIgnoredDirectories is the configuration key for ignored directories during discovery.
|
||||
ConfigKeyIgnoredDirectories = "ignored_directories"
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// defaultIgnoredDirectories lists directories to ignore during file discovery.
|
||||
var defaultIgnoredDirectories = []string{
|
||||
DirGit, DirGitHub, DirGitLab, DirSVN, // VCS
|
||||
DirNodeModules, DirBowerComponents, // JavaScript
|
||||
DirVendor, // Go/PHP
|
||||
DirVenvDot, DirVenv, DirEnv, DirTox, DirPycache, // Python
|
||||
DirDist, DirBuild, DirTarget, DirOut, // Build outputs
|
||||
DirIdea, DirVscode, // IDEs
|
||||
DirCache, DirTmpDot, DirTmp, // Cache/temp
|
||||
}
|
||||
|
||||
// GetDefaultIgnoredDirectories returns a copy of the default ignored directory names.
|
||||
// Returns a new slice to prevent external modification of the internal list.
|
||||
func GetDefaultIgnoredDirectories() []string {
|
||||
dirs := make([]string, len(defaultIgnoredDirectories))
|
||||
copy(dirs, defaultIgnoredDirectories)
|
||||
|
||||
return dirs
|
||||
}
|
||||
|
||||
// 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"
|
||||
)
|
||||
|
||||
// Directory names commonly ignored during file discovery.
|
||||
// These constants are used to exclude build artifacts, dependencies,
|
||||
// version control, and temporary files from action file discovery.
|
||||
const (
|
||||
// Version Control System directories
|
||||
// DirGit = ".git" (already defined above in "Directory and path constants").
|
||||
DirGitHub = ".github"
|
||||
DirGitLab = ".gitlab"
|
||||
DirSVN = ".svn"
|
||||
|
||||
// JavaScript/Node.js dependencies.
|
||||
DirNodeModules = "node_modules"
|
||||
DirBowerComponents = "bower_components"
|
||||
|
||||
// Package manager vendor directories.
|
||||
DirVendor = "vendor"
|
||||
|
||||
// Python virtual environments and cache.
|
||||
DirVenv = "venv"
|
||||
DirVenvDot = ".venv"
|
||||
DirEnv = "env"
|
||||
DirTox = ".tox"
|
||||
DirPycache = "__pycache__"
|
||||
|
||||
// Build output directories.
|
||||
DirDist = "dist"
|
||||
DirBuild = "build"
|
||||
DirTarget = "target"
|
||||
DirOut = "out"
|
||||
|
||||
// IDE configuration directories.
|
||||
DirIdea = ".idea"
|
||||
DirVscode = ".vscode"
|
||||
|
||||
// Cache and temporary directories.
|
||||
DirCache = ".cache"
|
||||
DirTmp = "tmp"
|
||||
DirTmpDot = ".tmp"
|
||||
)
|
||||
|
||||
// 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"
|
||||
)
|
||||
|
||||
// GitHub Actions runner constants.
|
||||
const (
|
||||
// RunnerUbuntuLatest is the latest Ubuntu runner.
|
||||
RunnerUbuntuLatest = "ubuntu-latest"
|
||||
// RunnerWindowsLatest is the latest Windows runner.
|
||||
RunnerWindowsLatest = "windows-latest"
|
||||
// RunnerMacosLatest is the latest macOS runner.
|
||||
RunnerMacosLatest = "macos-latest"
|
||||
)
|
||||
|
||||
// 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"
|
||||
// FlagIgnoreDirs is the ignore-dirs flag name.
|
||||
FlagIgnoreDirs = "ignore-dirs"
|
||||
// FlagCI is the CI mode flag name.
|
||||
FlagCI = "ci"
|
||||
|
||||
// CommandPin is the pin command name.
|
||||
CommandPin = "pin"
|
||||
|
||||
// CacheStatsKeyDir is the cache stats key for directory.
|
||||
CacheStatsKeyDir = "cache_dir"
|
||||
)
|
||||
|
||||
// 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"
|
||||
// ErrFailedToApplyUpdatesWrapped is the failed to apply updates error with wrapping.
|
||||
ErrFailedToApplyUpdatesWrapped = "failed to apply updates: %w"
|
||||
// ErrFailedToDiscoverActionFiles is the failed to discover action files error with wrapping.
|
||||
ErrFailedToDiscoverActionFiles = "failed to discover action files: %w"
|
||||
// ErrPathTraversal is the path traversal attempt error.
|
||||
ErrPathTraversal = "path traversal detected: output path '%s' attempts to escape output directory '%s'"
|
||||
// ErrInvalidOutputPath is the invalid output path error.
|
||||
ErrInvalidOutputPath = "invalid output path: %w"
|
||||
// ErrFailedToResolveOutputPath is the failed to resolve output path error with wrapping.
|
||||
ErrFailedToResolveOutputPath = "failed to resolve output path: %w"
|
||||
)
|
||||
|
||||
// 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"
|
||||
)
|
||||
|
||||
// Test command names - used across multiple test files.
|
||||
const (
|
||||
TestCmdGen = "gen"
|
||||
TestCmdConfig = "config"
|
||||
TestCmdValidate = "validate"
|
||||
TestCmdDeps = "deps"
|
||||
TestCmdShow = "show"
|
||||
TestCmdList = "list"
|
||||
)
|
||||
|
||||
// Test file paths and names - used across multiple test files.
|
||||
const (
|
||||
TestTmpDir = "/tmp"
|
||||
TestTmpActionFile = "/tmp/action.yml"
|
||||
TestErrorScenarioOldDeps = "error-scenarios/action-with-old-deps.yml"
|
||||
TestErrorScenarioMissing = "error-scenarios/missing-required-fields.yml"
|
||||
TestErrorScenarioInvalid = "error-scenarios/invalid-yaml-syntax.yml"
|
||||
)
|
||||
|
||||
// TestMinimalAction is the minimal action YAML content for testing.
|
||||
const TestMinimalAction = "name: Test\ndescription: Test\nruns:\n using: composite\n steps: []"
|
||||
|
||||
// TestScenarioNoDeps is the common test scenario description for actions with no dependencies.
|
||||
const TestScenarioNoDeps = "handles action with no dependencies"
|
||||
|
||||
// Test messages and error strings - used in output tests.
|
||||
const (
|
||||
TestMsgFileNotFound = "File not found"
|
||||
TestMsgInvalidYAML = "Invalid YAML"
|
||||
TestMsgQuietSuppressOutput = "quiet mode suppresses output"
|
||||
TestMsgNoOutputInQuiet = "Expected no output in quiet mode, got %q"
|
||||
TestMsgVerifyPermissions = "Verify permissions"
|
||||
TestMsgSuggestions = "Suggestions"
|
||||
TestMsgDetails = "Details"
|
||||
TestMsgCheckFilePath = "Check the file path"
|
||||
TestMsgTryAgain = "Try again"
|
||||
TestMsgProcessingStarted = "Processing started"
|
||||
TestMsgOperationCompleted = "Operation completed"
|
||||
TestMsgOutputMissingEmoji = "Output missing error emoji: %q"
|
||||
)
|
||||
|
||||
// Test scenario names - used in output tests.
|
||||
const (
|
||||
TestScenarioColorEnabled = "with color enabled"
|
||||
TestScenarioColorDisabled = "with color disabled"
|
||||
TestScenarioQuietEnabled = "quiet mode enabled"
|
||||
TestScenarioQuietDisabled = "quiet mode disabled"
|
||||
)
|
||||
|
||||
// Test URLs and paths - used in output tests.
|
||||
const (
|
||||
TestURLHelp = "https://example.com/help"
|
||||
TestKeyFile = "file"
|
||||
TestKeyPath = "path"
|
||||
)
|
||||
|
||||
// Test wizard inputs and prompts - used in wizard tests.
|
||||
const (
|
||||
TestWizardInputYes = "y\n"
|
||||
TestWizardInputNo = "n\n"
|
||||
TestWizardInputYesYes = "y\ny\n"
|
||||
TestWizardInputTwo = "2\n"
|
||||
TestWizardInputTripleNL = "\n\n\n"
|
||||
TestWizardInputDoubleNL = "\n\n"
|
||||
TestWizardPromptContinue = "Continue?"
|
||||
TestWizardPromptEnter = "Enter value"
|
||||
)
|
||||
|
||||
// Test repository and organization names - used in wizard tests.
|
||||
const (
|
||||
TestOrgName = "testorg"
|
||||
TestRepoName = "testrepo"
|
||||
TestValue = "test"
|
||||
TestVersion = "v1.0.0"
|
||||
TestDocsPath = "./docs"
|
||||
)
|
||||
|
||||
// Test assertion messages - used in wizard tests.
|
||||
const (
|
||||
TestAssertTheme = "Theme = %q, want %q"
|
||||
)
|
||||
|
||||
// Test dependency actions - used in updater tests.
|
||||
const (
|
||||
TestActionCheckoutV4 = "actions/checkout@v4"
|
||||
TestActionCheckoutPinned = "actions/checkout@abc123 # v4.1.1"
|
||||
TestActionCheckoutFullSHA = "actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7"
|
||||
TestActionCheckoutSHA = "692973e3d937129bcbf40652eb9f2f61becf3332"
|
||||
TestActionCheckoutVersion = "v4.1.7"
|
||||
TestCacheKey = "test-key"
|
||||
TestUpdateTypePatch = "patch"
|
||||
TestDepsSimpleCheckoutFile = "dependencies/simple-test-checkout.yml"
|
||||
)
|
||||
|
||||
// Test paths and output - used in generator tests.
|
||||
const (
|
||||
TestOutputPath = "/tmp/output"
|
||||
)
|
||||
|
||||
// Test HTML content - used in html tests.
|
||||
const (
|
||||
TestHTMLNewContent = "New content"
|
||||
TestHTMLClosingTag = "\n</html>"
|
||||
TestMsgFailedToReadOutput = "Failed to read output file: %v"
|
||||
)
|
||||
|
||||
// Test detector messages - used in detector tests.
|
||||
const (
|
||||
TestMsgFailedToCreateAction = "Failed to create action.yml: %v"
|
||||
TestPermRead = "read"
|
||||
TestPermWrite = "write"
|
||||
TestPermContents = "contents"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
212
appconstants/constants_test.go
Normal file
212
appconstants/constants_test.go
Normal file
@@ -0,0 +1,212 @@
|
||||
package appconstants
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const testModifiedValue = "modified"
|
||||
|
||||
// TestGetSupportedThemes tests the GetSupportedThemes function.
|
||||
func TestGetSupportedThemes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
themes := GetSupportedThemes()
|
||||
|
||||
// Check that we get a non-empty slice
|
||||
if len(themes) == 0 {
|
||||
t.Error("GetSupportedThemes() returned empty slice")
|
||||
}
|
||||
|
||||
// Check that known themes are included
|
||||
expectedThemes := []string{ThemeDefault, ThemeGitHub, ThemeMinimal, ThemeProfessional}
|
||||
for _, expected := range expectedThemes {
|
||||
found := false
|
||||
for _, theme := range themes {
|
||||
if theme == expected {
|
||||
found = true
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("GetSupportedThemes() missing expected theme: %s", expected)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify it returns a copy (modifying returned slice shouldn't affect original)
|
||||
themes1 := GetSupportedThemes()
|
||||
themes2 := GetSupportedThemes()
|
||||
if len(themes1) != len(themes2) {
|
||||
t.Error("GetSupportedThemes() not returning consistent results")
|
||||
}
|
||||
|
||||
// Modify the returned slice
|
||||
if len(themes1) > 0 {
|
||||
themes1[0] = testModifiedValue
|
||||
// Get a fresh copy
|
||||
themes3 := GetSupportedThemes()
|
||||
// Should not be modified
|
||||
if themes3[0] == testModifiedValue {
|
||||
t.Error("GetSupportedThemes() not returning a copy - original was modified")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetConfigSearchPaths tests the GetConfigSearchPaths function.
|
||||
func TestGetConfigSearchPaths(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
paths := GetConfigSearchPaths()
|
||||
|
||||
// Check that we get a non-empty slice
|
||||
if len(paths) == 0 {
|
||||
t.Error("GetConfigSearchPaths() returned empty slice")
|
||||
}
|
||||
|
||||
// Check that it contains path-like strings
|
||||
for _, path := range paths {
|
||||
if path == "" {
|
||||
t.Error("GetConfigSearchPaths() contains empty string")
|
||||
}
|
||||
|
||||
// Validate path doesn't contain traversal components
|
||||
if strings.Contains(path, "..") {
|
||||
t.Errorf("GetConfigSearchPaths() path %q contains unsafe .. component", path)
|
||||
}
|
||||
|
||||
// Validate path is already cleaned
|
||||
cleanPath := filepath.Clean(path)
|
||||
if path != cleanPath {
|
||||
t.Errorf("GetConfigSearchPaths() path %q is not cleaned (should be %q)", path, cleanPath)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify it returns a copy (modifying returned slice shouldn't affect original)
|
||||
paths1 := GetConfigSearchPaths()
|
||||
paths2 := GetConfigSearchPaths()
|
||||
if len(paths1) != len(paths2) {
|
||||
t.Error("GetConfigSearchPaths() not returning consistent results")
|
||||
}
|
||||
|
||||
// Modify the returned slice
|
||||
if len(paths1) > 0 {
|
||||
paths1[0] = testModifiedValue
|
||||
// Get a fresh copy
|
||||
paths3 := GetConfigSearchPaths()
|
||||
// Should not be modified
|
||||
if paths3[0] == testModifiedValue {
|
||||
t.Error("GetConfigSearchPaths() not returning a copy - original was modified")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetDefaultIgnoredDirectories tests the GetDefaultIgnoredDirectories function.
|
||||
func TestGetDefaultIgnoredDirectories(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dirs := GetDefaultIgnoredDirectories()
|
||||
|
||||
// Check that we get a non-empty slice
|
||||
if len(dirs) == 0 {
|
||||
t.Error("GetDefaultIgnoredDirectories() returned empty slice")
|
||||
}
|
||||
|
||||
// Check that known ignored directories are included
|
||||
expectedDirs := []string{DirGit, DirNodeModules, DirVendor, DirDist}
|
||||
for _, expected := range expectedDirs {
|
||||
found := false
|
||||
for _, dir := range dirs {
|
||||
if dir == expected {
|
||||
found = true
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("GetDefaultIgnoredDirectories() missing expected directory: %s", expected)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify it returns a copy (modifying returned slice shouldn't affect original)
|
||||
dirs1 := GetDefaultIgnoredDirectories()
|
||||
dirs2 := GetDefaultIgnoredDirectories()
|
||||
if len(dirs1) != len(dirs2) {
|
||||
t.Error("GetDefaultIgnoredDirectories() not returning consistent results")
|
||||
}
|
||||
|
||||
// Modify the returned slice
|
||||
if len(dirs1) > 0 {
|
||||
dirs1[0] = testModifiedValue
|
||||
// Get a fresh copy
|
||||
dirs3 := GetDefaultIgnoredDirectories()
|
||||
// Should not be modified
|
||||
if dirs3[0] == testModifiedValue {
|
||||
t.Error("GetDefaultIgnoredDirectories() not returning a copy - original was modified")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigurationSourceString tests the String method for ConfigurationSource.
|
||||
func TestConfigurationSourceString(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
source ConfigurationSource
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "defaults source",
|
||||
source: SourceDefaults,
|
||||
want: ConfigKeyDefaults,
|
||||
},
|
||||
{
|
||||
name: "global source",
|
||||
source: SourceGlobal,
|
||||
want: ScopeGlobal,
|
||||
},
|
||||
{
|
||||
name: "repo override source",
|
||||
source: SourceRepoOverride,
|
||||
want: "repo-override",
|
||||
},
|
||||
{
|
||||
name: "repo config source",
|
||||
source: SourceRepoConfig,
|
||||
want: "repo-config",
|
||||
},
|
||||
{
|
||||
name: "action config source",
|
||||
source: SourceActionConfig,
|
||||
want: "action-config",
|
||||
},
|
||||
{
|
||||
name: "environment source",
|
||||
source: SourceEnvironment,
|
||||
want: "environment",
|
||||
},
|
||||
{
|
||||
name: "CLI flags source",
|
||||
source: SourceCLIFlags,
|
||||
want: "cli-flags",
|
||||
},
|
||||
{
|
||||
name: "unknown source",
|
||||
source: ConfigurationSource(999),
|
||||
want: "unknown",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := tt.source.String()
|
||||
if got != tt.want {
|
||||
t.Errorf("ConfigurationSource.String() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
76
docs/COMMIT_MESSAGES.md
Normal file
76
docs/COMMIT_MESSAGES.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# Semantic Commit Messages
|
||||
|
||||
This project follows [Conventional Commits](https://www.conventionalcommits.org/) specification for commit messages.
|
||||
|
||||
## Format
|
||||
|
||||
```text
|
||||
<type>(<scope>): <subject>
|
||||
|
||||
<body>
|
||||
|
||||
<footer>
|
||||
```
|
||||
|
||||
### Type
|
||||
|
||||
Must be one of the following:
|
||||
|
||||
- **feat**: A new feature
|
||||
- **fix**: A bug fix
|
||||
- **docs**: Documentation only changes
|
||||
- **style**: Changes that do not affect the meaning of the code (white-space, formatting, etc)
|
||||
- **refactor**: A code change that neither fixes a bug nor adds a feature
|
||||
- **perf**: A code change that improves performance
|
||||
- **test**: Adding missing tests or correcting existing tests
|
||||
- **chore**: Changes to the build process or auxiliary tools
|
||||
- **ci**: Changes to CI configuration files and scripts
|
||||
- **build**: Changes that affect the build system or external dependencies
|
||||
- **revert**: Reverts a previous commit
|
||||
|
||||
### Scope
|
||||
|
||||
The scope is optional and can be anything specifying the place of the commit change.
|
||||
|
||||
### Subject
|
||||
|
||||
The subject contains a succinct description of the change:
|
||||
|
||||
- Use the imperative, present tense: "change" not "changed" nor "changes"
|
||||
- Don't capitalize the first letter
|
||||
- No dot (.) at the end
|
||||
|
||||
### Examples
|
||||
|
||||
```text
|
||||
feat: add support for AsciiDoc output format
|
||||
fix: correct template rendering for empty descriptions
|
||||
docs: update installation instructions
|
||||
chore: prepare release v1.2.3
|
||||
ci: update cosign version to v2.4.0
|
||||
```
|
||||
|
||||
## Validation
|
||||
|
||||
Commit messages are validated using commitlint:
|
||||
|
||||
- **Pre-commit hook**: Validates commit messages before they are created (if pre-commit is installed)
|
||||
- **CI/CD**: GitHub Actions workflow validates all commits in pull requests
|
||||
- **Release script**: Warns if recent commits don't follow the format
|
||||
|
||||
## Setup
|
||||
|
||||
To enable local commit message validation:
|
||||
|
||||
```bash
|
||||
# Install pre-commit hooks
|
||||
make pre-commit-install
|
||||
|
||||
# Or manually
|
||||
npm install
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- [Conventional Commits](https://www.conventionalcommits.org/)
|
||||
- [Commitlint](https://commitlint.js.org/)
|
||||
18
docs/api.md
18
docs/api.md
@@ -36,21 +36,21 @@ gh-action-readme gen [directory_or_file] [flags]
|
||||
#### Output Options
|
||||
|
||||
| Flag | Short | Type | Default | Description |
|
||||
|------|-------|------|---------|-------------|
|
||||
| ------ | ------- | ------ | --------- | ------------- |
|
||||
| `--output-format` | `-f` | string | `md` | Output format: md, html, json, asciidoc |
|
||||
| `--output-dir` | `-o` | string | `.` | Output directory for generated files |
|
||||
| `--output` | | string | | Custom output filename (overrides default naming) |
|
||||
|
||||
#### Theme Options
|
||||
|
||||
| Flag | Short | Type | Default | Description |
|
||||
|------|-------|------|---------|-------------|
|
||||
| `--theme` | `-t` | string | `default` | Theme: github, gitlab, minimal, professional, default |
|
||||
| Flag | Short | Type | Default | Description |
|
||||
| --------- | ----- | ------ | --------- | -------------------------------------------------------- |
|
||||
| `--theme` | `-t` | string | `default` | Theme: github, gitlab, minimal, professional, default |
|
||||
|
||||
#### Processing Options
|
||||
|
||||
| Flag | Short | Type | Default | Description |
|
||||
|------|-------|------|---------|-------------|
|
||||
| ------ | ------- | ------ | --------- | ------------- |
|
||||
| `--recursive` | `-r` | boolean | `false` | Search directories recursively for action.yml files |
|
||||
| `--quiet` | `-q` | boolean | `false` | Suppress progress output |
|
||||
| `--verbose` | `-v` | boolean | `false` | Enable verbose logging |
|
||||
@@ -58,7 +58,7 @@ gh-action-readme gen [directory_or_file] [flags]
|
||||
#### GitHub Integration
|
||||
|
||||
| Flag | Short | Type | Default | Description |
|
||||
|------|-------|------|---------|-------------|
|
||||
| ------ | ------- | ------ | --------- | ------------- |
|
||||
| `--github-token` | | string | | GitHub personal access token (or use GITHUB_TOKEN env) |
|
||||
| `--no-dependencies` | | boolean | `false` | Disable dependency analysis |
|
||||
|
||||
@@ -152,7 +152,7 @@ gh-action-readme validate [file_or_directory] [flags]
|
||||
### Flags
|
||||
|
||||
| Flag | Short | Type | Default | Description |
|
||||
|------|-------|------|---------|-------------|
|
||||
| ------ | ------- | ------ | --------- | ------------- |
|
||||
| `--verbose` | `-v` | boolean | `false` | Show detailed validation messages |
|
||||
| `--quiet` | `-q` | boolean | `false` | Only show errors, suppress warnings |
|
||||
| `--recursive` | `-r` | boolean | `false` | Validate recursively |
|
||||
@@ -343,7 +343,7 @@ gh-action-readme help config wizard
|
||||
These flags are available for all commands:
|
||||
|
||||
| Flag | Short | Type | Default | Description |
|
||||
|------|-------|------|---------|-------------|
|
||||
| ------ | ------- | ------ | --------- | ------------- |
|
||||
| `--config` | | string | | Custom configuration file path |
|
||||
| `--help` | `-h` | boolean | `false` | Show help for command |
|
||||
| `--quiet` | `-q` | boolean | `false` | Suppress non-error output |
|
||||
@@ -352,7 +352,7 @@ These flags are available for all commands:
|
||||
## 📊 Exit Codes
|
||||
|
||||
| Code | Description |
|
||||
|------|-------------|
|
||||
| ------ | ------------- |
|
||||
| `0` | Success |
|
||||
| `1` | General error |
|
||||
| `2` | Invalid arguments |
|
||||
|
||||
@@ -33,7 +33,7 @@ cache_ttl: 3600
|
||||
### Core Settings
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| -------- | ------ | --------- | ------------- |
|
||||
| `theme` | string | `default` | Default theme to use |
|
||||
| `output_format` | string | `md` | Default output format |
|
||||
| `output_dir` | string | `.` | Default output directory |
|
||||
@@ -42,7 +42,7 @@ cache_ttl: 3600
|
||||
### GitHub Integration
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| -------- | ------ | --------- | ------------- |
|
||||
| `github_token` | string | `""` | GitHub personal access token |
|
||||
| `dependencies_enabled` | boolean | `true` | Enable dependency analysis |
|
||||
| `rate_limit_delay` | int | `1000` | Delay between API calls (ms) |
|
||||
@@ -50,7 +50,7 @@ cache_ttl: 3600
|
||||
### Performance Settings
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| -------- | ------ | --------- | ------------- |
|
||||
| `cache_ttl` | int | `3600` | Cache TTL in seconds |
|
||||
| `concurrent_requests` | int | `3` | Max concurrent GitHub API requests |
|
||||
| `timeout` | int | `30` | Request timeout in seconds |
|
||||
|
||||
@@ -92,7 +92,7 @@ gh-action-readme gen --theme default
|
||||
## 🎯 Theme Comparison
|
||||
|
||||
| Feature | GitHub | GitLab | Minimal | Professional | Default |
|
||||
|---------|--------|--------|---------|-------------|---------|
|
||||
| --------- | -------- | -------- | --------- | ------------- | --------- |
|
||||
| **Badges** | ✅ Rich | ✅ GitLab | ❌ None | ✅ Comprehensive | ❌ None |
|
||||
| **TOC** | ✅ Yes | ✅ Yes | ❌ No | ✅ Advanced | ❌ No |
|
||||
| **Examples** | ✅ GitHub | ✅ CI/CD | ✅ Basic | ✅ Comprehensive | ✅ Basic |
|
||||
@@ -146,10 +146,10 @@ runs:
|
||||
<details>
|
||||
<summary>📋 Inputs</summary>
|
||||
|
||||
| Input | Description | Required | Default |
|
||||
|-------|-------------|----------|---------|
|
||||
| `aws-region` | AWS region to deploy to | Yes | `us-east-1` |
|
||||
| `environment` | Deployment environment | No | `production` |
|
||||
| Input | Description | Required | Default |
|
||||
| ------------- | ------------------------ | -------- | ------------- |
|
||||
| `aws-region` | AWS region to deploy to | Yes | `us-east-1` |
|
||||
| `environment` | Deployment environment | No | `production` |
|
||||
|
||||
</details>
|
||||
```
|
||||
|
||||
@@ -147,7 +147,7 @@ gh-action-readme gen --output-format json --output api/action.json
|
||||
## 📄 Output Formats
|
||||
|
||||
| Format | Description | Use Case | Extension |
|
||||
|--------|-------------|----------|-----------|
|
||||
| -------- | ------------- | ---------- | ----------- |
|
||||
| **md** | Markdown (default) | GitHub README files | `.md` |
|
||||
| **html** | Styled HTML | Web documentation | `.html` |
|
||||
| **json** | Structured data | API integration | `.json` |
|
||||
@@ -174,7 +174,7 @@ gh-action-readme gen --output-format asciidoc --output docs/action.adoc
|
||||
See [themes.md](themes.md) for detailed theme documentation.
|
||||
|
||||
| Theme | Best For | Features |
|
||||
|-------|----------|----------|
|
||||
| ------- | ---------- | ---------- |
|
||||
| **github** | GitHub marketplace | Badges, collapsible sections |
|
||||
| **gitlab** | GitLab repositories | CI/CD examples |
|
||||
| **minimal** | Simple actions | Clean, concise |
|
||||
|
||||
23
go.mod
23
go.mod
@@ -2,38 +2,39 @@ module github.com/ivuorinen/gh-action-readme
|
||||
|
||||
go 1.24.0
|
||||
|
||||
toolchain go1.25.1
|
||||
toolchain go1.25.6
|
||||
|
||||
require (
|
||||
github.com/adrg/xdg v0.5.3
|
||||
github.com/fatih/color v1.18.0
|
||||
github.com/goccy/go-yaml v1.18.0
|
||||
github.com/goccy/go-yaml v1.19.2
|
||||
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/spf13/cobra v1.10.1
|
||||
github.com/leanovate/gopter v0.2.11
|
||||
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.31.0
|
||||
golang.org/x/oauth2 v0.34.0
|
||||
)
|
||||
|
||||
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
|
||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
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.34.0 // indirect
|
||||
golang.org/x/term v0.33.0 // indirect
|
||||
golang.org/x/text v0.28.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
|
||||
)
|
||||
|
||||
636
go.sum
636
go.sum
@@ -1,87 +1,683 @@
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
|
||||
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
|
||||
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
|
||||
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
|
||||
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
|
||||
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
|
||||
cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
|
||||
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
|
||||
cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
|
||||
cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
|
||||
cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
|
||||
cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
|
||||
cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
|
||||
cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI=
|
||||
cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk=
|
||||
cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg=
|
||||
cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8=
|
||||
cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0=
|
||||
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
|
||||
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
|
||||
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
|
||||
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
|
||||
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
|
||||
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
|
||||
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
|
||||
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
|
||||
cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk=
|
||||
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
|
||||
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
|
||||
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
|
||||
cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
|
||||
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
|
||||
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
|
||||
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
|
||||
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
|
||||
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
|
||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
|
||||
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
|
||||
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
|
||||
github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
|
||||
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
|
||||
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
|
||||
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
|
||||
github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM=
|
||||
github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY=
|
||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
||||
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
||||
github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
|
||||
github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
|
||||
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
|
||||
github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
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.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
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/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
|
||||
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
||||
github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
||||
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
||||
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
|
||||
github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||
github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM=
|
||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.6/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/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
||||
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
||||
github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
||||
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
||||
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
|
||||
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
|
||||
github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q=
|
||||
github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
|
||||
github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
|
||||
github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
|
||||
github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
|
||||
github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU=
|
||||
github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=
|
||||
github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
|
||||
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90=
|
||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
|
||||
github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ=
|
||||
github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I=
|
||||
github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
||||
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzWu4=
|
||||
github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c=
|
||||
github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
|
||||
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
|
||||
github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
|
||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ=
|
||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw=
|
||||
github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
|
||||
github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg=
|
||||
github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY=
|
||||
github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||
github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo=
|
||||
github.com/neelance/sourcemap v0.0.0-20200213170602-2833bce08e4c/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM=
|
||||
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
|
||||
github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
|
||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||
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/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
|
||||
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.19.0 h1:Ea18xuIRQXLAUidVDox3AbwfUhD0/1IvohyTutOIFoc=
|
||||
github.com/schollz/progressbar/v3 v3.19.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec=
|
||||
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
|
||||
github.com/shurcooL/go v0.0.0-20200502201357-93f07166e636/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk=
|
||||
github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||
github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw=
|
||||
github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
|
||||
github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60=
|
||||
github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
|
||||
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.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
|
||||
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.2.1/go.mod h1:ExllRjgxM/piMAM+3tAZvg8fsklGAf3tPfi+i8t68Nk=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.8.1/go.mod h1:o0Pch8wJ9BVSWGQMbra6iw0oQ5oktSIBaujf1rJH9Ns=
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.etcd.io/etcd/api/v3 v3.5.0/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs=
|
||||
go.etcd.io/etcd/client/pkg/v3 v3.5.0/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g=
|
||||
go.etcd.io/etcd/client/v2 v2.305.0/go.mod h1:h9puh54ZTgAKtEbut2oe9P4L/oqKCVB6xsXlzd7alYQ=
|
||||
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
|
||||
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
|
||||
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
|
||||
go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
|
||||
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
|
||||
go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo=
|
||||
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.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo=
|
||||
golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
|
||||
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
|
||||
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
|
||||
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
|
||||
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
||||
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
|
||||
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||
golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||
golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
|
||||
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
|
||||
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
||||
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
|
||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc=
|
||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
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/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
||||
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg=
|
||||
golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=
|
||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
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.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
|
||||
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.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
|
||||
golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
|
||||
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
|
||||
golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||
golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE=
|
||||
golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
|
||||
golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
|
||||
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
|
||||
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
||||
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
||||
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
||||
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
||||
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
||||
google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||
google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||
google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||
google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||
google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||
google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
|
||||
google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
|
||||
google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
|
||||
google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
|
||||
google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg=
|
||||
google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE=
|
||||
google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=
|
||||
google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU=
|
||||
google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94=
|
||||
google.golang.org/api v0.44.0/go.mod h1:EBOGZqzyhtvMDoxwS97ctnh0zUmYY6CxqXsc1AvkYD8=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
|
||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
|
||||
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||
google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||
google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||
google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||
google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
|
||||
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||
google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
|
||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||
google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
|
||||
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A=
|
||||
google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
|
||||
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
|
||||
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||
google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||
google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0=
|
||||
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
||||
google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8=
|
||||
google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
|
||||
google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
|
||||
google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
|
||||
google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
|
||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/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=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
||||
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
||||
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
||||
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
|
||||
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
|
||||
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,10 +89,10 @@ 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"},
|
||||
Details: map[string]string{"file": appconstants.ActionFileNameYML},
|
||||
Suggestions: []string{
|
||||
"Check required fields",
|
||||
"Validate YAML syntax",
|
||||
@@ -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,13 @@ import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
// 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 +21,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]
|
||||
}
|
||||
|
||||
@@ -78,7 +77,7 @@ func getFileNotFoundSuggestions(context map[string]string) []string {
|
||||
}
|
||||
|
||||
// Suggest common file names if looking for action files
|
||||
if strings.Contains(path, "action") {
|
||||
if strings.Contains(path, testutil.ConfigFieldAction) {
|
||||
suggestions = append(suggestions,
|
||||
"Common action file names: action.yml, action.yaml",
|
||||
"Check if the file is in a subdirectory",
|
||||
501
internal/apperrors/suggestions_test.go
Normal file
501
internal/apperrors/suggestions_test.go
Normal file
@@ -0,0 +1,501 @@
|
||||
package apperrors
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
func TestGetSuggestions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
code appconstants.ErrorCode
|
||||
context map[string]string
|
||||
contains []string
|
||||
}{
|
||||
{
|
||||
name: "file not found with path",
|
||||
code: appconstants.ErrCodeFileNotFound,
|
||||
context: testutil.ContextWithPath("/path/to/action.yml"),
|
||||
contains: []string{
|
||||
"Check if the file exists: /path/to/action.yml",
|
||||
"Verify the file path is correct",
|
||||
"--recursive flag",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "file not found action file",
|
||||
code: appconstants.ErrCodeFileNotFound,
|
||||
context: testutil.ContextWithPath("/project/action.yml"),
|
||||
contains: []string{
|
||||
"Common action file names: action.yml, action.yaml",
|
||||
"Check if the file is in a subdirectory",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "permission denied",
|
||||
code: appconstants.ErrCodePermission,
|
||||
context: testutil.ContextWithPath("/restricted/file.txt"),
|
||||
contains: []string{
|
||||
"Check file permissions: ls -la /restricted/file.txt",
|
||||
"chmod 644 /restricted/file.txt",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid YAML with line number",
|
||||
code: appconstants.ErrCodeInvalidYAML,
|
||||
context: testutil.ContextWithLine("25"),
|
||||
contains: []string{
|
||||
"Error near line 25",
|
||||
"Check YAML indentation",
|
||||
"use spaces, not tabs",
|
||||
"YAML validator",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid YAML with tab error",
|
||||
code: appconstants.ErrCodeInvalidYAML,
|
||||
context: testutil.ContextWithError("found character that cannot start any token (tab)"),
|
||||
contains: []string{
|
||||
"YAML files must use spaces for indentation, not tabs",
|
||||
"Replace all tabs with spaces",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid action with missing fields",
|
||||
code: appconstants.ErrCodeInvalidAction,
|
||||
context: testutil.ContextWithMissingFields("name, description"),
|
||||
contains: []string{
|
||||
"Missing required fields: name, description",
|
||||
"required fields: name, description",
|
||||
"gh-action-readme schema",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: testutil.TestCaseNameNoActionFiles,
|
||||
code: appconstants.ErrCodeNoActionFiles,
|
||||
context: testutil.ContextWithDirectory("/project"),
|
||||
contains: []string{
|
||||
"Current directory: /project",
|
||||
"find /project -name 'action.y*ml'",
|
||||
"--recursive flag",
|
||||
"action.yml or action.yaml",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "GitHub API 401 error",
|
||||
code: appconstants.ErrCodeGitHubAPI,
|
||||
context: testutil.ContextWithStatusCode("401"),
|
||||
contains: []string{
|
||||
"Authentication failed",
|
||||
"check your GitHub token",
|
||||
"Token may be expired",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "GitHub API 403 error",
|
||||
code: appconstants.ErrCodeGitHubAPI,
|
||||
context: testutil.ContextWithStatusCode("403"),
|
||||
contains: []string{
|
||||
"Access forbidden",
|
||||
"check token permissions",
|
||||
"rate limit",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "GitHub API 404 error",
|
||||
code: appconstants.ErrCodeGitHubAPI,
|
||||
context: testutil.ContextWithStatusCode("404"),
|
||||
contains: []string{
|
||||
"Repository or resource not found",
|
||||
"repository is private",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "GitHub rate limit",
|
||||
code: appconstants.ErrCodeGitHubRateLimit,
|
||||
context: testutil.EmptyContext(),
|
||||
contains: []string{
|
||||
"rate limit exceeded",
|
||||
"GITHUB_TOKEN",
|
||||
"gh auth login",
|
||||
"Rate limits reset every hour",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "GitHub auth",
|
||||
code: appconstants.ErrCodeGitHubAuth,
|
||||
context: testutil.EmptyContext(),
|
||||
contains: []string{
|
||||
"export GITHUB_TOKEN",
|
||||
"gh auth login",
|
||||
"https://github.com/settings/tokens",
|
||||
"'repo' scope",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "configuration error with path",
|
||||
code: appconstants.ErrCodeConfiguration,
|
||||
context: testutil.ContextWithConfigPath("~/.config/gh-action-readme/config.yaml"),
|
||||
contains: []string{
|
||||
"Config path: ~/.config/gh-action-readme/config.yaml",
|
||||
"ls -la ~/.config/gh-action-readme/config.yaml",
|
||||
"gh-action-readme config init",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "validation error with invalid fields",
|
||||
code: appconstants.ErrCodeValidation,
|
||||
context: testutil.ContextWithField("invalid_fields", "runs.using, inputs.test"),
|
||||
contains: []string{
|
||||
"Invalid fields: runs.using, inputs.test",
|
||||
"Check spelling and nesting",
|
||||
"gh-action-readme schema",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "template error with theme",
|
||||
code: appconstants.ErrCodeTemplateRender,
|
||||
context: testutil.ContextWithField("theme", "custom"),
|
||||
contains: []string{
|
||||
"Current theme: custom",
|
||||
"Try using a different theme",
|
||||
"Available themes:",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "file write error with output path",
|
||||
code: appconstants.ErrCodeFileWrite,
|
||||
context: testutil.ContextWithField("output_path", "/output/README.md"),
|
||||
contains: []string{
|
||||
"Output directory: /output",
|
||||
"Check permissions: ls -la /output",
|
||||
"mkdir -p /output",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dependency analysis error",
|
||||
code: appconstants.ErrCodeDependencyAnalysis,
|
||||
context: testutil.ContextWithField("action", "my-action"),
|
||||
contains: []string{
|
||||
"Analyzing action: my-action",
|
||||
"GitHub token is set",
|
||||
"composite actions",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "cache access error",
|
||||
code: appconstants.ErrCodeCacheAccess,
|
||||
context: testutil.ContextWithField("cache_path", "~/.cache/gh-action-readme"),
|
||||
contains: []string{
|
||||
"Cache path: ~/.cache/gh-action-readme",
|
||||
"gh-action-readme cache clear",
|
||||
"permissions: ls -la ~/.cache/gh-action-readme",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unknown error code",
|
||||
code: "UNKNOWN_TEST_CODE",
|
||||
context: testutil.EmptyContext(),
|
||||
contains: []string{
|
||||
"Check the error message",
|
||||
"--verbose flag",
|
||||
"project documentation",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
suggestions := GetSuggestions(tt.code, tt.context)
|
||||
testutil.AssertSliceContainsAll(t, suggestions, tt.contains)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPermissionSuggestionsOSSpecific(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
context := testutil.ContextWithPath("/test/file")
|
||||
suggestions := getPermissionSuggestions(context)
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
testutil.AssertSliceContainsAll(t, suggestions, []string{"Administrator", "Windows file permissions"})
|
||||
default:
|
||||
testutil.AssertSliceContainsAll(t, suggestions, []string{"sudo", "ls -la"})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSuggestionsEmptyContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Test that all error codes work with empty context
|
||||
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 {
|
||||
t.Run(string(code), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
suggestions := GetSuggestions(code, testutil.EmptyContext())
|
||||
if len(suggestions) == 0 {
|
||||
t.Errorf("GetSuggestions(%s, {}) returned empty slice", code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFileNotFoundSuggestionsActionFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
context := testutil.ContextWithPath("/project/action.yml")
|
||||
|
||||
suggestions := getFileNotFoundSuggestions(context)
|
||||
testutil.AssertSliceContainsAll(t, suggestions, []string{"action.yml, action.yaml", "subdirectory"})
|
||||
}
|
||||
|
||||
func TestGetInvalidYAMLSuggestionsTabError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
context := testutil.ContextWithError("found character that cannot start any token, tab character")
|
||||
|
||||
suggestions := getInvalidYAMLSuggestions(context)
|
||||
testutil.AssertSliceContainsAll(t, suggestions, []string{"tabs with spaces"})
|
||||
}
|
||||
|
||||
func TestGetGitHubAPISuggestionsStatusCodes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
statusCodes := map[string]string{
|
||||
"401": "Authentication failed",
|
||||
"403": "Access forbidden",
|
||||
"404": "not found",
|
||||
}
|
||||
|
||||
for code, expectedText := range statusCodes {
|
||||
t.Run("status_"+code, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
context := testutil.ContextWithStatusCode(code)
|
||||
suggestions := getGitHubAPISuggestions(context)
|
||||
testutil.AssertSliceContainsAll(t, suggestions, []string{expectedText})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetValidationSuggestions tests the getValidationSuggestions function.
|
||||
func TestGetValidationSuggestions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
context map[string]string
|
||||
expectedContains []string
|
||||
}{
|
||||
{
|
||||
name: "basic validation suggestions",
|
||||
context: map[string]string{},
|
||||
expectedContains: []string{
|
||||
"Review validation errors",
|
||||
"Check required fields",
|
||||
"Use 'gh-action-readme schema' to see valid structure",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with invalid_fields context",
|
||||
context: testutil.ContextWithField("invalid_fields", "runs.using, description"),
|
||||
expectedContains: []string{
|
||||
"Invalid fields: runs.using, description",
|
||||
"Check spelling and nesting",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with validation_type required",
|
||||
context: testutil.ContextWithField("validation_type", "required"),
|
||||
expectedContains: []string{
|
||||
"Add missing required fields",
|
||||
"name, description, runs",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with validation_type type",
|
||||
context: testutil.ContextWithField("validation_type", "type"),
|
||||
expectedContains: []string{
|
||||
"Ensure field values match expected types",
|
||||
"Strings should be quoted",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with both invalid_fields and validation_type",
|
||||
context: testutil.MergeContexts(
|
||||
testutil.ContextWithField("invalid_fields", "name"),
|
||||
testutil.ContextWithField("validation_type", "required"),
|
||||
),
|
||||
expectedContains: []string{
|
||||
"Invalid fields: name",
|
||||
"Add missing required fields",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
suggestions := getValidationSuggestions(tt.context)
|
||||
testutil.AssertSliceContainsAll(t, suggestions, tt.expectedContains)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetConfigurationSuggestions tests the getConfigurationSuggestions function.
|
||||
func TestGetConfigurationSuggestions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
context map[string]string
|
||||
expectedContains []string
|
||||
}{
|
||||
{
|
||||
name: "basic configuration suggestions",
|
||||
context: map[string]string{},
|
||||
expectedContains: []string{
|
||||
"Check configuration file syntax",
|
||||
"Ensure configuration file exists",
|
||||
"Use 'gh-action-readme config init'",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with config_path context",
|
||||
context: testutil.ContextWithConfigPath("/path/to/config.yaml"),
|
||||
expectedContains: []string{
|
||||
"Config path: /path/to/config.yaml",
|
||||
"Check if file exists: ls -la /path/to/config.yaml",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with permission error in context",
|
||||
context: testutil.ContextWithError("permission denied"),
|
||||
expectedContains: []string{
|
||||
"Check file permissions for config file",
|
||||
"Ensure parent directory is writable",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with both config_path and permission error",
|
||||
context: testutil.MergeContexts(
|
||||
testutil.ContextWithConfigPath("/restricted/config.yaml"),
|
||||
testutil.ContextWithError("permission denied while reading"),
|
||||
),
|
||||
expectedContains: []string{
|
||||
"Config path: /restricted/config.yaml",
|
||||
"Check file permissions for config file",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: testutil.TestCaseNamePathTraversal,
|
||||
context: testutil.ContextWithConfigPath("../../../etc/passwd"),
|
||||
expectedContains: []string{
|
||||
"Check configuration file syntax",
|
||||
"Ensure configuration file exists",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
suggestions := getConfigurationSuggestions(tt.context)
|
||||
testutil.AssertSliceContainsAll(t, suggestions, tt.expectedContains)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetTemplateSuggestions tests the getTemplateSuggestions function.
|
||||
func TestGetTemplateSuggestions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
context map[string]string
|
||||
expectedContains []string
|
||||
}{
|
||||
{
|
||||
name: "basic template suggestions",
|
||||
context: map[string]string{},
|
||||
expectedContains: []string{
|
||||
"Check template syntax",
|
||||
"Ensure all template variables are defined",
|
||||
"Verify custom template path is correct",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with template_path context",
|
||||
context: testutil.ContextWithField("template_path", "/path/to/custom-template.tmpl"),
|
||||
expectedContains: []string{
|
||||
"Template path: /path/to/custom-template.tmpl",
|
||||
"Ensure template file exists and is readable",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with theme context",
|
||||
context: testutil.ContextWithField("theme", "custom-theme"),
|
||||
expectedContains: []string{
|
||||
"Current theme: custom-theme",
|
||||
"Try using a different theme: --theme github",
|
||||
"Available themes: default, github, gitlab, minimal, professional",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with both template_path and theme",
|
||||
context: testutil.MergeContexts(
|
||||
testutil.ContextWithField("template_path", "/custom/template.tmpl"),
|
||||
testutil.ContextWithField("theme", "github"),
|
||||
),
|
||||
expectedContains: []string{
|
||||
"Template path: /custom/template.tmpl",
|
||||
"Current theme: github",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: testutil.TestCaseNamePathTraversal,
|
||||
context: testutil.ContextWithField("template_path", "../../../../../../etc/passwd"),
|
||||
expectedContains: []string{
|
||||
"Check template syntax",
|
||||
"Ensure all template variables are defined",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
suggestions := getTemplateSuggestions(tt.context)
|
||||
testutil.AssertSliceContainsAll(t, suggestions, tt.expectedContains)
|
||||
})
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
173
internal/cache/cache_test.go
vendored
173
internal/cache/cache_test.go
vendored
@@ -69,12 +69,12 @@ func TestNewCache(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache_SetAndGet(t *testing.T) {
|
||||
func TestCacheSetAndGet(t *testing.T) {
|
||||
tmpDir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
cache := createTestCache(t, tmpDir)
|
||||
defer func() { _ = cache.Close() }()
|
||||
defer testutil.CleanupCache(t, cache)()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -84,9 +84,9 @@ func TestCache_SetAndGet(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "string value",
|
||||
key: "test-key",
|
||||
value: "test-value",
|
||||
expected: "test-value",
|
||||
key: testutil.CacheTestKey,
|
||||
value: testutil.CacheTestValue,
|
||||
expected: testutil.CacheTestValue,
|
||||
},
|
||||
{
|
||||
name: "struct value",
|
||||
@@ -121,20 +121,20 @@ func TestCache_SetAndGet(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache_TTL(t *testing.T) {
|
||||
func TestCacheTTL(t *testing.T) {
|
||||
tmpDir, cleanup := testutil.TempDir(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
|
||||
err := cache.SetWithTTL("short-lived", "value", shortTTL)
|
||||
err := cache.SetWithTTL(testutil.CacheShortLivedKey, "value", shortTTL)
|
||||
testutil.AssertNoError(t, err)
|
||||
|
||||
// Should exist immediately
|
||||
value, exists := cache.Get("short-lived")
|
||||
value, exists := cache.Get(testutil.CacheShortLivedKey)
|
||||
if !exists {
|
||||
t.Fatal("expected value to exist immediately")
|
||||
}
|
||||
@@ -144,18 +144,18 @@ func TestCache_TTL(t *testing.T) {
|
||||
time.Sleep(shortTTL + 50*time.Millisecond)
|
||||
|
||||
// Should not exist after TTL
|
||||
_, exists = cache.Get("short-lived")
|
||||
_, exists = cache.Get(testutil.CacheShortLivedKey)
|
||||
if exists {
|
||||
t.Error("expected value to be expired")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache_GetOrSet(t *testing.T) {
|
||||
func TestCacheGetOrSet(t *testing.T) {
|
||||
tmpDir, cleanup := testutil.TempDir(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())
|
||||
@@ -180,12 +180,12 @@ func TestCache_GetOrSet(t *testing.T) {
|
||||
testutil.AssertEqual(t, 1, callCount) // Getter not called again
|
||||
}
|
||||
|
||||
func TestCache_GetOrSetError(t *testing.T) {
|
||||
func TestCacheGetOrSetError(t *testing.T) {
|
||||
tmpDir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
cache := createTestCache(t, tmpDir)
|
||||
defer func() { _ = cache.Close() }()
|
||||
defer testutil.CleanupCache(t, cache)()
|
||||
|
||||
// Getter that returns error
|
||||
getter := func() (any, error) {
|
||||
@@ -207,12 +207,12 @@ func TestCache_GetOrSetError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache_ConcurrentAccess(t *testing.T) {
|
||||
func TestCacheConcurrentAccess(t *testing.T) {
|
||||
tmpDir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
cache := createTestCache(t, tmpDir)
|
||||
defer func() { _ = cache.Close() }()
|
||||
defer testutil.CleanupCache(t, cache)()
|
||||
|
||||
const numGoroutines = 10
|
||||
const numOperations = 100
|
||||
@@ -222,42 +222,45 @@ func TestCache_ConcurrentAccess(t *testing.T) {
|
||||
|
||||
// Launch multiple goroutines doing concurrent operations
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
go func(goroutineID int) {
|
||||
defer wg.Done()
|
||||
|
||||
for j := 0; j < numOperations; j++ {
|
||||
key := fmt.Sprintf("key-%d-%d", goroutineID, j)
|
||||
value := fmt.Sprintf("value-%d-%d", goroutineID, j)
|
||||
|
||||
// Set value
|
||||
err := cache.Set(key, value)
|
||||
if err != nil {
|
||||
t.Errorf("error setting value: %v", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Get value
|
||||
retrieved, exists := cache.Get(key)
|
||||
if !exists {
|
||||
t.Errorf("expected key %s to exist", key)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if retrieved != value {
|
||||
t.Errorf("expected %s, got %s", value, retrieved)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
}(i)
|
||||
go performConcurrentCacheOperations(t, cache, i, numOperations, &wg)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestCache_Persistence(t *testing.T) {
|
||||
func performConcurrentCacheOperations(t *testing.T, cache *Cache, goroutineID, numOperations int, wg *sync.WaitGroup) {
|
||||
t.Helper()
|
||||
defer wg.Done()
|
||||
|
||||
for j := 0; j < numOperations; j++ {
|
||||
key := fmt.Sprintf("key-%d-%d", goroutineID, j)
|
||||
value := fmt.Sprintf("value-%d-%d", goroutineID, j)
|
||||
|
||||
// Set value
|
||||
err := cache.Set(key, value)
|
||||
if err != nil {
|
||||
t.Errorf("error setting value: %v", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Get value
|
||||
retrieved, exists := cache.Get(key)
|
||||
if !exists {
|
||||
t.Errorf("expected key %s to exist", key)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if retrieved != value {
|
||||
t.Errorf("expected %s, got %s", value, retrieved)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachePersistence(t *testing.T) {
|
||||
tmpDir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
@@ -272,7 +275,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")
|
||||
@@ -282,20 +285,20 @@ func TestCache_Persistence(t *testing.T) {
|
||||
testutil.AssertEqual(t, "persistent-value", value)
|
||||
}
|
||||
|
||||
func TestCache_Clear(t *testing.T) {
|
||||
func TestCacheClear(t *testing.T) {
|
||||
tmpDir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
cache := createTestCache(t, tmpDir)
|
||||
defer func() { _ = cache.Close() }()
|
||||
defer testutil.CleanupCache(t, cache)()
|
||||
|
||||
// Add some data
|
||||
_ = cache.Set("key1", "value1")
|
||||
_ = cache.Set("key2", "value2")
|
||||
_ = cache.Set(testutil.CacheTestKey1, testutil.CacheTestValue1)
|
||||
_ = cache.Set(testutil.CacheTestKey2, "value2")
|
||||
|
||||
// Verify data exists
|
||||
_, exists1 := cache.Get("key1")
|
||||
_, exists2 := cache.Get("key2")
|
||||
_, exists1 := cache.Get(testutil.CacheTestKey1)
|
||||
_, exists2 := cache.Get(testutil.CacheTestKey2)
|
||||
if !exists1 || !exists2 {
|
||||
t.Fatal("expected test data to exist before clear")
|
||||
}
|
||||
@@ -305,37 +308,37 @@ func TestCache_Clear(t *testing.T) {
|
||||
testutil.AssertNoError(t, err)
|
||||
|
||||
// Verify data is gone
|
||||
_, exists1 = cache.Get("key1")
|
||||
_, exists2 = cache.Get("key2")
|
||||
_, exists1 = cache.Get(testutil.CacheTestKey1)
|
||||
_, exists2 = cache.Get(testutil.CacheTestKey2)
|
||||
if exists1 || exists2 {
|
||||
t.Error("expected data to be cleared")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache_Delete(t *testing.T) {
|
||||
func TestCacheDelete(t *testing.T) {
|
||||
tmpDir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
cache := createTestCache(t, tmpDir)
|
||||
defer func() { _ = cache.Close() }()
|
||||
defer testutil.CleanupCache(t, cache)()
|
||||
|
||||
// Add some data
|
||||
_ = cache.Set("key1", "value1")
|
||||
_ = cache.Set("key2", "value2")
|
||||
_ = cache.Set(testutil.CacheTestKey1, testutil.CacheTestValue1)
|
||||
_ = cache.Set(testutil.CacheTestKey2, "value2")
|
||||
_ = cache.Set("key3", "value3")
|
||||
|
||||
// Verify data exists
|
||||
_, exists := cache.Get("key1")
|
||||
_, exists := cache.Get(testutil.CacheTestKey1)
|
||||
if !exists {
|
||||
t.Fatal("expected key1 to exist before delete")
|
||||
}
|
||||
|
||||
// Delete specific key
|
||||
cache.Delete("key1")
|
||||
cache.Delete(testutil.CacheTestKey1)
|
||||
|
||||
// Verify deleted key is gone but others remain
|
||||
_, exists1 := cache.Get("key1")
|
||||
_, exists2 := cache.Get("key2")
|
||||
_, exists1 := cache.Get(testutil.CacheTestKey1)
|
||||
_, exists2 := cache.Get(testutil.CacheTestKey2)
|
||||
_, exists3 := cache.Get("key3")
|
||||
|
||||
if exists1 {
|
||||
@@ -349,19 +352,19 @@ func TestCache_Delete(t *testing.T) {
|
||||
cache.Delete("nonexistent")
|
||||
}
|
||||
|
||||
func TestCache_Stats(t *testing.T) {
|
||||
func TestCacheStats(t *testing.T) {
|
||||
tmpDir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
cache := createTestCache(t, tmpDir)
|
||||
defer func() { _ = cache.Close() }()
|
||||
defer testutil.CleanupCache(t, cache)()
|
||||
|
||||
// Ensure cache starts clean
|
||||
_ = cache.Clear()
|
||||
|
||||
// Add some data
|
||||
_ = cache.Set("key1", "value1")
|
||||
_ = cache.Set("key2", "larger-value-with-more-content")
|
||||
_ = cache.Set(testutil.CacheTestKey1, testutil.CacheTestValue1)
|
||||
_ = cache.Set(testutil.CacheTestKey2, "larger-value-with-more-content")
|
||||
|
||||
stats := cache.Stats()
|
||||
|
||||
@@ -397,7 +400,7 @@ func TestCache_Stats(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache_CleanupExpiredEntries(t *testing.T) {
|
||||
func TestCacheCleanupExpiredEntries(t *testing.T) {
|
||||
tmpDir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
@@ -412,14 +415,14 @@ 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")
|
||||
err = cache.Set(testutil.CacheExpiringKey, "expiring-value")
|
||||
testutil.AssertNoError(t, err)
|
||||
|
||||
// Verify it exists
|
||||
_, exists := cache.Get("expiring-key")
|
||||
_, exists := cache.Get(testutil.CacheExpiringKey)
|
||||
if !exists {
|
||||
t.Fatal("expected entry to exist initially")
|
||||
}
|
||||
@@ -428,13 +431,13 @@ func TestCache_CleanupExpiredEntries(t *testing.T) {
|
||||
time.Sleep(config.DefaultTTL + config.CleanupInterval + 20*time.Millisecond)
|
||||
|
||||
// Entry should be cleaned up
|
||||
_, exists = cache.Get("expiring-key")
|
||||
_, exists = cache.Get(testutil.CacheExpiringKey)
|
||||
if exists {
|
||||
t.Error("expected expired entry to be cleaned up")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache_ErrorHandling(t *testing.T) {
|
||||
func TestCacheErrorHandling(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setupFunc func(t *testing.T) *Cache
|
||||
@@ -465,23 +468,23 @@ 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache_AsyncSaveErrorHandling(t *testing.T) {
|
||||
func TestCacheAsyncSaveErrorHandling(t *testing.T) {
|
||||
tmpDir, cleanup := testutil.TempDir(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
|
||||
err := cache.Set("test-key", "test-value")
|
||||
err := cache.Set(testutil.CacheTestKey, testutil.CacheTestValue)
|
||||
testutil.AssertNoError(t, err)
|
||||
|
||||
// Give some time for async save to complete
|
||||
@@ -490,19 +493,19 @@ func TestCache_AsyncSaveErrorHandling(t *testing.T) {
|
||||
// The async save should have completed without panicking
|
||||
// We can't easily test the error logging without capturing logs,
|
||||
// but we can verify the cache still works
|
||||
value, exists := cache.Get("test-key")
|
||||
value, exists := cache.Get(testutil.CacheTestKey)
|
||||
if !exists {
|
||||
t.Error("expected value to exist after async save")
|
||||
}
|
||||
testutil.AssertEqual(t, "test-value", value)
|
||||
testutil.AssertEqual(t, testutil.CacheTestValue, value)
|
||||
}
|
||||
|
||||
func TestCache_EstimateSize(t *testing.T) {
|
||||
func TestCacheEstimateSize(t *testing.T) {
|
||||
tmpDir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
cache := createTestCache(t, tmpDir)
|
||||
defer func() { _ = cache.Close() }()
|
||||
defer testutil.CleanupCache(t, cache)()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -525,9 +528,9 @@ func TestCache_EstimateSize(t *testing.T) {
|
||||
{
|
||||
name: "struct",
|
||||
value: map[string]any{
|
||||
"key1": "value1",
|
||||
"key2": 42,
|
||||
"key3": []string{"a", "b", "c"},
|
||||
testutil.CacheTestKey1: testutil.CacheTestValue1,
|
||||
testutil.CacheTestKey2: 42,
|
||||
"key3": []string{"a", "b", "c"},
|
||||
},
|
||||
minSize: 30,
|
||||
maxSize: 200,
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/adrg/xdg"
|
||||
"github.com/gofri/go-github-ratelimit/github_ratelimit"
|
||||
@@ -14,9 +13,10 @@ 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"
|
||||
templatesembed "github.com/ivuorinen/gh-action-readme/templates_embed"
|
||||
)
|
||||
|
||||
// AppConfig represents the application configuration that can be used at multiple levels.
|
||||
@@ -25,9 +25,10 @@ type AppConfig struct {
|
||||
GitHubToken string `mapstructure:"github_token" yaml:"github_token,omitempty"` // Only in global config
|
||||
|
||||
// Repository Information (auto-detected, overridable)
|
||||
Organization string `mapstructure:"organization" yaml:"organization,omitempty"`
|
||||
Repository string `mapstructure:"repository" yaml:"repository,omitempty"`
|
||||
Version string `mapstructure:"version" yaml:"version,omitempty"`
|
||||
Organization string `mapstructure:"organization" yaml:"organization,omitempty"`
|
||||
Repository string `mapstructure:"repository" yaml:"repository,omitempty"`
|
||||
Version string `mapstructure:"version" yaml:"version,omitempty"`
|
||||
UseDefaultBranch bool `mapstructure:"use_default_branch" yaml:"use_default_branch"`
|
||||
|
||||
// Template Settings
|
||||
Theme string `mapstructure:"theme" yaml:"theme"`
|
||||
@@ -56,8 +57,9 @@ type AppConfig struct {
|
||||
RepoOverrides map[string]AppConfig `mapstructure:"repo_overrides" yaml:"repo_overrides,omitempty"`
|
||||
|
||||
// Behavior
|
||||
Verbose bool `mapstructure:"verbose" yaml:"verbose"`
|
||||
Quiet bool `mapstructure:"quiet" yaml:"quiet"`
|
||||
Verbose bool `mapstructure:"verbose" yaml:"verbose"`
|
||||
Quiet bool `mapstructure:"quiet" yaml:"quiet"`
|
||||
IgnoredDirectories []string `mapstructure:"ignored_directories" yaml:"ignored_directories,omitempty"`
|
||||
|
||||
// Default values for action.yml files (legacy)
|
||||
Defaults DefaultValues `mapstructure:"defaults" yaml:"defaults,omitempty"`
|
||||
@@ -79,13 +81,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 +106,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 +114,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)
|
||||
}
|
||||
@@ -152,7 +149,7 @@ func resolveTemplatePath(templatePath string) string {
|
||||
}
|
||||
|
||||
// Check if template is available in embedded filesystem first
|
||||
if templates_embed.IsEmbeddedTemplateAvailable(templatePath) {
|
||||
if templatesembed.IsEmbeddedTemplateAvailable(templatePath) {
|
||||
// Return a special marker to indicate this should use embedded templates
|
||||
// The actual template loading will handle embedded vs filesystem
|
||||
return templatePath
|
||||
@@ -180,21 +177,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 ""
|
||||
@@ -210,9 +215,10 @@ func resolveThemeTemplate(theme string) string {
|
||||
func DefaultAppConfig() *AppConfig {
|
||||
return &AppConfig{
|
||||
// Repository Information (will be auto-detected)
|
||||
Organization: "",
|
||||
Repository: "",
|
||||
Version: "",
|
||||
Organization: "",
|
||||
Repository: "",
|
||||
Version: "",
|
||||
UseDefaultBranch: true, // Use detected default branch (main/master) in usage examples
|
||||
|
||||
// Template Settings
|
||||
Theme: "default", // default, github, gitlab, minimal, professional
|
||||
@@ -227,7 +233,7 @@ func DefaultAppConfig() *AppConfig {
|
||||
|
||||
// Workflow Requirements
|
||||
Permissions: map[string]string{},
|
||||
RunsOn: []string{"ubuntu-latest"},
|
||||
RunsOn: []string{appconstants.RunnerUbuntuLatest},
|
||||
|
||||
// Features
|
||||
AnalyzeDependencies: false,
|
||||
@@ -240,8 +246,9 @@ func DefaultAppConfig() *AppConfig {
|
||||
RepoOverrides: map[string]AppConfig{},
|
||||
|
||||
// Behavior
|
||||
Verbose: false,
|
||||
Quiet: false,
|
||||
Verbose: false,
|
||||
Quiet: false,
|
||||
IgnoredDirectories: appconstants.GetDefaultIgnoredDirectories(),
|
||||
|
||||
// Default values for action.yml files (legacy)
|
||||
Defaults: DefaultValues{
|
||||
@@ -290,35 +297,39 @@ 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)
|
||||
}
|
||||
for k, v := range src.Permissions {
|
||||
dst.Permissions[k] = v
|
||||
}
|
||||
// mergeStringMap is a generic helper that merges a source map into a destination map.
|
||||
func mergeStringMap(src map[string]string, dst *map[string]string) {
|
||||
if len(src) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
if *dst == nil {
|
||||
*dst = make(map[string]string)
|
||||
}
|
||||
for k, v := range src {
|
||||
(*dst)[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
// mergeMapFields merges map fields from src to dst if non-empty.
|
||||
func mergeMapFields(dst *AppConfig, src *AppConfig) {
|
||||
mergeStringMap(src.Permissions, &dst.Permissions)
|
||||
mergeStringMap(src.Variables, &dst.Variables)
|
||||
}
|
||||
|
||||
// mergeSliceFields merges slice fields from src to dst if non-empty.
|
||||
func mergeSliceFields(dst *AppConfig, src *AppConfig) {
|
||||
if len(src.RunsOn) > 0 {
|
||||
dst.RunsOn = make([]string, len(src.RunsOn))
|
||||
copy(dst.RunsOn, src.RunsOn)
|
||||
// copySliceIfNotEmpty copies src slice to dst if src is not empty.
|
||||
func copySliceIfNotEmpty(dst *[]string, src []string) {
|
||||
if len(src) > 0 {
|
||||
*dst = make([]string, len(src))
|
||||
copy(*dst, src)
|
||||
}
|
||||
}
|
||||
|
||||
func mergeSliceFields(dst *AppConfig, src *AppConfig) {
|
||||
copySliceIfNotEmpty(&dst.RunsOn, src.RunsOn)
|
||||
copySliceIfNotEmpty(&dst.IgnoredDirectories, src.IgnoredDirectories)
|
||||
}
|
||||
|
||||
// mergeBooleanFields merges boolean fields from src to dst if true.
|
||||
func mergeBooleanFields(dst *AppConfig, src *AppConfig) {
|
||||
if src.AnalyzeDependencies {
|
||||
@@ -333,6 +344,9 @@ func mergeBooleanFields(dst *AppConfig, src *AppConfig) {
|
||||
if src.Quiet {
|
||||
dst.Quiet = src.Quiet
|
||||
}
|
||||
if src.UseDefaultBranch {
|
||||
dst.UseDefaultBranch = src.UseDefaultBranch
|
||||
}
|
||||
}
|
||||
|
||||
// mergeSecurityFields merges security-sensitive fields if allowed.
|
||||
@@ -353,59 +367,32 @@ func mergeSecurityFields(dst *AppConfig, src *AppConfig, allowTokens bool) {
|
||||
|
||||
// LoadRepoConfig loads repository-level configuration from hidden config files.
|
||||
func LoadRepoConfig(repoRoot string) (*AppConfig, error) {
|
||||
// Hidden config file paths in priority order
|
||||
configPaths := []string{
|
||||
".ghreadme.yaml", // Primary hidden config
|
||||
".config/ghreadme.yaml", // Secondary hidden config
|
||||
".github/ghreadme.yaml", // GitHub ecosystem standard
|
||||
return loadRepoConfigInternal(repoRoot)
|
||||
}
|
||||
|
||||
// loadRepoConfigInternal is the shared internal implementation for repo config loading.
|
||||
func loadRepoConfigInternal(repoRoot string) (*AppConfig, error) {
|
||||
configPath, found := findFirstExistingConfig(repoRoot, appconstants.GetConfigSearchPaths())
|
||||
if found {
|
||||
return loadConfigFromViper(configPath)
|
||||
}
|
||||
|
||||
for _, configName := range configPaths {
|
||||
configPath := filepath.Join(repoRoot, configName)
|
||||
if _, err := os.Stat(configPath); err == nil {
|
||||
// Config file found, load it
|
||||
v := viper.New()
|
||||
v.SetConfigFile(configPath)
|
||||
v.SetConfigType("yaml")
|
||||
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
return nil, fmt.Errorf("failed to read repo config %s: %w", configPath, err)
|
||||
}
|
||||
|
||||
var config AppConfig
|
||||
if err := v.Unmarshal(&config); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal repo config: %w", err)
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
}
|
||||
}
|
||||
|
||||
// No config found, return empty config
|
||||
return &AppConfig{}, nil
|
||||
}
|
||||
|
||||
// LoadActionConfig loads action-level configuration from config.yaml.
|
||||
func LoadActionConfig(actionDir string) (*AppConfig, error) {
|
||||
configPath := filepath.Join(actionDir, "config.yaml")
|
||||
return loadActionConfigInternal(actionDir)
|
||||
}
|
||||
|
||||
// loadActionConfigInternal is the shared internal implementation for action config loading.
|
||||
func loadActionConfigInternal(actionDir string) (*AppConfig, error) {
|
||||
configPath := filepath.Join(actionDir, appconstants.ConfigYAML)
|
||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||
return &AppConfig{}, nil // No action config is fine
|
||||
return &AppConfig{}, nil
|
||||
}
|
||||
|
||||
v := viper.New()
|
||||
v.SetConfigFile(configPath)
|
||||
v.SetConfigType("yaml")
|
||||
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
return nil, fmt.Errorf("failed to read action config %s: %w", configPath, err)
|
||||
}
|
||||
|
||||
var config AppConfig
|
||||
if err := v.Unmarshal(&config); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal action config: %w", err)
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
return loadConfigFromViper(configPath)
|
||||
}
|
||||
|
||||
// DetectRepositoryName detects the repository name from git remote URL.
|
||||
@@ -422,6 +409,29 @@ func DetectRepositoryName(repoRoot string) string {
|
||||
return info.GetRepositoryName()
|
||||
}
|
||||
|
||||
// loadAndMergeConfig is a helper that loads config from a directory and merges it.
|
||||
// Returns nil if dir is empty (no-op). Returns error if loading fails.
|
||||
func loadAndMergeConfig(
|
||||
config *AppConfig,
|
||||
dir string,
|
||||
loadFunc func(string) (*AppConfig, error),
|
||||
errorFormat string,
|
||||
allowTokens bool,
|
||||
) error {
|
||||
if dir == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
loadedConfig, err := loadFunc(dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf(errorFormat, err)
|
||||
}
|
||||
|
||||
MergeConfigs(config, loadedConfig, allowTokens)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadConfiguration loads configuration with multi-level hierarchy.
|
||||
func LoadConfiguration(configFile, repoRoot, actionDir string) (*AppConfig, error) {
|
||||
// 1. Start with defaults
|
||||
@@ -430,7 +440,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
|
||||
|
||||
@@ -443,28 +453,20 @@ func LoadConfiguration(configFile, repoRoot, actionDir string) (*AppConfig, erro
|
||||
}
|
||||
|
||||
// 4. Load repository root ghreadme.yaml
|
||||
if repoRoot != "" {
|
||||
repoConfig, err := LoadRepoConfig(repoRoot)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load repo config: %w", err)
|
||||
}
|
||||
MergeConfigs(config, repoConfig, false) // No tokens in repo config
|
||||
if err := loadAndMergeConfig(config, repoRoot, LoadRepoConfig,
|
||||
appconstants.ErrFailedToLoadRepoConfig, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 5. Load action-specific config.yaml
|
||||
if actionDir != "" {
|
||||
actionConfig, err := LoadActionConfig(actionDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load action config: %w", err)
|
||||
}
|
||||
MergeConfigs(config, actionConfig, false) // No tokens in action config
|
||||
if err := loadAndMergeConfig(config, actionDir, LoadActionConfig,
|
||||
appconstants.ErrFailedToLoadActionConfig, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 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 +475,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 +525,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
|
||||
}
|
||||
180
internal/config_helper_test.go
Normal file
180
internal/config_helper_test.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-github/v74/github"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
// TestAssertBooleanConfigFields_Helper tests the assertBooleanConfigFields helper.
|
||||
func TestAssertBooleanConfigFieldsHelper(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
got *AppConfig
|
||||
want *AppConfig
|
||||
}{
|
||||
{
|
||||
name: "all fields match",
|
||||
got: &AppConfig{
|
||||
AnalyzeDependencies: true,
|
||||
ShowSecurityInfo: false,
|
||||
Verbose: true,
|
||||
Quiet: false,
|
||||
UseDefaultBranch: true,
|
||||
},
|
||||
want: &AppConfig{
|
||||
AnalyzeDependencies: true,
|
||||
ShowSecurityInfo: false,
|
||||
Verbose: true,
|
||||
Quiet: false,
|
||||
UseDefaultBranch: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "all fields false",
|
||||
got: &AppConfig{
|
||||
AnalyzeDependencies: false,
|
||||
ShowSecurityInfo: false,
|
||||
Verbose: false,
|
||||
Quiet: false,
|
||||
UseDefaultBranch: false,
|
||||
},
|
||||
want: &AppConfig{
|
||||
AnalyzeDependencies: false,
|
||||
ShowSecurityInfo: false,
|
||||
Verbose: false,
|
||||
Quiet: false,
|
||||
UseDefaultBranch: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Call the helper - it will call t.Error if fields don't match
|
||||
// For matching cases, it should not error
|
||||
assertBooleanConfigFields(t, tt.got, tt.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssertGitHubClientValid_Helper tests the assertGitHubClientValid helper.
|
||||
func TestAssertGitHubClientValidHelper(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
client *GitHubClient
|
||||
expectedToken string
|
||||
}{
|
||||
{
|
||||
name: "valid client with token",
|
||||
client: &GitHubClient{
|
||||
Client: github.NewClient(nil),
|
||||
Token: "test-token-123",
|
||||
},
|
||||
expectedToken: "test-token-123",
|
||||
},
|
||||
{
|
||||
name: "valid client with empty token",
|
||||
client: &GitHubClient{
|
||||
Client: github.NewClient(nil),
|
||||
Token: "",
|
||||
},
|
||||
expectedToken: "",
|
||||
},
|
||||
{
|
||||
name: "valid client with github PAT",
|
||||
client: &GitHubClient{
|
||||
Client: github.NewClient(nil),
|
||||
Token: "ghp_1234567890abcdefghijklmnopqrstuvwxyzABCD",
|
||||
},
|
||||
expectedToken: "ghp_1234567890abcdefghijklmnopqrstuvwxyzABCD",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Call the helper - it will verify the client is valid
|
||||
// For valid clients, it should not error
|
||||
assertGitHubClientValid(t, tt.client, tt.expectedToken)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunTemplatePathTest_Helper tests the runTemplatePathTest helper.
|
||||
func TestRunTemplatePathTestHelper(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
setupFunc func(*testing.T) (string, func())
|
||||
checkFunc func(*testing.T, string)
|
||||
expectResult string
|
||||
}{
|
||||
{
|
||||
name: "absolute path setup",
|
||||
setupFunc: func(t *testing.T) (string, func()) {
|
||||
t.Helper()
|
||||
tmpDir := t.TempDir()
|
||||
templatePath := filepath.Join(tmpDir, "test.tmpl")
|
||||
|
||||
err := os.WriteFile(templatePath, []byte("test template"), appconstants.FilePermDefault)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to write template: %v", err)
|
||||
}
|
||||
|
||||
return templatePath, func() { /* Cleanup handled by t.TempDir() */ }
|
||||
},
|
||||
checkFunc: func(t *testing.T, result string) {
|
||||
t.Helper()
|
||||
if result == "" {
|
||||
t.Error(testutil.TestMsgExpectedNonEmpty)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "relative path setup",
|
||||
setupFunc: func(t *testing.T) (string, func()) {
|
||||
t.Helper()
|
||||
|
||||
return "templates/readme.tmpl", func() { /* No cleanup needed for relative path test */ }
|
||||
},
|
||||
checkFunc: func(t *testing.T, result string) {
|
||||
t.Helper()
|
||||
if result == "" {
|
||||
t.Error(testutil.TestMsgExpectedNonEmpty)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nil checkFunc (just runs setup)",
|
||||
setupFunc: func(t *testing.T) (string, func()) {
|
||||
t.Helper()
|
||||
|
||||
return "test/path.tmpl", func() { /* No cleanup needed for nil checkFunc test */ }
|
||||
},
|
||||
checkFunc: nil, // No validation
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Call the helper - it runs setup, calls resolveTemplatePath, and validates
|
||||
runTemplatePathTest(t, tt.setupFunc, tt.checkFunc)
|
||||
})
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
284
internal/config_test_helper.go
Normal file
284
internal/config_test_helper.go
Normal file
@@ -0,0 +1,284 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
// boolFields represents the boolean configuration fields used in merge tests.
|
||||
type boolFields struct {
|
||||
AnalyzeDependencies bool
|
||||
ShowSecurityInfo bool
|
||||
Verbose bool
|
||||
Quiet bool
|
||||
UseDefaultBranch bool
|
||||
}
|
||||
|
||||
// createBoolFieldMergeTest creates a test table entry for testing boolean field merging.
|
||||
// This helper reduces duplication by standardizing the creation of AppConfig test structures
|
||||
// with boolean fields.
|
||||
func createBoolFieldMergeTest(name string, dst, src, want boolFields) struct {
|
||||
name string
|
||||
dst *AppConfig
|
||||
src *AppConfig
|
||||
want *AppConfig
|
||||
} {
|
||||
return struct {
|
||||
name string
|
||||
dst *AppConfig
|
||||
src *AppConfig
|
||||
want *AppConfig
|
||||
}{
|
||||
name: name,
|
||||
dst: &AppConfig{
|
||||
AnalyzeDependencies: dst.AnalyzeDependencies,
|
||||
ShowSecurityInfo: dst.ShowSecurityInfo,
|
||||
Verbose: dst.Verbose,
|
||||
Quiet: dst.Quiet,
|
||||
UseDefaultBranch: dst.UseDefaultBranch,
|
||||
},
|
||||
src: &AppConfig{
|
||||
AnalyzeDependencies: src.AnalyzeDependencies,
|
||||
ShowSecurityInfo: src.ShowSecurityInfo,
|
||||
Verbose: src.Verbose,
|
||||
Quiet: src.Quiet,
|
||||
UseDefaultBranch: src.UseDefaultBranch,
|
||||
},
|
||||
want: &AppConfig{
|
||||
AnalyzeDependencies: want.AnalyzeDependencies,
|
||||
ShowSecurityInfo: want.ShowSecurityInfo,
|
||||
Verbose: want.Verbose,
|
||||
Quiet: want.Quiet,
|
||||
UseDefaultBranch: want.UseDefaultBranch,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// createGitRemoteTestCase creates a test table entry for git remote detection tests.
|
||||
// This helper reduces duplication for tests that set up a git repo with a remote config.
|
||||
func createGitRemoteTestCase(
|
||||
name, configContent, expectedResult, description string,
|
||||
) struct {
|
||||
name string
|
||||
setupFunc func(t *testing.T) string
|
||||
expectedResult string
|
||||
description string
|
||||
} {
|
||||
return struct {
|
||||
name string
|
||||
setupFunc func(t *testing.T) string
|
||||
expectedResult string
|
||||
description string
|
||||
}{
|
||||
name: name,
|
||||
setupFunc: func(t *testing.T) string {
|
||||
t.Helper()
|
||||
tmpDir, _ := testutil.TempDir(t)
|
||||
testutil.InitGitRepo(t, tmpDir)
|
||||
|
||||
if configContent != "" {
|
||||
configPath := filepath.Join(tmpDir, testutil.ConfigFieldGit, "config")
|
||||
testutil.WriteTestFile(t, configPath, configContent)
|
||||
}
|
||||
|
||||
return tmpDir
|
||||
},
|
||||
expectedResult: expectedResult,
|
||||
description: description,
|
||||
}
|
||||
}
|
||||
|
||||
// createTokenMergeTest creates a test table entry for testing token merging behavior.
|
||||
// This helper reduces duplication for the 4 token merge test cases.
|
||||
func createTokenMergeTest(
|
||||
name, dstToken, srcToken, wantToken string,
|
||||
allowTokens bool,
|
||||
) struct {
|
||||
name string
|
||||
dst *AppConfig
|
||||
src *AppConfig
|
||||
allowTokens bool
|
||||
want *AppConfig
|
||||
} {
|
||||
return struct {
|
||||
name string
|
||||
dst *AppConfig
|
||||
src *AppConfig
|
||||
allowTokens bool
|
||||
want *AppConfig
|
||||
}{
|
||||
name: name,
|
||||
dst: &AppConfig{GitHubToken: dstToken},
|
||||
src: &AppConfig{GitHubToken: srcToken},
|
||||
allowTokens: allowTokens,
|
||||
want: &AppConfig{GitHubToken: wantToken},
|
||||
}
|
||||
}
|
||||
|
||||
// createMapMergeTest creates a test table entry for testing map field merging (permissions/variables).
|
||||
// This helper reduces duplication for tests that merge map[string]string fields.
|
||||
func createMapMergeTest(
|
||||
name string,
|
||||
dstMap, srcMap, expectedMap map[string]string,
|
||||
isPermissions bool,
|
||||
) struct {
|
||||
name string
|
||||
dst *AppConfig
|
||||
src *AppConfig
|
||||
expected *AppConfig
|
||||
} {
|
||||
dst := &AppConfig{}
|
||||
src := &AppConfig{}
|
||||
expected := &AppConfig{}
|
||||
|
||||
if isPermissions {
|
||||
dst.Permissions = dstMap
|
||||
src.Permissions = srcMap
|
||||
expected.Permissions = expectedMap
|
||||
} else {
|
||||
dst.Variables = dstMap
|
||||
src.Variables = srcMap
|
||||
expected.Variables = expectedMap
|
||||
}
|
||||
|
||||
return struct {
|
||||
name string
|
||||
dst *AppConfig
|
||||
src *AppConfig
|
||||
expected *AppConfig
|
||||
}{
|
||||
name: name,
|
||||
dst: dst,
|
||||
src: src,
|
||||
expected: expected,
|
||||
}
|
||||
}
|
||||
|
||||
// ConfigHierarchySetup contains fixture paths for creating a multi-level config hierarchy.
|
||||
type ConfigHierarchySetup struct {
|
||||
GlobalFixture string // Fixture path for global config
|
||||
RepoFixture string // Fixture path for repo config
|
||||
ActionFixture string // Fixture path for action config
|
||||
}
|
||||
|
||||
// SetupConfigHierarchy creates a multi-level config hierarchy (global/repo/action).
|
||||
// Returns global config path, repo root, and action directory.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// globalPath, repoRoot, actionDir := SetupConfigHierarchy(t, tmpDir, ConfigHierarchySetup{
|
||||
// GlobalFixture: testutil.TestConfigGlobalDefault,
|
||||
// RepoFixture: testutil.TestConfigRepoSimple,
|
||||
// ActionFixture: testutil.TestConfigActionSimple,
|
||||
// })
|
||||
func SetupConfigHierarchy(
|
||||
t *testing.T,
|
||||
baseDir string,
|
||||
setup ConfigHierarchySetup,
|
||||
) (globalConfigPath, repoRoot, actionDir string) {
|
||||
t.Helper()
|
||||
// setupAndCreateConfigFixtures sets up config fixtures in a test directory.
|
||||
// It creates the repo directory structure unconditionally and populates config files
|
||||
// based on the provided setup.GlobalFixture, setup.RepoFixture, and
|
||||
// setup.ActionFixture. Returns globalConfigPath, repoRoot, and actionDir.
|
||||
|
||||
// Create global config
|
||||
if setup.GlobalFixture != "" {
|
||||
globalConfigDir := filepath.Join(baseDir, testutil.TestDirDotConfig, testutil.TestBinaryName)
|
||||
globalConfigPath = testutil.WriteFileInDir(
|
||||
t, globalConfigDir, testutil.TestFileConfigYAML,
|
||||
testutil.MustReadFixture(setup.GlobalFixture),
|
||||
)
|
||||
}
|
||||
|
||||
// Create repo config
|
||||
repoRoot = filepath.Join(baseDir, testutil.ConfigFieldRepo)
|
||||
if err := os.MkdirAll(repoRoot, 0o700); err != nil {
|
||||
t.Fatalf("failed to create repo directory: %v", err)
|
||||
}
|
||||
if setup.RepoFixture != "" {
|
||||
testutil.WriteFileInDir(
|
||||
t, repoRoot, testutil.TestFileGHReadmeYAML,
|
||||
testutil.MustReadFixture(setup.RepoFixture),
|
||||
)
|
||||
}
|
||||
|
||||
// Create action config
|
||||
if setup.ActionFixture != "" {
|
||||
actionDir = filepath.Join(repoRoot, testutil.ConfigFieldAction)
|
||||
testutil.WriteFileInDir(
|
||||
t, actionDir, testutil.TestFileConfigYAML,
|
||||
testutil.MustReadFixture(setup.ActionFixture),
|
||||
)
|
||||
} else {
|
||||
actionDir = repoRoot
|
||||
}
|
||||
|
||||
return globalConfigPath, repoRoot, actionDir
|
||||
}
|
||||
|
||||
// WriteConfigFixture writes a config fixture to a directory with standard config filename.
|
||||
// Returns the full path to the written config file.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// configPath := WriteConfigFixture(t, tmpDir, testutil.TestConfigGlobalDefault)
|
||||
func WriteConfigFixture(t *testing.T, dir, fixturePath string) string {
|
||||
t.Helper()
|
||||
|
||||
return testutil.WriteFileInDir(
|
||||
t, dir, testutil.TestFileConfigYAML,
|
||||
testutil.MustReadFixture(fixturePath),
|
||||
)
|
||||
}
|
||||
|
||||
// ExpectedConfig holds expected values for config field assertions.
|
||||
// Only non-zero values will be checked.
|
||||
type ExpectedConfig struct {
|
||||
Theme string
|
||||
OutputFormat string
|
||||
OutputDir string
|
||||
Template string
|
||||
Schema string
|
||||
Verbose bool
|
||||
Quiet bool
|
||||
GitHubToken string
|
||||
}
|
||||
|
||||
// AssertConfigFields asserts that config matches expected values for all non-empty fields.
|
||||
// Only checks fields that are set in expected (non-zero values).
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// AssertConfigFields(t, config, ExpectedConfig{
|
||||
// Theme: testutil.TestThemeDefault,
|
||||
// OutputFormat: "md",
|
||||
// Verbose: true,
|
||||
// })
|
||||
func AssertConfigFields(t *testing.T, config *AppConfig, expected ExpectedConfig) {
|
||||
t.Helper()
|
||||
if expected.Theme != "" {
|
||||
testutil.AssertEqual(t, expected.Theme, config.Theme)
|
||||
}
|
||||
if expected.OutputFormat != "" {
|
||||
testutil.AssertEqual(t, expected.OutputFormat, config.OutputFormat)
|
||||
}
|
||||
if expected.OutputDir != "" {
|
||||
testutil.AssertEqual(t, expected.OutputDir, config.OutputDir)
|
||||
}
|
||||
if expected.Template != "" {
|
||||
testutil.AssertEqual(t, expected.Template, config.Template)
|
||||
}
|
||||
if expected.Schema != "" {
|
||||
testutil.AssertEqual(t, expected.Schema, config.Schema)
|
||||
}
|
||||
// Always check booleans (they have meaningful zero values)
|
||||
testutil.AssertEqual(t, expected.Verbose, config.Verbose)
|
||||
testutil.AssertEqual(t, expected.Quiet, config.Quiet)
|
||||
if expected.GitHubToken != "" {
|
||||
testutil.AssertEqual(t, expected.GitHubToken, config.GitHubToken)
|
||||
}
|
||||
}
|
||||
35
internal/config_test_helpers.go
Normal file
35
internal/config_test_helpers.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-github/v74/github"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
// assertGitHubClient validates GitHub client creation results.
|
||||
// This helper reduces test code duplication by centralizing
|
||||
// the client validation logic for github.Client instances.
|
||||
func assertGitHubClient(t *testing.T, client *github.Client, err error, expectError bool) {
|
||||
t.Helper()
|
||||
|
||||
if expectError {
|
||||
if err == nil {
|
||||
t.Error(testutil.TestErrNoErrorGotNone)
|
||||
}
|
||||
if client != nil {
|
||||
t.Error("expected nil client on error")
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Success case
|
||||
if err != nil {
|
||||
t.Errorf(testutil.TestErrUnexpected, err)
|
||||
}
|
||||
if client == nil {
|
||||
t.Error("expected non-nil client")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,7 +105,7 @@ func (cl *ConfigurationLoader) ValidateConfiguration(config *AppConfig) error {
|
||||
}
|
||||
|
||||
// Validate output format
|
||||
validFormats := []string{"md", "html", "json", "asciidoc"}
|
||||
validFormats := appconstants.GetSupportedOutputFormats()
|
||||
if !containsString(validFormats, config.OutputFormat) {
|
||||
return fmt.Errorf("invalid output format '%s', must be one of: %s",
|
||||
config.OutputFormat, strings.Join(validFormats, ", "))
|
||||
@@ -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,153 +189,84 @@ 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
|
||||
}
|
||||
|
||||
cl.applyRepoOverrides(config, repoRoot)
|
||||
}
|
||||
|
||||
// loadRepoConfigStep loads repository root configuration.
|
||||
func (cl *ConfigurationLoader) loadRepoConfigStep(config *AppConfig, repoRoot string) error {
|
||||
if !cl.sources[SourceRepoConfig] || repoRoot == "" {
|
||||
// loadConfigStep is a generic helper for loading and merging configuration from a specific source.
|
||||
func (cl *ConfigurationLoader) loadConfigStep(
|
||||
config *AppConfig,
|
||||
sourceName appconstants.ConfigurationSource,
|
||||
dirPath string,
|
||||
loadFunc func(string) (*AppConfig, error),
|
||||
errorFormat string,
|
||||
mergeTokens bool,
|
||||
) error {
|
||||
if !cl.sources[sourceName] || dirPath == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
repoConfig, err := cl.loadRepoConfig(repoRoot)
|
||||
loadedConfig, err := loadFunc(dirPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load repo config: %w", err)
|
||||
return fmt.Errorf(errorFormat, err)
|
||||
}
|
||||
cl.mergeConfigs(config, repoConfig, false) // No tokens in repo config
|
||||
cl.mergeConfigs(config, loadedConfig, mergeTokens)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadRepoConfigStep loads repository root configuration.
|
||||
func (cl *ConfigurationLoader) loadRepoConfigStep(config *AppConfig, repoRoot string) error {
|
||||
return cl.loadConfigStep(
|
||||
config,
|
||||
appconstants.SourceRepoConfig,
|
||||
repoRoot,
|
||||
cl.loadRepoConfig,
|
||||
appconstants.ErrFailedToLoadRepoConfig,
|
||||
false, // No tokens in repo config
|
||||
)
|
||||
}
|
||||
|
||||
// loadActionConfigStep loads action-specific configuration.
|
||||
func (cl *ConfigurationLoader) loadActionConfigStep(config *AppConfig, actionDir string) error {
|
||||
if !cl.sources[SourceActionConfig] || actionDir == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
actionConfig, err := cl.loadActionConfig(actionDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load action config: %w", err)
|
||||
}
|
||||
cl.mergeConfigs(config, actionConfig, false) // No tokens in action config
|
||||
|
||||
return nil
|
||||
return cl.loadConfigStep(
|
||||
config,
|
||||
appconstants.SourceActionConfig,
|
||||
actionDir,
|
||||
cl.loadActionConfig,
|
||||
appconstants.ErrFailedToLoadActionConfig,
|
||||
false, // No tokens in action config
|
||||
)
|
||||
}
|
||||
|
||||
// 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 +288,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 +298,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 +305,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 +316,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(), ", "))
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
178
internal/configuration_loader_test_helper.go
Normal file
178
internal/configuration_loader_test_helper.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
// repoOverrideTestCase defines the structure for repository override test cases.
|
||||
type repoOverrideTestCase struct {
|
||||
name string
|
||||
setupFunc func(t *testing.T) (config *AppConfig, repoRoot string)
|
||||
expectedTheme string
|
||||
expectedFormat string
|
||||
description string
|
||||
}
|
||||
|
||||
// runRepoOverrideTest executes a test case for repository override functionality.
|
||||
// This helper reduces duplication in TestConfigurationLoaderApplyRepoOverrides tests.
|
||||
func runRepoOverrideTest(t *testing.T, tc repoOverrideTestCase) {
|
||||
t.Helper()
|
||||
|
||||
config, repoRoot := tc.setupFunc(t)
|
||||
|
||||
loader := NewConfigurationLoader()
|
||||
loader.applyRepoOverrides(config, repoRoot)
|
||||
|
||||
// Verify expected values
|
||||
testutil.AssertEqual(t, tc.expectedTheme, config.Theme)
|
||||
testutil.AssertEqual(t, tc.expectedFormat, config.OutputFormat)
|
||||
}
|
||||
|
||||
// repoOverrideTestParams holds parameters for creating repo override test cases.
|
||||
type repoOverrideTestParams struct {
|
||||
name, remoteURL, overrideKey string
|
||||
overrideTheme, overrideFormat string
|
||||
expectedTheme, expectedFormat string
|
||||
description string
|
||||
}
|
||||
|
||||
// createRepoOverrideTestCase creates a repo override test case with git repo setup.
|
||||
// This helper reduces duplication when creating test cases that need git repositories.
|
||||
func createRepoOverrideTestCase(params repoOverrideTestParams) repoOverrideTestCase {
|
||||
return repoOverrideTestCase{
|
||||
name: params.name,
|
||||
setupFunc: func(t *testing.T) (*AppConfig, string) {
|
||||
t.Helper()
|
||||
tmpDir, _ := testutil.TempDir(t)
|
||||
|
||||
if params.remoteURL != "" {
|
||||
testutil.CreateGitRepoWithRemote(t, tmpDir, params.remoteURL)
|
||||
}
|
||||
|
||||
config := &AppConfig{
|
||||
Theme: testutil.TestThemeDefault,
|
||||
OutputFormat: "md",
|
||||
RepoOverrides: map[string]AppConfig{
|
||||
params.overrideKey: {
|
||||
Theme: params.overrideTheme,
|
||||
OutputFormat: params.overrideFormat,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return config, tmpDir
|
||||
},
|
||||
expectedTheme: params.expectedTheme,
|
||||
expectedFormat: params.expectedFormat,
|
||||
description: params.description,
|
||||
}
|
||||
}
|
||||
|
||||
// configLoaderTestCase defines the structure for configuration loader test cases.
|
||||
type configLoaderTestCase struct {
|
||||
name string
|
||||
setupFunc func(t *testing.T) string
|
||||
expectError bool
|
||||
checkFunc func(t *testing.T, config *AppConfig)
|
||||
description string
|
||||
}
|
||||
|
||||
// runConfigLoaderTest executes a test case for configuration loading functionality.
|
||||
// This helper reduces duplication between LoadGlobalConfig and loadActionConfig tests.
|
||||
func runConfigLoaderTest(
|
||||
t *testing.T,
|
||||
tc configLoaderTestCase,
|
||||
loadFunc func(loader *ConfigurationLoader, path string) (*AppConfig, error),
|
||||
) {
|
||||
t.Helper()
|
||||
t.Parallel()
|
||||
|
||||
path := tc.setupFunc(t)
|
||||
|
||||
loader := NewConfigurationLoader()
|
||||
config, err := loadFunc(loader, path)
|
||||
|
||||
if tc.expectError {
|
||||
testutil.AssertError(t, err)
|
||||
} else {
|
||||
testutil.AssertNoError(t, err)
|
||||
|
||||
if tc.checkFunc != nil {
|
||||
tc.checkFunc(t, config)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// checkThemeAndFormat is a helper that creates a checkFunc for verifying theme and output format.
|
||||
// This reduces duplication in test cases that only need to verify these two fields.
|
||||
func checkThemeAndFormat(expectedTheme, expectedFormat string) func(t *testing.T, config *AppConfig) {
|
||||
return func(t *testing.T, config *AppConfig) {
|
||||
t.Helper()
|
||||
testutil.AssertEqual(t, expectedTheme, config.Theme)
|
||||
testutil.AssertEqual(t, expectedFormat, config.OutputFormat)
|
||||
}
|
||||
}
|
||||
|
||||
// AssertSourceEnabled fails the test if the specified source is not in the enabled sources list.
|
||||
// This helper reduces duplication in tests that verify configuration sources are enabled.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// AssertSourceEnabled(t, enabledSources, appconstants.ConfigSourceGlobal)
|
||||
func AssertSourceEnabled(
|
||||
t *testing.T,
|
||||
sources []appconstants.ConfigurationSource,
|
||||
expectedSource appconstants.ConfigurationSource,
|
||||
) {
|
||||
t.Helper()
|
||||
for _, source := range sources {
|
||||
if source == expectedSource {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Errorf("expected source %s to be enabled, but it was not found", expectedSource)
|
||||
}
|
||||
|
||||
// AssertSourceDisabled fails the test if the specified source is in the enabled sources list.
|
||||
// This helper reduces duplication in tests that verify configuration sources are disabled.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// AssertSourceDisabled(t, enabledSources, appconstants.ConfigSourceGlobal)
|
||||
func AssertSourceDisabled(
|
||||
t *testing.T,
|
||||
sources []appconstants.ConfigurationSource,
|
||||
expectedSource appconstants.ConfigurationSource,
|
||||
) {
|
||||
t.Helper()
|
||||
for _, source := range sources {
|
||||
if source == expectedSource {
|
||||
t.Errorf("expected source %s to be disabled, but it was found", expectedSource)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AssertAllSourcesEnabled fails the test if any of the expected sources are not in the enabled sources list.
|
||||
// This helper reduces duplication in tests that verify multiple configuration sources are enabled.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// AssertAllSourcesEnabled(t, enabledSources,
|
||||
// appconstants.ConfigSourceGlobal,
|
||||
// appconstants.ConfigSourceRepo,
|
||||
// appconstants.ConfigSourceAction)
|
||||
func AssertAllSourcesEnabled(
|
||||
t *testing.T,
|
||||
sources []appconstants.ConfigurationSource,
|
||||
expectedSources ...appconstants.ConfigurationSource,
|
||||
) {
|
||||
t.Helper()
|
||||
for _, expected := range expectedSources {
|
||||
AssertSourceEnabled(t, sources, expected)
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
// Package internal provides common constants used throughout the application.
|
||||
package internal
|
||||
|
||||
// File extension constants.
|
||||
const (
|
||||
// ActionFileExtYML is the primary action file extension.
|
||||
ActionFileExtYML = ".yml"
|
||||
// ActionFileExtYAML is the alternative action file extension.
|
||||
ActionFileExtYAML = ".yaml"
|
||||
|
||||
// ActionFileNameYML is the primary action file name.
|
||||
ActionFileNameYML = "action.yml"
|
||||
// ActionFileNameYAML is the alternative action file name.
|
||||
ActionFileNameYAML = "action.yaml"
|
||||
)
|
||||
|
||||
// File permission constants.
|
||||
const (
|
||||
// FilePermDefault is the default file permission for created files.
|
||||
FilePermDefault = 0600
|
||||
// FilePermTest is the file permission used in tests.
|
||||
FilePermTest = 0600
|
||||
)
|
||||
|
||||
// Configuration file constants.
|
||||
const (
|
||||
// ConfigFileName is the primary configuration file name.
|
||||
ConfigFileName = "config"
|
||||
// ConfigFileExtYAML is the configuration file extension.
|
||||
ConfigFileExtYAML = ".yaml"
|
||||
// ConfigFileNameFull is the full configuration file name.
|
||||
ConfigFileNameFull = ConfigFileName + ConfigFileExtYAML
|
||||
)
|
||||
|
||||
// Context key constants for maps and data structures.
|
||||
const (
|
||||
// ContextKeyError is used as a key for error information in context maps.
|
||||
ContextKeyError = "error"
|
||||
// ContextKeyTheme is used as a key for theme information.
|
||||
ContextKeyTheme = "theme"
|
||||
// ContextKeyConfig is used as a key for configuration information.
|
||||
ContextKeyConfig = "config"
|
||||
)
|
||||
|
||||
// Common string identifiers.
|
||||
const (
|
||||
// ThemeGitHub is the GitHub theme identifier.
|
||||
ThemeGitHub = "github"
|
||||
// ThemeGitLab is the GitLab theme identifier.
|
||||
ThemeGitLab = "gitlab"
|
||||
// ThemeMinimal is the minimal theme identifier.
|
||||
ThemeMinimal = "minimal"
|
||||
// ThemeProfessional is the professional theme identifier.
|
||||
ThemeProfessional = "professional"
|
||||
// ThemeDefault is the default theme identifier.
|
||||
ThemeDefault = "default"
|
||||
)
|
||||
|
||||
// Environment variable names.
|
||||
const (
|
||||
// EnvGitHubToken is the tool-specific GitHub token environment variable.
|
||||
EnvGitHubToken = "GH_README_GITHUB_TOKEN" // #nosec G101 -- environment variable name, not a credential
|
||||
// EnvGitHubTokenStandard is the standard GitHub token environment variable.
|
||||
EnvGitHubTokenStandard = "GITHUB_TOKEN" // #nosec G101 -- environment variable name, not a credential
|
||||
)
|
||||
|
||||
// Configuration keys and paths.
|
||||
const (
|
||||
// ConfigKeyGitHubToken is the configuration key for GitHub token.
|
||||
ConfigKeyGitHubToken = "github_token"
|
||||
// ConfigKeyTheme is the configuration key for theme.
|
||||
ConfigKeyTheme = "theme"
|
||||
// ConfigKeyOutputFormat is the configuration key for output format.
|
||||
ConfigKeyOutputFormat = "output_format"
|
||||
// ConfigKeyOutputDir is the configuration key for output directory.
|
||||
ConfigKeyOutputDir = "output_dir"
|
||||
// ConfigKeyVerbose is the configuration key for verbose mode.
|
||||
ConfigKeyVerbose = "verbose"
|
||||
// ConfigKeyQuiet is the configuration key for quiet mode.
|
||||
ConfigKeyQuiet = "quiet"
|
||||
// ConfigKeyAnalyzeDependencies is the configuration key for dependency analysis.
|
||||
ConfigKeyAnalyzeDependencies = "analyze_dependencies"
|
||||
// ConfigKeyShowSecurityInfo is the configuration key for security info display.
|
||||
ConfigKeyShowSecurityInfo = "show_security_info"
|
||||
)
|
||||
|
||||
// Template path constants.
|
||||
const (
|
||||
// TemplatePathDefault is the default template path.
|
||||
TemplatePathDefault = "templates/readme.tmpl"
|
||||
// TemplatePathGitHub is the GitHub theme template path.
|
||||
TemplatePathGitHub = "templates/themes/github/readme.tmpl"
|
||||
// TemplatePathGitLab is the GitLab theme template path.
|
||||
TemplatePathGitLab = "templates/themes/gitlab/readme.tmpl"
|
||||
// TemplatePathMinimal is the minimal theme template path.
|
||||
TemplatePathMinimal = "templates/themes/minimal/readme.tmpl"
|
||||
// TemplatePathProfessional is the professional theme template path.
|
||||
TemplatePathProfessional = "templates/themes/professional/readme.tmpl"
|
||||
)
|
||||
|
||||
// Config file search patterns.
|
||||
const (
|
||||
// ConfigFilePatternHidden is the primary hidden config file pattern.
|
||||
ConfigFilePatternHidden = ".ghreadme.yaml"
|
||||
// ConfigFilePatternConfig is the secondary config directory pattern.
|
||||
ConfigFilePatternConfig = ".config/ghreadme.yaml"
|
||||
// ConfigFilePatternGitHub is the GitHub ecosystem config pattern.
|
||||
ConfigFilePatternGitHub = ".github/ghreadme.yaml"
|
||||
)
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/google/go-github/v74/github"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/git"
|
||||
)
|
||||
|
||||
@@ -27,49 +28,6 @@ const (
|
||||
BranchName VersionType = "branch"
|
||||
// LocalPath represents a local file path reference.
|
||||
LocalPath VersionType = "local"
|
||||
|
||||
// Common string constants.
|
||||
compositeUsing = "composite"
|
||||
updateTypeNone = "none"
|
||||
updateTypeMajor = "major"
|
||||
updateTypePatch = "patch"
|
||||
updateTypeMinor = "minor"
|
||||
defaultBranch = "main"
|
||||
|
||||
// Timeout constants.
|
||||
apiCallTimeout = 10 * time.Second
|
||||
cacheDefaultTTL = 1 * time.Hour
|
||||
|
||||
// File permission constants.
|
||||
backupFilePerms = 0600
|
||||
updatedFilePerms = 0600
|
||||
|
||||
// GitHub URL patterns.
|
||||
githubBaseURL = "https://github.com"
|
||||
marketplaceBaseURL = "https://github.com/marketplace/actions/"
|
||||
|
||||
// Version parsing constants.
|
||||
fullSHALength = 40
|
||||
minSHALength = 7
|
||||
versionPartsCount = 3
|
||||
|
||||
// File path patterns.
|
||||
dockerPrefix = "docker://"
|
||||
localPathPrefix = "./"
|
||||
localPathUpPrefix = "../"
|
||||
|
||||
// File extensions.
|
||||
backupExtension = ".backup"
|
||||
|
||||
// Cache key prefixes.
|
||||
cacheKeyLatest = "latest:"
|
||||
cacheKeyRepo = "repo:"
|
||||
|
||||
// YAML structure constants.
|
||||
usesFieldPrefix = "uses: "
|
||||
|
||||
// Special line estimation for script URLs.
|
||||
scriptLineEstimate = 10
|
||||
)
|
||||
|
||||
// Dependency represents a GitHub Action dependency with detailed information.
|
||||
@@ -188,13 +146,16 @@ func (a *Analyzer) CheckOutdated(deps []Dependency) ([]OutdatedDependency, error
|
||||
}
|
||||
|
||||
updateType := a.compareVersions(currentVersion, latestVersion)
|
||||
if updateType != updateTypeNone {
|
||||
if updateType != appconstants.UpdateTypeNone {
|
||||
outdated = append(outdated, OutdatedDependency{
|
||||
Current: dep,
|
||||
LatestVersion: latestVersion,
|
||||
LatestSHA: latestSHA,
|
||||
UpdateType: updateType,
|
||||
IsSecurityUpdate: updateType == updateTypeMajor, // Assume major updates might be security
|
||||
Current: dep,
|
||||
LatestVersion: latestVersion,
|
||||
LatestSHA: latestSHA,
|
||||
UpdateType: updateType,
|
||||
// Don't assume major version bumps are security updates
|
||||
// This should only be set if confirmed by security advisory data
|
||||
// Future enhancement: integrate with GitHub Security Advisories API
|
||||
IsSecurityUpdate: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -252,7 +213,7 @@ func (a *Analyzer) validateAndCheckComposite(
|
||||
action *ActionWithComposite,
|
||||
progressCallback func(current, total int, message string),
|
||||
) ([]Dependency, bool, error) {
|
||||
if action.Runs.Using != compositeUsing {
|
||||
if action.Runs.Using != appconstants.ActionTypeComposite {
|
||||
if err := a.validateActionType(action.Runs.Using); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
@@ -336,13 +297,13 @@ func (a *Analyzer) analyzeActionDependency(step CompositeStep, _ int) (*Dependen
|
||||
|
||||
// Build dependency
|
||||
dep := &Dependency{
|
||||
Name: fmt.Sprintf("%s/%s", owner, repo),
|
||||
Name: fmt.Sprintf(appconstants.URLPatternGitHubRepo, owner, repo),
|
||||
Uses: step.Uses,
|
||||
Version: version,
|
||||
VersionType: versionType,
|
||||
IsPinned: versionType == CommitSHA || (versionType == SemanticVersion && a.isVersionPinned(version)),
|
||||
Author: owner,
|
||||
SourceURL: fmt.Sprintf("%s/%s/%s", githubBaseURL, owner, repo),
|
||||
SourceURL: fmt.Sprintf("%s/%s/%s", appconstants.GitHubBaseURL, owner, repo),
|
||||
IsLocalAction: isLocal,
|
||||
IsShellScript: false,
|
||||
WithParams: a.convertWithParams(step.With),
|
||||
@@ -350,7 +311,7 @@ func (a *Analyzer) analyzeActionDependency(step CompositeStep, _ int) (*Dependen
|
||||
|
||||
// Add marketplace URL for public actions
|
||||
if !isLocal {
|
||||
dep.MarketplaceURL = marketplaceBaseURL + repo
|
||||
dep.MarketplaceURL = fmt.Sprintf("%s%s/%s", appconstants.MarketplaceBaseURL, owner, repo)
|
||||
}
|
||||
|
||||
// Fetch additional metadata from GitHub API if available
|
||||
@@ -375,11 +336,11 @@ func (a *Analyzer) analyzeShellScript(step CompositeStep, stepNumber int) *Depen
|
||||
// This would ideally link to the specific line in the action.yml file
|
||||
scriptURL = fmt.Sprintf(
|
||||
"%s/%s/%s/blob/%s/action.yml#L%d",
|
||||
githubBaseURL,
|
||||
appconstants.GitHubBaseURL,
|
||||
a.RepoInfo.Organization,
|
||||
a.RepoInfo.Repository,
|
||||
a.RepoInfo.DefaultBranch,
|
||||
stepNumber*scriptLineEstimate,
|
||||
stepNumber*appconstants.ScriptLineEstimate,
|
||||
) // Rough estimate
|
||||
}
|
||||
|
||||
@@ -408,11 +369,12 @@ func (a *Analyzer) parseUsesStatement(uses string) (owner, repo, version string,
|
||||
// - ./local-action
|
||||
// - docker://alpine:3.14
|
||||
|
||||
if strings.HasPrefix(uses, localPathPrefix) || strings.HasPrefix(uses, localPathUpPrefix) {
|
||||
if strings.HasPrefix(uses, appconstants.LocalPathPrefix) ||
|
||||
strings.HasPrefix(uses, appconstants.LocalPathUpPrefix) {
|
||||
return "", "", uses, LocalPath
|
||||
}
|
||||
|
||||
if strings.HasPrefix(uses, dockerPrefix) {
|
||||
if strings.HasPrefix(uses, appconstants.DockerPrefix) {
|
||||
return "", "", uses, LocalPath
|
||||
}
|
||||
|
||||
@@ -443,9 +405,9 @@ func (a *Analyzer) parseUsesStatement(uses string) (owner, repo, version string,
|
||||
// isCommitSHA checks if a version string is a commit SHA.
|
||||
func (a *Analyzer) isCommitSHA(version string) bool {
|
||||
// Check if it's a 40-character hex string (full SHA) or 7+ character hex (short SHA)
|
||||
re := regexp.MustCompile(`^[a-f0-9]{7,40}$`)
|
||||
re := regexp.MustCompile(appconstants.RegexGitSHA)
|
||||
|
||||
return len(version) >= minSHALength && re.MatchString(version)
|
||||
return len(version) >= appconstants.MinSHALength && re.MatchString(version)
|
||||
}
|
||||
|
||||
// isSemanticVersion checks if a version string follows semantic versioning.
|
||||
@@ -460,7 +422,7 @@ func (a *Analyzer) isSemanticVersion(version string) bool {
|
||||
func (a *Analyzer) isVersionPinned(version string) bool {
|
||||
// Consider it pinned if it specifies patch version (v1.2.3) or is a commit SHA
|
||||
// Also check for full commit SHAs (40 chars)
|
||||
if len(version) == fullSHALength {
|
||||
if len(version) == appconstants.FullSHALength {
|
||||
return true
|
||||
}
|
||||
re := regexp.MustCompile(`^v?\d+\.\d+\.\d+`)
|
||||
@@ -488,11 +450,11 @@ func (a *Analyzer) getLatestVersion(owner, repo string) (version, sha string, er
|
||||
return "", "", errors.New("GitHub client not available")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), apiCallTimeout)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), appconstants.APICallTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Check cache first
|
||||
cacheKey := cacheKeyLatest + fmt.Sprintf("%s/%s", owner, repo)
|
||||
cacheKey := appconstants.CacheKeyLatest + fmt.Sprintf(appconstants.URLPatternGitHubRepo, owner, repo)
|
||||
if version, sha, found := a.getCachedVersion(cacheKey); found {
|
||||
return version, sha, nil
|
||||
}
|
||||
@@ -578,7 +540,7 @@ func (a *Analyzer) cacheVersion(cacheKey, version, sha string) {
|
||||
}
|
||||
|
||||
versionInfo := map[string]string{"version": version, "sha": sha}
|
||||
_ = a.Cache.SetWithTTL(cacheKey, versionInfo, cacheDefaultTTL)
|
||||
_ = a.Cache.SetWithTTL(cacheKey, versionInfo, appconstants.CacheDefaultTTL)
|
||||
}
|
||||
|
||||
// compareVersions compares two version strings and returns the update type.
|
||||
@@ -587,12 +549,12 @@ func (a *Analyzer) compareVersions(current, latest string) string {
|
||||
latestClean := strings.TrimPrefix(latest, "v")
|
||||
|
||||
if currentClean == latestClean {
|
||||
return updateTypeNone
|
||||
return appconstants.UpdateTypeNone
|
||||
}
|
||||
|
||||
// Special case: floating major version (e.g., "4" -> "4.1.1") should be patch
|
||||
if !strings.Contains(currentClean, ".") && strings.HasPrefix(latestClean, currentClean+".") {
|
||||
return updateTypePatch
|
||||
return appconstants.UpdateTypePatch
|
||||
}
|
||||
|
||||
currentParts := a.parseVersionParts(currentClean)
|
||||
@@ -605,7 +567,7 @@ func (a *Analyzer) compareVersions(current, latest string) string {
|
||||
func (a *Analyzer) parseVersionParts(version string) []string {
|
||||
parts := strings.Split(version, ".")
|
||||
// For floating versions like "v4", treat as "v4.0.0" for comparison
|
||||
for len(parts) < versionPartsCount {
|
||||
for len(parts) < appconstants.VersionPartsCount {
|
||||
parts = append(parts, "0")
|
||||
}
|
||||
|
||||
@@ -615,16 +577,16 @@ func (a *Analyzer) parseVersionParts(version string) []string {
|
||||
// determineUpdateType compares version parts and returns update type.
|
||||
func (a *Analyzer) determineUpdateType(currentParts, latestParts []string) string {
|
||||
if currentParts[0] != latestParts[0] {
|
||||
return updateTypeMajor
|
||||
return appconstants.UpdateTypeMajor
|
||||
}
|
||||
if currentParts[1] != latestParts[1] {
|
||||
return updateTypeMinor
|
||||
return appconstants.UpdateTypeMinor
|
||||
}
|
||||
if currentParts[2] != latestParts[2] {
|
||||
return updateTypePatch
|
||||
return appconstants.UpdateTypePatch
|
||||
}
|
||||
|
||||
return updateTypeNone
|
||||
return appconstants.UpdateTypeNone
|
||||
}
|
||||
|
||||
// updateActionFile applies updates to a single action file.
|
||||
@@ -636,35 +598,59 @@ 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)
|
||||
}
|
||||
|
||||
// Apply updates to content
|
||||
lines := strings.Split(string(content), "\n")
|
||||
for _, update := range updates {
|
||||
// Find and replace the uses line
|
||||
for i, line := range lines {
|
||||
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
|
||||
update.LineNumber = i + 1 // Store line number for reference
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
applyUpdatesToLines(lines, updates)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Validate the updated file by trying to parse it
|
||||
// Validate and rollback on failure
|
||||
if err := a.validateAndRollbackOnFailure(filePath, backupPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove backup on success
|
||||
_ = os.Remove(backupPath)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyUpdatesToLines applies all updates to the file lines in place.
|
||||
// Preserves indentation and YAML list markers.
|
||||
func applyUpdatesToLines(lines []string, updates []PinnedUpdate) {
|
||||
for _, update := range updates {
|
||||
for i, line := range lines {
|
||||
if !strings.Contains(line, update.OldUses) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Preserve both indentation AND list markers
|
||||
trimmed := strings.TrimLeft(line, " \t")
|
||||
indent := strings.Repeat(" ", len(line)-len(trimmed))
|
||||
|
||||
// Check if this is a list item (starts with "- ")
|
||||
listMarker := ""
|
||||
if strings.HasPrefix(trimmed, "- ") {
|
||||
listMarker = "- "
|
||||
}
|
||||
|
||||
// Reconstruct: indent + list marker + uses field
|
||||
lines[i] = indent + listMarker + appconstants.UsesFieldPrefix + update.NewUses
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// validateAndRollbackOnFailure validates the action file and rolls back changes on failure.
|
||||
func (a *Analyzer) validateAndRollbackOnFailure(filePath, backupPath string) error {
|
||||
if err := a.validateActionFile(filePath); err != nil {
|
||||
// Rollback on validation failure
|
||||
if rollbackErr := os.Rename(backupPath, filePath); rollbackErr != nil {
|
||||
@@ -674,26 +660,69 @@ func (a *Analyzer) updateActionFile(filePath string, updates []PinnedUpdate) err
|
||||
return fmt.Errorf("validation failed, rolled back changes: %w", err)
|
||||
}
|
||||
|
||||
// Remove backup on success
|
||||
_ = os.Remove(backupPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateActionFile validates that an action.yml file conforms to GitHub Actions schema.
|
||||
// Schema reference: https://www.schemastore.org/github-action.json
|
||||
func (a *Analyzer) validateActionFile(filePath string) error {
|
||||
// Parse to check YAML syntax
|
||||
action, err := a.parseCompositeAction(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Validate required fields per GitHub Actions schema
|
||||
if action.Name == "" {
|
||||
return errors.New("validation failed: missing required field 'name'")
|
||||
}
|
||||
if action.Description == "" {
|
||||
return errors.New("validation failed: missing required field 'description'")
|
||||
}
|
||||
if action.Runs.Using == "" {
|
||||
return errors.New("validation failed: missing required field 'runs.using'")
|
||||
}
|
||||
|
||||
// Validate 'using' field value against GitHub Actions specification
|
||||
// Valid runtimes: node12, node16, node20, node24, docker, composite
|
||||
// Reference: https://docs.github.com/en/actions/creating-actions
|
||||
validRuntimes := []string{
|
||||
"node12",
|
||||
"node16",
|
||||
"node20",
|
||||
"node24",
|
||||
"docker",
|
||||
"composite",
|
||||
}
|
||||
|
||||
validUsing := false
|
||||
runtime := strings.TrimSpace(strings.ToLower(action.Runs.Using))
|
||||
for _, valid := range validRuntimes {
|
||||
if runtime == valid {
|
||||
validUsing = true
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !validUsing {
|
||||
return fmt.Errorf(
|
||||
"validation failed: invalid value for 'runs.using': %s (valid: %s)",
|
||||
action.Runs.Using,
|
||||
strings.Join(validRuntimes, ", "),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateActionFile validates that an action.yml file is still valid after updates.
|
||||
func (a *Analyzer) validateActionFile(filePath string) error {
|
||||
_, err := a.parseCompositeAction(filePath)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// 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 +741,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,52 +10,122 @@ 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"
|
||||
)
|
||||
|
||||
func TestAnalyzer_AnalyzeActionFile(t *testing.T) {
|
||||
// analyzeActionFileTestCase describes a single test case for AnalyzeActionFile.
|
||||
type analyzeActionFileTestCase struct {
|
||||
name string
|
||||
actionYML string
|
||||
expectError bool
|
||||
expectDeps bool
|
||||
expectedLen int
|
||||
expectedDeps []string
|
||||
}
|
||||
|
||||
// runAnalyzeActionFileTest executes a single test case with setup, analysis, and validation.
|
||||
func runAnalyzeActionFileTest(t *testing.T, tt analyzeActionFileTestCase) {
|
||||
t.Helper()
|
||||
|
||||
tmpDir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
actionPath := filepath.Join(tmpDir, appconstants.ActionFileNameYML)
|
||||
testutil.WriteTestFile(t, actionPath, tt.actionYML)
|
||||
|
||||
mockResponses := testutil.MockGitHubResponses()
|
||||
githubClient := testutil.MockGitHubClient(mockResponses)
|
||||
cacheInstance, _ := cache.NewCache(cache.DefaultConfig())
|
||||
|
||||
analyzer := &Analyzer{
|
||||
GitHubClient: githubClient,
|
||||
Cache: NewCacheAdapter(cacheInstance),
|
||||
}
|
||||
|
||||
deps, err := analyzer.AnalyzeActionFile(actionPath)
|
||||
|
||||
if tt.expectError {
|
||||
testutil.AssertError(t, err)
|
||||
|
||||
return
|
||||
}
|
||||
testutil.AssertNoError(t, err)
|
||||
|
||||
validateAnalyzedDependencies(t, tt, deps)
|
||||
}
|
||||
|
||||
// validateAnalyzedDependencies checks that analyzed dependencies match expectations.
|
||||
func validateAnalyzedDependencies(t *testing.T, tt analyzeActionFileTestCase, deps []Dependency) {
|
||||
t.Helper()
|
||||
|
||||
if tt.expectDeps {
|
||||
validateExpectedDeps(t, tt, deps)
|
||||
} else if len(deps) != 0 {
|
||||
t.Errorf("expected no dependencies, got %d", len(deps))
|
||||
}
|
||||
}
|
||||
|
||||
// validateExpectedDeps validates dependencies when deps are expected.
|
||||
func validateExpectedDeps(t *testing.T, tt analyzeActionFileTestCase, deps []Dependency) {
|
||||
t.Helper()
|
||||
|
||||
if len(deps) != tt.expectedLen {
|
||||
t.Errorf("expected %d dependencies, got %d", tt.expectedLen, len(deps))
|
||||
}
|
||||
|
||||
if tt.expectedDeps == nil {
|
||||
return
|
||||
}
|
||||
|
||||
for i, expectedDep := range tt.expectedDeps {
|
||||
if i >= len(deps) {
|
||||
t.Errorf("expected dependency %s but got fewer dependencies", expectedDep)
|
||||
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(deps[i].Name+"@"+deps[i].Version, expectedDep) {
|
||||
t.Errorf("expected dependency %s, got %s@%s", expectedDep, deps[i].Name, deps[i].Version)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzerAnalyzeActionFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
actionYML string
|
||||
expectError bool
|
||||
expectDeps bool
|
||||
expectedLen int
|
||||
expectedDeps []string
|
||||
}{
|
||||
tests := []analyzeActionFileTestCase{
|
||||
{
|
||||
name: "simple action - no dependencies",
|
||||
actionYML: testutil.MustReadFixture("actions/javascript/simple.yml"),
|
||||
actionYML: testutil.MustReadFixture(testutil.TestFixtureJavaScriptSimple),
|
||||
expectError: false,
|
||||
expectDeps: false,
|
||||
expectedLen: 0,
|
||||
},
|
||||
{
|
||||
name: "composite action with dependencies",
|
||||
actionYML: testutil.MustReadFixture("actions/composite/with-dependencies.yml"),
|
||||
actionYML: testutil.MustReadFixture(testutil.TestFixtureCompositeWithDeps),
|
||||
expectError: false,
|
||||
expectDeps: true,
|
||||
expectedLen: 5, // 3 action dependencies + 2 shell script dependencies
|
||||
expectedDeps: []string{"actions/checkout@v4", "actions/setup-node@v4", "actions/setup-python@v4"},
|
||||
expectedLen: 5,
|
||||
expectedDeps: []string{testutil.TestActionCheckoutV4, "actions/setup-node@v4", "actions/setup-python@v4"},
|
||||
},
|
||||
{
|
||||
name: "docker action - no step dependencies",
|
||||
actionYML: testutil.MustReadFixture("actions/docker/basic.yml"),
|
||||
actionYML: testutil.MustReadFixture(testutil.TestFixtureDockerBasic),
|
||||
expectError: false,
|
||||
expectDeps: false,
|
||||
expectedLen: 0,
|
||||
},
|
||||
{
|
||||
name: "invalid action file",
|
||||
actionYML: testutil.MustReadFixture("actions/invalid/invalid-using.yml"),
|
||||
name: testutil.TestCaseNameInvalidActionFile,
|
||||
actionYML: testutil.MustReadFixture(testutil.TestFixtureInvalidInvalidUsing),
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "minimal action - no dependencies",
|
||||
actionYML: testutil.MustReadFixture("minimal-action.yml"),
|
||||
actionYML: testutil.MustReadFixture(testutil.TestFixtureMinimalAction),
|
||||
expectError: false,
|
||||
expectDeps: false,
|
||||
expectedLen: 0,
|
||||
@@ -65,62 +135,12 @@ func TestAnalyzer_AnalyzeActionFile(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Create temporary action file
|
||||
tmpDir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
actionPath := filepath.Join(tmpDir, "action.yml")
|
||||
testutil.WriteTestFile(t, actionPath, tt.actionYML)
|
||||
|
||||
// Create analyzer with mock GitHub client
|
||||
mockResponses := testutil.MockGitHubResponses()
|
||||
githubClient := testutil.MockGitHubClient(mockResponses)
|
||||
cacheInstance, _ := cache.NewCache(cache.DefaultConfig())
|
||||
|
||||
analyzer := &Analyzer{
|
||||
GitHubClient: githubClient,
|
||||
Cache: NewCacheAdapter(cacheInstance),
|
||||
}
|
||||
|
||||
// Analyze the action file
|
||||
deps, err := analyzer.AnalyzeActionFile(actionPath)
|
||||
|
||||
// Check error expectation
|
||||
if tt.expectError {
|
||||
testutil.AssertError(t, err)
|
||||
|
||||
return
|
||||
}
|
||||
testutil.AssertNoError(t, err)
|
||||
|
||||
// Check dependencies
|
||||
if tt.expectDeps {
|
||||
if len(deps) != tt.expectedLen {
|
||||
t.Errorf("expected %d dependencies, got %d", tt.expectedLen, len(deps))
|
||||
}
|
||||
|
||||
// Check specific dependencies if provided
|
||||
if tt.expectedDeps != nil {
|
||||
for i, expectedDep := range tt.expectedDeps {
|
||||
if i >= len(deps) {
|
||||
t.Errorf("expected dependency %s but got fewer dependencies", expectedDep)
|
||||
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(deps[i].Name+"@"+deps[i].Version, expectedDep) {
|
||||
t.Errorf("expected dependency %s, got %s@%s", expectedDep, deps[i].Name, deps[i].Version)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if len(deps) != 0 {
|
||||
t.Errorf("expected no dependencies, got %d", len(deps))
|
||||
}
|
||||
runAnalyzeActionFileTest(t, tt)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzer_ParseUsesStatement(t *testing.T) {
|
||||
func TestAnalyzerParseUsesStatement(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
@@ -132,8 +152,8 @@ func TestAnalyzer_ParseUsesStatement(t *testing.T) {
|
||||
expectedType VersionType
|
||||
}{
|
||||
{
|
||||
name: "semantic version",
|
||||
uses: "actions/checkout@v4",
|
||||
name: testutil.TestCaseNameSemanticVersion,
|
||||
uses: testutil.TestActionCheckoutV4,
|
||||
expectedOwner: "actions",
|
||||
expectedRepo: "checkout",
|
||||
expectedVersion: "v4",
|
||||
@@ -148,11 +168,11 @@ func TestAnalyzer_ParseUsesStatement(t *testing.T) {
|
||||
expectedType: SemanticVersion,
|
||||
},
|
||||
{
|
||||
name: "commit SHA",
|
||||
name: testutil.TestCaseNameCommitSHA,
|
||||
uses: "actions/checkout@8f4b7f84bd579b95d7f0b90f8d8b6e5d9b8a7f6e",
|
||||
expectedOwner: "actions",
|
||||
expectedRepo: "checkout",
|
||||
expectedVersion: "8f4b7f84bd579b95d7f0b90f8d8b6e5d9b8a7f6e",
|
||||
expectedVersion: testutil.TestSHAForTesting,
|
||||
expectedType: CommitSHA,
|
||||
},
|
||||
{
|
||||
@@ -181,7 +201,7 @@ func TestAnalyzer_ParseUsesStatement(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzer_VersionChecking(t *testing.T) {
|
||||
func TestAnalyzerVersionChecking(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
@@ -207,7 +227,7 @@ func TestAnalyzer_VersionChecking(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "commit SHA full",
|
||||
version: "8f4b7f84bd579b95d7f0b90f8d8b6e5d9b8a7f6e",
|
||||
version: testutil.TestSHAForTesting,
|
||||
isPinned: true,
|
||||
isCommitSHA: true,
|
||||
isSemantic: false,
|
||||
@@ -252,7 +272,7 @@ func TestAnalyzer_VersionChecking(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzer_GetLatestVersion(t *testing.T) {
|
||||
func TestAnalyzerGetLatestVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Create mock GitHub client with test responses
|
||||
@@ -277,15 +297,15 @@ func TestAnalyzer_GetLatestVersion(t *testing.T) {
|
||||
name: "valid repository",
|
||||
owner: "actions",
|
||||
repo: "checkout",
|
||||
expectedVersion: "v4.1.1",
|
||||
expectedSHA: "8f4b7f84bd579b95d7f0b90f8d8b6e5d9b8a7f6e",
|
||||
expectedVersion: testutil.TestVersionV4_1_1,
|
||||
expectedSHA: testutil.TestSHAForTesting,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "another valid repository",
|
||||
owner: "actions",
|
||||
repo: "setup-node",
|
||||
expectedVersion: "v4.0.0",
|
||||
expectedVersion: testutil.TestVersionV4_0_0,
|
||||
expectedSHA: "1a4e6d7c9f8e5b2a3c4d5e6f7a8b9c0d1e2f3a4b",
|
||||
expectError: false,
|
||||
},
|
||||
@@ -310,7 +330,7 @@ func TestAnalyzer_GetLatestVersion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzer_CheckOutdated(t *testing.T) {
|
||||
func TestAnalyzerCheckOutdated(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Create mock GitHub client
|
||||
@@ -326,8 +346,8 @@ func TestAnalyzer_CheckOutdated(t *testing.T) {
|
||||
// Create test dependencies
|
||||
dependencies := []Dependency{
|
||||
{
|
||||
Name: "actions/checkout",
|
||||
Uses: "actions/checkout@v3",
|
||||
Name: testutil.TestActionCheckout,
|
||||
Uses: testutil.TestActionCheckoutV3,
|
||||
Version: "v3",
|
||||
IsPinned: false,
|
||||
VersionType: SemanticVersion,
|
||||
@@ -336,7 +356,7 @@ func TestAnalyzer_CheckOutdated(t *testing.T) {
|
||||
{
|
||||
Name: "actions/setup-node",
|
||||
Uses: "actions/setup-node@v4.0.0",
|
||||
Version: "v4.0.0",
|
||||
Version: testutil.TestVersionV4_0_0,
|
||||
IsPinned: true,
|
||||
VersionType: SemanticVersion,
|
||||
Description: "Setup Node.js",
|
||||
@@ -353,9 +373,9 @@ func TestAnalyzer_CheckOutdated(t *testing.T) {
|
||||
|
||||
found := false
|
||||
for _, dep := range outdated {
|
||||
if dep.Current.Name == "actions/checkout" && dep.Current.Version == "v3" {
|
||||
if dep.Current.Name == testutil.TestActionCheckout && dep.Current.Version == "v3" {
|
||||
found = true
|
||||
if dep.LatestVersion != "v4.1.1" {
|
||||
if dep.LatestVersion != testutil.TestVersionV4_1_1 {
|
||||
t.Errorf("expected latest version v4.1.1, got %s", dep.LatestVersion)
|
||||
}
|
||||
if dep.UpdateType != "major" {
|
||||
@@ -369,7 +389,7 @@ func TestAnalyzer_CheckOutdated(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzer_CompareVersions(t *testing.T) {
|
||||
func TestAnalyzerCompareVersions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
analyzer := &Analyzer{}
|
||||
@@ -383,31 +403,31 @@ func TestAnalyzer_CompareVersions(t *testing.T) {
|
||||
{
|
||||
name: "major version difference",
|
||||
current: "v3.0.0",
|
||||
latest: "v4.0.0",
|
||||
latest: testutil.TestVersionV4_0_0,
|
||||
expectedType: "major",
|
||||
},
|
||||
{
|
||||
name: "minor version difference",
|
||||
current: "v4.0.0",
|
||||
current: testutil.TestVersionV4_0_0,
|
||||
latest: "v4.1.0",
|
||||
expectedType: "minor",
|
||||
},
|
||||
{
|
||||
name: "patch version difference",
|
||||
current: "v4.1.0",
|
||||
latest: "v4.1.1",
|
||||
latest: testutil.TestVersionV4_1_1,
|
||||
expectedType: "patch",
|
||||
},
|
||||
{
|
||||
name: "no difference",
|
||||
current: "v4.1.1",
|
||||
latest: "v4.1.1",
|
||||
current: testutil.TestVersionV4_1_1,
|
||||
latest: testutil.TestVersionV4_1_1,
|
||||
expectedType: "none",
|
||||
},
|
||||
{
|
||||
name: "floating to specific",
|
||||
current: "v4",
|
||||
latest: "v4.1.1",
|
||||
latest: testutil.TestVersionV4_1_1,
|
||||
expectedType: "patch",
|
||||
},
|
||||
}
|
||||
@@ -422,16 +442,16 @@ func TestAnalyzer_CompareVersions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzer_GeneratePinnedUpdate(t *testing.T) {
|
||||
func TestAnalyzerGeneratePinnedUpdate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmpDir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
// Create a test action file with composite steps
|
||||
actionContent := testutil.MustReadFixture("test-composite-action.yml")
|
||||
actionContent := testutil.MustReadFixture(testutil.TestFixtureTestCompositeAction)
|
||||
|
||||
actionPath := filepath.Join(tmpDir, "action.yml")
|
||||
actionPath := filepath.Join(tmpDir, appconstants.ActionFileNameYML)
|
||||
testutil.WriteTestFile(t, actionPath, actionContent)
|
||||
|
||||
// Create analyzer
|
||||
@@ -446,8 +466,8 @@ func TestAnalyzer_GeneratePinnedUpdate(t *testing.T) {
|
||||
|
||||
// Create test dependency
|
||||
dep := Dependency{
|
||||
Name: "actions/checkout",
|
||||
Uses: "actions/checkout@v3",
|
||||
Name: testutil.TestActionCheckout,
|
||||
Uses: testutil.TestActionCheckoutV3,
|
||||
Version: "v3",
|
||||
IsPinned: false,
|
||||
VersionType: SemanticVersion,
|
||||
@@ -458,21 +478,21 @@ func TestAnalyzer_GeneratePinnedUpdate(t *testing.T) {
|
||||
update, err := analyzer.GeneratePinnedUpdate(
|
||||
actionPath,
|
||||
dep,
|
||||
"v4.1.1",
|
||||
"8f4b7f84bd579b95d7f0b90f8d8b6e5d9b8a7f6e",
|
||||
testutil.TestVersionV4_1_1,
|
||||
testutil.TestSHAForTesting,
|
||||
)
|
||||
|
||||
testutil.AssertNoError(t, err)
|
||||
|
||||
// Verify update details
|
||||
testutil.AssertEqual(t, actionPath, update.FilePath)
|
||||
testutil.AssertEqual(t, "actions/checkout@v3", update.OldUses)
|
||||
testutil.AssertEqual(t, testutil.TestActionCheckoutV3, update.OldUses)
|
||||
testutil.AssertStringContains(t, update.NewUses, "actions/checkout@8f4b7f84bd579b95d7f0b90f8d8b6e5d9b8a7f6e")
|
||||
testutil.AssertStringContains(t, update.NewUses, "# v4.1.1")
|
||||
testutil.AssertEqual(t, "major", update.UpdateType)
|
||||
}
|
||||
|
||||
func TestAnalyzer_WithCache(t *testing.T) {
|
||||
func TestAnalyzerWithCache(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Test that caching works properly
|
||||
@@ -498,7 +518,7 @@ func TestAnalyzer_WithCache(t *testing.T) {
|
||||
testutil.AssertEqual(t, sha1, sha2)
|
||||
}
|
||||
|
||||
func TestAnalyzer_RateLimitHandling(t *testing.T) {
|
||||
func TestAnalyzerRateLimitHandling(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Create mock client that returns rate limit error
|
||||
@@ -517,7 +537,7 @@ func TestAnalyzer_RateLimitHandling(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
client := github.NewClient(&http.Client{Transport: &mockTransport{client: mockClient}})
|
||||
client := github.NewClient(&http.Client{Transport: &testutil.MockTransport{Client: mockClient}})
|
||||
cacheInstance, _ := cache.NewCache(cache.DefaultConfig())
|
||||
|
||||
analyzer := &Analyzer{
|
||||
@@ -538,7 +558,7 @@ func TestAnalyzer_RateLimitHandling(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzer_WithoutGitHubClient(t *testing.T) {
|
||||
func TestAnalyzerWithoutGitHubClient(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Test graceful degradation when GitHub client is not available
|
||||
@@ -550,8 +570,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.ActionFileNameYML)
|
||||
testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture(testutil.TestFixtureCompositeBasic))
|
||||
|
||||
deps, err := analyzer.AnalyzeActionFile(actionPath)
|
||||
|
||||
@@ -568,15 +588,6 @@ func TestAnalyzer_WithoutGitHubClient(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// mockTransport wraps our mock HTTP client for GitHub client.
|
||||
type mockTransport struct {
|
||||
client *testutil.MockHTTPClient
|
||||
}
|
||||
|
||||
func (t *mockTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return t.client.Do(req)
|
||||
}
|
||||
|
||||
// TestNewAnalyzer tests the analyzer constructor.
|
||||
func TestNewAnalyzer(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -586,7 +597,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",
|
||||
@@ -653,3 +664,125 @@ func TestNewAnalyzer(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNoOpCache tests the no-op cache implementation.
|
||||
func TestNoOpCache(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
noc := NewNoOpCache()
|
||||
if noc == nil {
|
||||
t.Fatal("NewNoOpCache() returned nil")
|
||||
}
|
||||
|
||||
// Test Get - should always return false
|
||||
val, ok := noc.Get(testutil.CacheTestKey)
|
||||
if ok {
|
||||
t.Error("NoOpCache.Get() should return false")
|
||||
}
|
||||
if val != nil {
|
||||
t.Error("NoOpCache.Get() should return nil value")
|
||||
}
|
||||
|
||||
// Test Set - should not error
|
||||
err := noc.Set(testutil.CacheTestKey, testutil.CacheTestValue)
|
||||
if err != nil {
|
||||
t.Errorf("NoOpCache.Set() returned error: %v", err)
|
||||
}
|
||||
|
||||
// Test SetWithTTL - should not error
|
||||
err = noc.SetWithTTL(testutil.CacheTestKey, testutil.CacheTestValue, time.Hour)
|
||||
if err != nil {
|
||||
t.Errorf("NoOpCache.SetWithTTL() returned error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCacheAdapterSet tests the cache adapter Set method.
|
||||
func TestCacheAdapterSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c, err := cache.NewCache(cache.DefaultConfig())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create cache: %v", err)
|
||||
}
|
||||
defer testutil.CleanupCache(t, c)()
|
||||
|
||||
adapter := NewCacheAdapter(c)
|
||||
|
||||
// Test Set
|
||||
err = adapter.Set(testutil.CacheTestKey, testutil.CacheTestValue)
|
||||
if err != nil {
|
||||
t.Errorf("CacheAdapter.Set() returned error: %v", err)
|
||||
}
|
||||
|
||||
// Verify value was set
|
||||
val, ok := adapter.Get(testutil.CacheTestKey)
|
||||
if !ok {
|
||||
t.Error("CacheAdapter.Get() should return true after Set")
|
||||
}
|
||||
if val != testutil.CacheTestValue {
|
||||
t.Errorf("CacheAdapter.Get() = %v, want %q", val, testutil.CacheTestValue)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsCompositeAction tests composite action detection.
|
||||
func TestIsCompositeAction(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
fixture string
|
||||
want bool
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: testutil.TestCaseNameCompositeAction,
|
||||
fixture: "composite-action.yml",
|
||||
want: true,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "docker action",
|
||||
fixture: "docker-action.yml",
|
||||
want: false,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: testutil.TestCaseNameJavaScriptAction,
|
||||
fixture: "javascript-action.yml",
|
||||
want: false,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: testutil.TestCaseNameInvalidYAML,
|
||||
fixture: "invalid.yml",
|
||||
want: false,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Read fixture content using safe helper
|
||||
yamlContent := testutil.MustReadAnalyzerFixture(tt.fixture)
|
||||
|
||||
// Create temp file with action YAML
|
||||
tmpDir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
actionPath := filepath.Join(tmpDir, appconstants.ActionFileNameYML)
|
||||
testutil.WriteTestFile(t, actionPath, yamlContent)
|
||||
|
||||
got, err := IsCompositeAction(actionPath)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("IsCompositeAction() error = %v, wantErr %v", err, tt.wantErr)
|
||||
|
||||
return
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("IsCompositeAction() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,38 @@ package dependencies
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/goccy/go-yaml"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
)
|
||||
|
||||
// validateFilePath ensures a file path is safe to read.
|
||||
// Returns an error if the path contains traversal attempts.
|
||||
func validateFilePath(path string) error {
|
||||
cleanPath := filepath.Clean(path)
|
||||
|
||||
// Check for ".." components in cleaned path
|
||||
for _, component := range strings.Split(filepath.ToSlash(cleanPath), "/") {
|
||||
if component == ".." {
|
||||
return fmt.Errorf("invalid file path: traversal detected in %q", path)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseCompositeActionFromFile reads and parses a composite action file.
|
||||
func (a *Analyzer) parseCompositeActionFromFile(actionPath string) (*ActionWithComposite, error) {
|
||||
// Validate path before reading
|
||||
if err := validateFilePath(actionPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Read the file
|
||||
data, err := os.ReadFile(actionPath) // #nosec G304 -- action path from function parameter
|
||||
data, err := os.ReadFile(actionPath) // #nosec G304 -- path validated above
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read action file %s: %w", actionPath, err)
|
||||
}
|
||||
@@ -33,7 +57,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 +71,5 @@ func IsCompositeAction(actionPath string) (bool, error) {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return action.Runs.Using == compositeUsing, nil
|
||||
return action.Runs.Using == appconstants.ActionTypeComposite, nil
|
||||
}
|
||||
|
||||
62
internal/dependencies/parser_test.go
Normal file
62
internal/dependencies/parser_test.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package dependencies
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateFilePath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid relative path",
|
||||
path: "testdata/action.yml",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid absolute path",
|
||||
path: "/tmp/action.yml",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "traversal with double dots",
|
||||
path: "../../../etc/passwd",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "traversal in middle of path",
|
||||
path: "foo/../../../etc/passwd",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "clean path with dot slash",
|
||||
path: "./foo/bar",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid nested path",
|
||||
path: "internal/testdata/fixtures/action.yml",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "path with trailing slash",
|
||||
path: "testdata/action.yml/",
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := validateFilePath(tt.path)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("validateFilePath() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
749
internal/dependencies/updater_test.go
Normal file
749
internal/dependencies/updater_test.go
Normal file
@@ -0,0 +1,749 @@
|
||||
package dependencies
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/cache"
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
// newTestAnalyzer creates an Analyzer with cache for testing.
|
||||
// Returns the analyzer and a cleanup function.
|
||||
// Pattern used 7+ times in updater_test.go.
|
||||
func newTestAnalyzer(t *testing.T) (*Analyzer, func()) {
|
||||
t.Helper()
|
||||
|
||||
cacheInstance, err := cache.NewCache(cache.DefaultConfig())
|
||||
testutil.AssertNoError(t, err)
|
||||
|
||||
analyzer := &Analyzer{
|
||||
Cache: NewCacheAdapter(cacheInstance),
|
||||
}
|
||||
|
||||
return analyzer, testutil.CleanupCache(t, cacheInstance)
|
||||
}
|
||||
|
||||
// validatePinnedUpdateSuccess validates that the update succeeded and backup was cleaned up.
|
||||
func validatePinnedUpdateSuccess(t *testing.T, actionPath string, validateBackup bool, analyzer *Analyzer) {
|
||||
t.Helper()
|
||||
|
||||
if validateBackup {
|
||||
testutil.AssertBackupNotExists(t, actionPath)
|
||||
}
|
||||
|
||||
// Verify file is still valid YAML
|
||||
err := analyzer.validateActionFile(actionPath)
|
||||
testutil.AssertNoError(t, err)
|
||||
}
|
||||
|
||||
// validatePinnedUpdateRollback validates that the rollback succeeded and file is unchanged.
|
||||
func validatePinnedUpdateRollback(t *testing.T, actionPath, originalContent string) {
|
||||
t.Helper()
|
||||
|
||||
testutil.ValidateRollback(t, actionPath, originalContent)
|
||||
|
||||
// Backup should be removed after rollback
|
||||
testutil.AssertBackupNotExists(t, actionPath)
|
||||
}
|
||||
|
||||
// TestApplyPinnedUpdates tests the ApplyPinnedUpdates method.
|
||||
// Note: These tests identify a bug where the `- ` list marker is not preserved
|
||||
// when updating YAML. The current implementation replaces entire lines with
|
||||
// just "uses: " prefix, losing the list marker. Tests are written to document
|
||||
// current behavior while validating the logic works.
|
||||
func TestApplyPinnedUpdates(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
actionContent string
|
||||
updates []PinnedUpdate
|
||||
wantErr bool
|
||||
validateBackup bool
|
||||
checkRollback bool
|
||||
}{
|
||||
createSingleUpdateTestCase(singleUpdateParams{
|
||||
name: "list format updates now work correctly (bug fixed)",
|
||||
fixturePath: "dependencies/simple-list-step.yml",
|
||||
oldUses: testutil.TestCheckoutV4OldUses,
|
||||
newUses: testutil.TestCheckoutPinnedV417,
|
||||
commitSHA: testutil.TestActionCheckoutSHA,
|
||||
version: testutil.TestVersionV417,
|
||||
updateType: "patch",
|
||||
wantErr: false,
|
||||
validateBackup: true,
|
||||
checkRollback: false,
|
||||
}),
|
||||
createSingleUpdateTestCase(singleUpdateParams{
|
||||
name: "updates work when uses is not in list format",
|
||||
fixturePath: "dependencies/named-step.yml",
|
||||
oldUses: testutil.TestCheckoutV4OldUses,
|
||||
newUses: testutil.TestCheckoutPinnedV417,
|
||||
commitSHA: testutil.TestActionCheckoutSHA,
|
||||
version: testutil.TestVersionV417,
|
||||
updateType: "patch",
|
||||
wantErr: false,
|
||||
validateBackup: true,
|
||||
checkRollback: false,
|
||||
}),
|
||||
{
|
||||
name: "multiple updates in non-list format",
|
||||
actionContent: testutil.MustReadFixture("dependencies/multiple-steps.yml"),
|
||||
updates: []PinnedUpdate{
|
||||
{
|
||||
FilePath: "", // Will be set by test
|
||||
OldUses: testutil.TestCheckoutV4OldUses,
|
||||
NewUses: testutil.TestCheckoutPinnedV417,
|
||||
CommitSHA: testutil.TestActionCheckoutSHA,
|
||||
Version: testutil.TestVersionV417,
|
||||
UpdateType: "patch",
|
||||
LineNumber: 0,
|
||||
},
|
||||
{
|
||||
FilePath: "", // Will be set by test
|
||||
OldUses: testutil.TestActionSetupNodeV3,
|
||||
NewUses: "actions/setup-node@1a4e6d7c9f8e5b2a3c4d5e6f7a8b9c0d1e2f3a4b # v4.0.0",
|
||||
CommitSHA: "1a4e6d7c9f8e5b2a3c4d5e6f7a8b9c0d1e2f3a4b",
|
||||
Version: "v4.0.0",
|
||||
UpdateType: "major",
|
||||
LineNumber: 0,
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
validateBackup: true,
|
||||
checkRollback: false,
|
||||
},
|
||||
createSingleUpdateTestCase(singleUpdateParams{
|
||||
name: "preserves indentation in non-list format",
|
||||
fixturePath: "dependencies/step-with-parameters.yml",
|
||||
oldUses: testutil.TestCheckoutV4OldUses,
|
||||
newUses: testutil.TestCheckoutPinnedV417,
|
||||
commitSHA: testutil.TestActionCheckoutSHA,
|
||||
version: testutil.TestVersionV417,
|
||||
updateType: "patch",
|
||||
wantErr: false,
|
||||
validateBackup: true,
|
||||
checkRollback: false,
|
||||
}),
|
||||
createSingleUpdateTestCase(singleUpdateParams{
|
||||
name: "handles already pinned dependencies",
|
||||
fixturePath: "dependencies/already-pinned.yml",
|
||||
oldUses: testutil.TestCheckoutPinnedV417,
|
||||
newUses: testutil.TestCheckoutPinnedV417,
|
||||
commitSHA: testutil.TestActionCheckoutSHA,
|
||||
version: testutil.TestVersionV417,
|
||||
updateType: "none",
|
||||
wantErr: false,
|
||||
validateBackup: true,
|
||||
checkRollback: false,
|
||||
}),
|
||||
{
|
||||
name: "invalid YAML triggers rollback",
|
||||
actionContent: testutil.MustReadFixture("dependencies/simple-test-step.yml"),
|
||||
updates: []PinnedUpdate{
|
||||
{
|
||||
FilePath: "", // Will be set by test
|
||||
OldUses: "name: Test Action",
|
||||
NewUses: "invalid:::yaml",
|
||||
CommitSHA: "",
|
||||
Version: "",
|
||||
UpdateType: "none",
|
||||
LineNumber: 0,
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
validateBackup: false,
|
||||
checkRollback: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Create temporary directory and action file
|
||||
dir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
actionPath := testutil.WriteActionFile(t, dir, tt.actionContent)
|
||||
|
||||
// Store original content for rollback check
|
||||
originalContent, _ := os.ReadFile(actionPath) // #nosec G304 -- test file path
|
||||
|
||||
// Set file path in updates
|
||||
for i := range tt.updates {
|
||||
tt.updates[i].FilePath = actionPath
|
||||
}
|
||||
|
||||
// Create analyzer
|
||||
analyzer, cleanupAnalyzer := newTestAnalyzer(t)
|
||||
defer cleanupAnalyzer()
|
||||
|
||||
// Apply updates
|
||||
err := analyzer.ApplyPinnedUpdates(tt.updates)
|
||||
|
||||
// Check error expectation
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ApplyPinnedUpdates() error = %v, wantErr %v", err, tt.wantErr)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.wantErr {
|
||||
validatePinnedUpdateSuccess(t, actionPath, tt.validateBackup, analyzer)
|
||||
}
|
||||
|
||||
if tt.checkRollback {
|
||||
validatePinnedUpdateRollback(t, actionPath, string(originalContent))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// validateUpdateFileSuccess validates that the file was updated correctly and backup was cleaned up.
|
||||
func validateUpdateFileSuccess(t *testing.T, actionPath, expectedYAML string, checkBackup bool) {
|
||||
t.Helper()
|
||||
|
||||
testutil.AssertFileContentEquals(t, actionPath, expectedYAML)
|
||||
|
||||
if checkBackup {
|
||||
testutil.AssertBackupNotExists(t, actionPath)
|
||||
}
|
||||
}
|
||||
|
||||
// validateUpdateFileRollback validates that the rollback succeeded and file is unchanged.
|
||||
func validateUpdateFileRollback(t *testing.T, actionPath, initialYAML string) {
|
||||
t.Helper()
|
||||
|
||||
testutil.AssertFileContentEquals(t, actionPath, initialYAML)
|
||||
}
|
||||
|
||||
// TestUpdateActionFile tests the updateActionFile method directly.
|
||||
func TestUpdateActionFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
initialYAML string
|
||||
updates []PinnedUpdate
|
||||
expectedYAML string
|
||||
expectError bool
|
||||
checkBackup bool
|
||||
rollbackCheck bool
|
||||
}{
|
||||
{
|
||||
name: "finds and replaces uses statement in non-list format",
|
||||
initialYAML: testutil.MustReadFixture("dependencies/test-checkout-v4.yml"),
|
||||
updates: []PinnedUpdate{
|
||||
{
|
||||
OldUses: testutil.TestCheckoutV4OldUses,
|
||||
NewUses: testutil.TestCheckoutPinnedV411,
|
||||
},
|
||||
},
|
||||
expectedYAML: testutil.MustReadFixture("dependencies/test-checkout-pinned.yml"),
|
||||
expectError: false,
|
||||
checkBackup: true,
|
||||
},
|
||||
{
|
||||
name: "handles different version formats",
|
||||
initialYAML: testutil.MustReadFixture("dependencies/test-checkout-v4-1-0.yml"),
|
||||
updates: []PinnedUpdate{
|
||||
{
|
||||
OldUses: "actions/checkout@v4.1.0",
|
||||
NewUses: testutil.TestCheckoutPinnedV411,
|
||||
},
|
||||
},
|
||||
expectedYAML: testutil.MustReadFixture("dependencies/test-checkout-pinned.yml"),
|
||||
expectError: false,
|
||||
checkBackup: true,
|
||||
},
|
||||
{
|
||||
name: "handles multiple references to same action",
|
||||
initialYAML: testutil.MustReadFixture("dependencies/test-multiple-checkout.yml"),
|
||||
updates: []PinnedUpdate{
|
||||
{
|
||||
OldUses: testutil.TestCheckoutV4OldUses,
|
||||
NewUses: testutil.TestCheckoutPinnedV411,
|
||||
},
|
||||
},
|
||||
expectedYAML: testutil.MustReadFixture("dependencies/test-multiple-checkout-pinned.yml"),
|
||||
expectError: false,
|
||||
checkBackup: true,
|
||||
},
|
||||
{
|
||||
name: "preserves whitespace and comments",
|
||||
initialYAML: testutil.MustReadFixture("dependencies/test-checkout-with-comment.yml"),
|
||||
updates: []PinnedUpdate{
|
||||
{
|
||||
OldUses: testutil.TestCheckoutV4OldUses,
|
||||
NewUses: testutil.TestCheckoutPinnedV411,
|
||||
},
|
||||
},
|
||||
expectedYAML: testutil.MustReadFixture("dependencies/test-checkout-with-comment-pinned.yml"),
|
||||
expectError: false,
|
||||
checkBackup: true,
|
||||
},
|
||||
{
|
||||
name: "invalid YAML triggers rollback",
|
||||
initialYAML: testutil.MustReadFixture(testutil.TestFixtureSimpleCheckout),
|
||||
updates: []PinnedUpdate{
|
||||
{
|
||||
OldUses: testutil.TestCheckoutV4OldUses,
|
||||
NewUses: "\"unclosed string that breaks YAML parsing", // Unclosed quote breaks YAML
|
||||
},
|
||||
},
|
||||
expectedYAML: "", // Should rollback to original
|
||||
expectError: true,
|
||||
checkBackup: false,
|
||||
rollbackCheck: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Create temp directory and file
|
||||
dir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
actionPath := testutil.WriteActionFile(t, dir, tt.initialYAML)
|
||||
|
||||
// Create analyzer
|
||||
analyzer, cleanupAnalyzer := newTestAnalyzer(t)
|
||||
defer cleanupAnalyzer()
|
||||
|
||||
// Apply update
|
||||
err := analyzer.updateActionFile(actionPath, tt.updates)
|
||||
|
||||
// Check error expectation
|
||||
if (err != nil) != tt.expectError {
|
||||
t.Errorf("updateActionFile() error = %v, expectError %v", err, tt.expectError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.expectError {
|
||||
validateUpdateFileSuccess(t, actionPath, tt.expectedYAML, tt.checkBackup)
|
||||
}
|
||||
|
||||
if tt.rollbackCheck {
|
||||
validateUpdateFileRollback(t, actionPath, tt.initialYAML)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateActionFile tests the validateActionFile method.
|
||||
func TestValidateActionFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
yamlContent string
|
||||
expectValid bool
|
||||
}{
|
||||
{
|
||||
name: "valid composite action",
|
||||
yamlContent: testutil.MustReadFixture("dependencies/simple-list-step.yml"),
|
||||
expectValid: true,
|
||||
},
|
||||
{
|
||||
name: "valid JavaScript action",
|
||||
yamlContent: testutil.MustReadFixture("dependencies/valid-javascript-action.yml"),
|
||||
expectValid: true,
|
||||
},
|
||||
{
|
||||
name: "valid Docker action",
|
||||
yamlContent: testutil.MustReadFixture("dependencies/valid-docker-action.yml"),
|
||||
expectValid: true,
|
||||
},
|
||||
{
|
||||
name: "missing name field",
|
||||
yamlContent: testutil.MustReadFixture("dependencies/missing-name.yml"),
|
||||
expectValid: false,
|
||||
},
|
||||
{
|
||||
name: "missing description field",
|
||||
yamlContent: testutil.MustReadFixture("dependencies/missing-description.yml"),
|
||||
expectValid: false,
|
||||
},
|
||||
{
|
||||
name: "missing runs field",
|
||||
yamlContent: testutil.MustReadFixture("dependencies/missing-runs.yml"),
|
||||
expectValid: false,
|
||||
},
|
||||
{
|
||||
name: "invalid YAML syntax",
|
||||
yamlContent: testutil.MustReadFixture("dependencies/invalid-syntax.yml"),
|
||||
expectValid: false,
|
||||
},
|
||||
{
|
||||
name: "invalid using field",
|
||||
yamlContent: testutil.MustReadFixture("dependencies/invalid-using.yml"),
|
||||
expectValid: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Create temp file
|
||||
dir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
actionPath := testutil.WriteActionFile(t, dir, tt.yamlContent)
|
||||
|
||||
// Create analyzer
|
||||
analyzer, cleanupAnalyzer := newTestAnalyzer(t)
|
||||
defer cleanupAnalyzer()
|
||||
|
||||
// Validate
|
||||
err := analyzer.validateActionFile(actionPath)
|
||||
|
||||
if tt.expectValid && err != nil {
|
||||
t.Errorf("validateActionFile() expected valid but got error: %v", err)
|
||||
}
|
||||
|
||||
if !tt.expectValid && err == nil {
|
||||
t.Errorf("validateActionFile() expected invalid but got nil error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetLatestTagEdgeCases tests edge cases for getLatestTag.
|
||||
func TestGetLatestTagEdgeCases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mockSetup func() *Analyzer
|
||||
owner string
|
||||
repo string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "no tags available",
|
||||
mockSetup: func() *Analyzer {
|
||||
mockClient := testutil.MockGitHubClient(map[string]string{
|
||||
"GET https://api.github.com/repos/test/repo/tags": "[]",
|
||||
})
|
||||
cacheInstance, _ := cache.NewCache(cache.DefaultConfig())
|
||||
|
||||
return &Analyzer{
|
||||
GitHubClient: mockClient,
|
||||
Cache: NewCacheAdapter(cacheInstance),
|
||||
}
|
||||
},
|
||||
owner: "test",
|
||||
repo: "repo",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "GitHub client nil",
|
||||
mockSetup: func() *Analyzer {
|
||||
return &Analyzer{
|
||||
GitHubClient: nil,
|
||||
Cache: nil,
|
||||
}
|
||||
},
|
||||
owner: "test",
|
||||
repo: "repo",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "malformed tag response",
|
||||
mockSetup: func() *Analyzer {
|
||||
mockClient := testutil.MockGitHubClient(map[string]string{
|
||||
"GET https://api.github.com/repos/test/repo/tags": "invalid json",
|
||||
})
|
||||
cacheInstance, _ := cache.NewCache(cache.DefaultConfig())
|
||||
|
||||
return &Analyzer{
|
||||
GitHubClient: mockClient,
|
||||
Cache: NewCacheAdapter(cacheInstance),
|
||||
}
|
||||
},
|
||||
owner: "test",
|
||||
repo: "repo",
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
analyzer := tt.mockSetup()
|
||||
if analyzer.Cache != nil {
|
||||
// Clean up cache if it exists
|
||||
defer func() {
|
||||
if ca, ok := analyzer.Cache.(*CacheAdapter); ok {
|
||||
_ = ca.cache.Close()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
_, _, err := analyzer.getLatestVersion(tt.owner, tt.repo)
|
||||
|
||||
if (err != nil) != tt.expectError {
|
||||
t.Errorf("getLatestVersion() error = %v, expectError %v", err, tt.expectError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// assertCacheVersionNotFound validates that no version was found in the cache.
|
||||
func assertCacheVersionNotFound(t *testing.T, version, sha string, found bool) {
|
||||
t.Helper()
|
||||
|
||||
if found {
|
||||
t.Error("getCachedVersion() should return false")
|
||||
}
|
||||
if version != "" {
|
||||
t.Errorf("version = %q, want empty", version)
|
||||
}
|
||||
if sha != "" {
|
||||
t.Errorf("sha = %q, want empty", sha)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCacheVersionEdgeCases tests edge cases for cacheVersion and getCachedVersion.
|
||||
func TestCacheVersionEdgeCases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Parametrized tests for getCachedVersion edge cases
|
||||
notFoundCases := []struct {
|
||||
name string
|
||||
setupFn func(*testing.T) (*Analyzer, func())
|
||||
cacheKey string
|
||||
}{
|
||||
{
|
||||
name: "nil cache",
|
||||
setupFn: func(_ *testing.T) (*Analyzer, func()) {
|
||||
return &Analyzer{Cache: nil}, func() {
|
||||
// No cleanup needed for nil cache
|
||||
}
|
||||
},
|
||||
cacheKey: testutil.CacheTestKey,
|
||||
},
|
||||
{
|
||||
name: "invalid data type",
|
||||
setupFn: func(t *testing.T) (*Analyzer, func()) {
|
||||
t.Helper()
|
||||
c, err := cache.NewCache(cache.DefaultConfig())
|
||||
testutil.AssertNoError(t, err)
|
||||
_ = c.Set(testutil.CacheTestKey, "invalid-string")
|
||||
|
||||
return &Analyzer{Cache: NewCacheAdapter(c)}, testutil.CleanupCache(t, c)
|
||||
},
|
||||
cacheKey: testutil.CacheTestKey,
|
||||
},
|
||||
{
|
||||
name: "empty cache entry",
|
||||
setupFn: func(t *testing.T) (*Analyzer, func()) {
|
||||
t.Helper()
|
||||
c, err := cache.NewCache(cache.DefaultConfig())
|
||||
testutil.AssertNoError(t, err)
|
||||
|
||||
return &Analyzer{Cache: NewCacheAdapter(c)}, testutil.CleanupCache(t, c)
|
||||
},
|
||||
cacheKey: "nonexistent-key",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range notFoundCases {
|
||||
t.Run("getCachedVersion with "+tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
analyzer, cleanup := tc.setupFn(t)
|
||||
defer cleanup()
|
||||
version, sha, found := analyzer.getCachedVersion(tc.cacheKey)
|
||||
assertCacheVersionNotFound(t, version, sha, found)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("cacheVersion with nil cache", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
analyzer := &Analyzer{Cache: nil}
|
||||
// Should not panic
|
||||
analyzer.cacheVersion(testutil.CacheTestKey, "v1.0.0", "abc123")
|
||||
})
|
||||
|
||||
t.Run("cacheVersion stores and retrieves correctly", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cacheInstance, err := cache.NewCache(cache.DefaultConfig())
|
||||
testutil.AssertNoError(t, err)
|
||||
defer testutil.CleanupCache(t, cacheInstance)()
|
||||
|
||||
analyzer := &Analyzer{Cache: NewCacheAdapter(cacheInstance)}
|
||||
|
||||
// Cache a version
|
||||
analyzer.cacheVersion(testutil.CacheTestKey, "v1.2.3", "def456")
|
||||
|
||||
// Retrieve it
|
||||
version, sha, found := analyzer.getCachedVersion(testutil.CacheTestKey)
|
||||
|
||||
if !found {
|
||||
t.Error("getCachedVersion() should return true after cacheVersion()")
|
||||
}
|
||||
if version != "v1.2.3" {
|
||||
t.Errorf("getCachedVersion() version = %s, want v1.2.3", version)
|
||||
}
|
||||
if sha != "def456" {
|
||||
t.Errorf("getCachedVersion() sha = %s, want def456", sha)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestUpdateActionFileBackupAndRollback tests backup creation and rollback functionality.
|
||||
func TestUpdateActionFileBackupAndRollback(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("backup created before modification", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
originalContent := testutil.MustReadFixture(testutil.TestFixtureSimpleCheckout)
|
||||
actionPath := testutil.WriteActionFile(t, dir, originalContent)
|
||||
|
||||
analyzer, cleanupAnalyzer := newTestAnalyzer(t)
|
||||
defer cleanupAnalyzer()
|
||||
|
||||
updates := []PinnedUpdate{
|
||||
{
|
||||
OldUses: testutil.TestCheckoutV4OldUses,
|
||||
NewUses: testutil.TestCheckoutPinnedV411,
|
||||
},
|
||||
}
|
||||
|
||||
err := analyzer.updateActionFile(actionPath, updates)
|
||||
testutil.AssertNoError(t, err)
|
||||
|
||||
// Backup should be removed after successful update
|
||||
testutil.AssertBackupNotExists(t, actionPath)
|
||||
})
|
||||
|
||||
t.Run("rollback on validation failure", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
originalContent := testutil.MustReadFixture(testutil.TestFixtureSimpleCheckout)
|
||||
actionPath := testutil.WriteActionFile(t, dir, originalContent)
|
||||
|
||||
analyzer, cleanupAnalyzer := newTestAnalyzer(t)
|
||||
defer cleanupAnalyzer()
|
||||
|
||||
// Create an update that breaks YAML
|
||||
updates := []PinnedUpdate{
|
||||
{
|
||||
OldUses: "name: Test",
|
||||
NewUses: "invalid::yaml::syntax:",
|
||||
},
|
||||
}
|
||||
|
||||
err := analyzer.updateActionFile(actionPath, updates)
|
||||
if err == nil {
|
||||
t.Error("updateActionFile() should return error for invalid YAML")
|
||||
}
|
||||
|
||||
// File should be rolled back to original
|
||||
testutil.AssertFileContentEquals(t, actionPath, originalContent)
|
||||
|
||||
// Backup should be removed after rollback
|
||||
testutil.AssertBackupNotExists(t, actionPath)
|
||||
})
|
||||
|
||||
t.Run("file permission errors", func(t *testing.T) {
|
||||
// Skip on Windows as permission handling is different
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Skipping permission test on Windows")
|
||||
}
|
||||
|
||||
dir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
actionPath := filepath.Join(dir, appconstants.ActionFileNameYML)
|
||||
testutil.WriteTestFile(t, actionPath, "name: Test\ndescription: Test\nruns:\n using: composite\n steps: []")
|
||||
|
||||
// Make file read-only
|
||||
err := os.Chmod(actionPath, 0444) // #nosec G302 -- intentionally read-only for test
|
||||
testutil.AssertNoError(t, err)
|
||||
|
||||
analyzer, cleanupAnalyzer := newTestAnalyzer(t)
|
||||
defer cleanupAnalyzer()
|
||||
|
||||
updates := []PinnedUpdate{
|
||||
{
|
||||
OldUses: "anything",
|
||||
NewUses: "something",
|
||||
},
|
||||
}
|
||||
|
||||
err = analyzer.updateActionFile(actionPath, updates)
|
||||
if err == nil {
|
||||
t.Error("updateActionFile() should return error for read-only file")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestApplyPinnedUpdatesGroupedByFile tests updates to multiple files.
|
||||
func TestApplyPinnedUpdatesGroupedByFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
// Create two action files in non-list format (to avoid YAML bug)
|
||||
action1Path := filepath.Join(dir, "action1.yml")
|
||||
action2Path := filepath.Join(dir, "action2.yml")
|
||||
|
||||
action1Content := testutil.MustReadFixture("dependencies/action1-checkout.yml")
|
||||
action2Content := testutil.MustReadFixture("dependencies/action2-setup-node.yml")
|
||||
|
||||
testutil.WriteTestFile(t, action1Path, action1Content)
|
||||
testutil.WriteTestFile(t, action2Path, action2Content)
|
||||
|
||||
analyzer, cleanupAnalyzer := newTestAnalyzer(t)
|
||||
defer cleanupAnalyzer()
|
||||
|
||||
// Create updates for both files
|
||||
updates := []PinnedUpdate{
|
||||
{
|
||||
FilePath: action1Path,
|
||||
OldUses: testutil.TestCheckoutV4OldUses,
|
||||
NewUses: testutil.TestCheckoutPinnedV411,
|
||||
},
|
||||
{
|
||||
FilePath: action2Path,
|
||||
OldUses: testutil.TestActionSetupNodeV3,
|
||||
NewUses: "actions/setup-node@def456 # v4.0.0",
|
||||
},
|
||||
}
|
||||
|
||||
err := analyzer.ApplyPinnedUpdates(updates)
|
||||
testutil.AssertNoError(t, err)
|
||||
|
||||
// Verify both files were updated
|
||||
content1 := testutil.SafeReadFile(t, action1Path, dir)
|
||||
if !strings.Contains(string(content1), testutil.TestCheckoutPinnedV411) {
|
||||
t.Errorf("action1.yml was not updated correctly, got:\n%s", string(content1))
|
||||
}
|
||||
|
||||
content2 := testutil.SafeReadFile(t, action2Path, dir)
|
||||
if !strings.Contains(string(content2), "actions/setup-node@def456 # v4.0.0") {
|
||||
t.Errorf("action2.yml was not updated correctly, got:\n%s", string(content2))
|
||||
}
|
||||
}
|
||||
48
internal/dependencies/updater_test_helper.go
Normal file
48
internal/dependencies/updater_test_helper.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package dependencies
|
||||
|
||||
import "github.com/ivuorinen/gh-action-readme/testutil"
|
||||
|
||||
// singleUpdateParams holds parameters for creating a test case with a single update.
|
||||
type singleUpdateParams struct {
|
||||
name string
|
||||
fixturePath string
|
||||
oldUses, newUses, commitSHA, version, updateType string
|
||||
wantErr, validateBackup, checkRollback bool
|
||||
}
|
||||
|
||||
// createSingleUpdateTestCase creates a test case with a single PinnedUpdate.
|
||||
// This helper reduces duplication for test cases that update a single dependency.
|
||||
func createSingleUpdateTestCase(params singleUpdateParams) struct {
|
||||
name string
|
||||
actionContent string
|
||||
updates []PinnedUpdate
|
||||
wantErr bool
|
||||
validateBackup bool
|
||||
checkRollback bool
|
||||
} {
|
||||
return struct {
|
||||
name string
|
||||
actionContent string
|
||||
updates []PinnedUpdate
|
||||
wantErr bool
|
||||
validateBackup bool
|
||||
checkRollback bool
|
||||
}{
|
||||
name: params.name,
|
||||
actionContent: testutil.MustReadFixture(params.fixturePath),
|
||||
updates: []PinnedUpdate{
|
||||
{
|
||||
FilePath: "", // Will be set by test
|
||||
OldUses: params.oldUses,
|
||||
NewUses: params.newUses,
|
||||
CommitSHA: params.commitSHA,
|
||||
Version: params.version,
|
||||
UpdateType: params.updateType,
|
||||
LineNumber: 0,
|
||||
},
|
||||
},
|
||||
wantErr: params.wantErr,
|
||||
validateBackup: params.validateBackup,
|
||||
checkRollback: params.checkRollback,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
361
internal/errorhandler_integration_test.go
Normal file
361
internal/errorhandler_integration_test.go
Normal file
@@ -0,0 +1,361 @@
|
||||
package internal_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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/testutil"
|
||||
)
|
||||
|
||||
const (
|
||||
envGoTestSubprocess = "GO_TEST_SUBPROCESS"
|
||||
envTestType = "TEST_TYPE"
|
||||
)
|
||||
|
||||
// verifyExitCode checks that the command exited with the expected exit code.
|
||||
func verifyExitCode(t *testing.T, err error, expectedExit int) {
|
||||
t.Helper()
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
if exitErr.ExitCode() != expectedExit {
|
||||
t.Errorf("expected exit code %d, got %d", expectedExit, exitErr.ExitCode())
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf(testutil.TestErrUnexpected, err)
|
||||
}
|
||||
if expectedExit != 0 {
|
||||
t.Errorf("expected exit code %d, but process exited successfully", expectedExit)
|
||||
}
|
||||
}
|
||||
|
||||
// execSubprocessTest spawns a subprocess and returns its stderr output and error.
|
||||
func execSubprocessTest(t *testing.T, testType string) (string, error) {
|
||||
t.Helper()
|
||||
//nolint:gosec // Controlled test arguments
|
||||
cmd := exec.Command(os.Args[0], "-test.run=^TestErrorHandlerIntegration$")
|
||||
cmd.Env = append(os.Environ(),
|
||||
envGoTestSubprocess+"=1",
|
||||
envTestType+"="+testType,
|
||||
)
|
||||
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get stderr pipe: %v", err)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("failed to start subprocess: %v", err)
|
||||
}
|
||||
|
||||
stderrOutput := make([]byte, 4096)
|
||||
n, _ := stderr.Read(stderrOutput)
|
||||
stderrStr := string(stderrOutput[:n])
|
||||
|
||||
return stderrStr, cmd.Wait()
|
||||
}
|
||||
|
||||
// runSubprocessErrorTest executes a subprocess test and verifies exit code and stderr.
|
||||
// Consolidates 15 duplicated test loops.
|
||||
func runSubprocessErrorTest(t *testing.T, testType string, expectedExit int, expectedStderr string) {
|
||||
t.Helper()
|
||||
|
||||
stderrStr, err := execSubprocessTest(t, testType)
|
||||
verifyExitCode(t, err, expectedExit)
|
||||
|
||||
if !strings.Contains(strings.ToLower(stderrStr), strings.ToLower(expectedStderr)) {
|
||||
t.Errorf("stderr missing expected text %q, got: %s", expectedStderr, stderrStr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestErrorHandlerIntegration tests error handler methods that call os.Exit()
|
||||
// using subprocess pattern.
|
||||
func TestErrorHandlerIntegration(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Check if this is the subprocess
|
||||
if os.Getenv(envGoTestSubprocess) == "1" {
|
||||
runSubprocessTest()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
testType string
|
||||
expectedExit int
|
||||
expectedStderr string
|
||||
}{
|
||||
{
|
||||
name: "HandleError with file not found",
|
||||
testType: "handle_error_file_not_found",
|
||||
expectedExit: appconstants.ExitCodeError,
|
||||
expectedStderr: testutil.TestErrFileNotFound,
|
||||
},
|
||||
{
|
||||
name: "HandleError with validation error",
|
||||
testType: "handle_error_validation",
|
||||
expectedExit: appconstants.ExitCodeError,
|
||||
expectedStderr: "validation failed",
|
||||
},
|
||||
{
|
||||
name: "HandleError with context",
|
||||
testType: "handle_error_with_context",
|
||||
expectedExit: appconstants.ExitCodeError,
|
||||
expectedStderr: "config file",
|
||||
},
|
||||
{
|
||||
name: "HandleError with suggestions",
|
||||
testType: "handle_error_with_suggestions",
|
||||
expectedExit: appconstants.ExitCodeError,
|
||||
expectedStderr: testutil.TestErrFileError,
|
||||
},
|
||||
{
|
||||
name: "HandleFatalError with permission denied",
|
||||
testType: "handle_fatal_error_permission",
|
||||
expectedExit: appconstants.ExitCodeError,
|
||||
expectedStderr: testutil.TestErrPermissionDenied,
|
||||
},
|
||||
{
|
||||
name: "HandleFatalError with config error",
|
||||
testType: "handle_fatal_error_config",
|
||||
expectedExit: appconstants.ExitCodeError,
|
||||
expectedStderr: "configuration error",
|
||||
},
|
||||
{
|
||||
name: "HandleSimpleError with generic error",
|
||||
testType: "handle_simple_error_generic",
|
||||
expectedExit: appconstants.ExitCodeError,
|
||||
expectedStderr: "operation failed",
|
||||
},
|
||||
{
|
||||
name: "HandleSimpleError with file not found pattern",
|
||||
testType: "handle_simple_error_not_found",
|
||||
expectedExit: appconstants.ExitCodeError,
|
||||
expectedStderr: testutil.TestErrFileError,
|
||||
},
|
||||
{
|
||||
name: "HandleSimpleError with permission pattern",
|
||||
testType: "handle_simple_error_permission",
|
||||
expectedExit: appconstants.ExitCodeError,
|
||||
expectedStderr: "access error",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
runSubprocessErrorTest(t, tt.testType, tt.expectedExit, tt.expectedStderr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// runSubprocessTest executes the actual error handler call based on TEST_TYPE.
|
||||
func runSubprocessTest() {
|
||||
testType := os.Getenv(envTestType)
|
||||
output := internal.NewColoredOutput(false) // quiet=false
|
||||
handler := internal.NewErrorHandler(output)
|
||||
|
||||
switch testType {
|
||||
case "handle_error_file_not_found":
|
||||
err := apperrors.New(appconstants.ErrCodeFileNotFound, testutil.TestErrFileNotFound)
|
||||
handler.HandleError(err)
|
||||
|
||||
case "handle_error_validation":
|
||||
err := apperrors.New(appconstants.ErrCodeValidation, "validation failed")
|
||||
handler.HandleError(err)
|
||||
|
||||
case "handle_error_with_context":
|
||||
err := apperrors.New(appconstants.ErrCodeConfiguration, "config file missing")
|
||||
err = err.WithDetails(map[string]string{
|
||||
"path": "/invalid/path/config.yaml",
|
||||
"type": "application",
|
||||
})
|
||||
handler.HandleError(err)
|
||||
|
||||
case "handle_error_with_suggestions":
|
||||
err := apperrors.New(appconstants.ErrCodeFileNotFound, "file error occurred")
|
||||
err = err.WithSuggestions("Check that the file exists", "Verify file permissions")
|
||||
handler.HandleError(err)
|
||||
|
||||
case "handle_fatal_error_permission":
|
||||
handler.HandleFatalError(
|
||||
appconstants.ErrCodePermission,
|
||||
"permission denied accessing file",
|
||||
map[string]string{"file": "/etc/passwd"},
|
||||
)
|
||||
|
||||
case "handle_fatal_error_config":
|
||||
handler.HandleFatalError(
|
||||
appconstants.ErrCodeConfiguration,
|
||||
"configuration error in settings",
|
||||
map[string]string{
|
||||
"section": "github",
|
||||
"key": "token",
|
||||
},
|
||||
)
|
||||
|
||||
case "handle_simple_error_generic":
|
||||
handler.HandleSimpleError("operation failed", errors.New("generic error occurred"))
|
||||
|
||||
case "handle_simple_error_not_found":
|
||||
handler.HandleSimpleError(testutil.TestErrFileError, errors.New("no such file or directory"))
|
||||
|
||||
case "handle_simple_error_permission":
|
||||
handler.HandleSimpleError("access error", errors.New(testutil.TestErrPermissionDenied))
|
||||
|
||||
default:
|
||||
os.Exit(99) // Unexpected test type
|
||||
}
|
||||
}
|
||||
|
||||
// TestErrorHandlerAllErrorCodes tests that all error codes produce correct exit codes.
|
||||
func TestErrorHandlerAllErrorCodes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Check if this is the subprocess
|
||||
if os.Getenv(envGoTestSubprocess) == "1" {
|
||||
runErrorCodeTest()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
errorCodes := []struct {
|
||||
code appconstants.ErrorCode
|
||||
description string
|
||||
}{
|
||||
{appconstants.ErrCodeFileNotFound, testutil.TestErrFileNotFound},
|
||||
{appconstants.ErrCodePermission, testutil.TestErrPermissionDenied},
|
||||
{appconstants.ErrCodeInvalidYAML, testutil.TestCaseNameInvalidYAML},
|
||||
{appconstants.ErrCodeInvalidAction, "invalid action"},
|
||||
{appconstants.ErrCodeNoActionFiles, testutil.TestCaseNameNoActionFiles},
|
||||
{appconstants.ErrCodeGitHubAPI, "github api error"},
|
||||
{appconstants.ErrCodeGitHubRateLimit, "rate limit"},
|
||||
{appconstants.ErrCodeGitHubAuth, "auth error"},
|
||||
{appconstants.ErrCodeConfiguration, "configuration error"},
|
||||
{appconstants.ErrCodeValidation, "validation error"},
|
||||
{appconstants.ErrCodeTemplateRender, "template error"},
|
||||
{appconstants.ErrCodeFileWrite, "file write error"},
|
||||
{appconstants.ErrCodeDependencyAnalysis, "dependency error"},
|
||||
{appconstants.ErrCodeCacheAccess, "cache error"},
|
||||
{appconstants.ErrCodeUnknown, testutil.TestCaseNameUnknownError},
|
||||
}
|
||||
|
||||
for _, tc := range errorCodes {
|
||||
t.Run(string(tc.code), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
//nolint:gosec // Controlled test arguments
|
||||
cmd := exec.Command(os.Args[0], "-test.run=^TestErrorHandlerAllErrorCodes$/^"+string(tc.code)+"$")
|
||||
cmd.Env = append(os.Environ(),
|
||||
"GO_TEST_SUBPROCESS=1",
|
||||
"ERROR_CODE="+string(tc.code),
|
||||
"ERROR_DESC="+tc.description,
|
||||
)
|
||||
|
||||
stderr, _ := cmd.StderrPipe()
|
||||
_ = cmd.Start()
|
||||
|
||||
stderrOutput := make([]byte, 4096)
|
||||
n, _ := stderr.Read(stderrOutput)
|
||||
stderrStr := string(stderrOutput[:n])
|
||||
|
||||
err := cmd.Wait()
|
||||
|
||||
// All errors should exit with ExitCodeError (1)
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
if exitErr.ExitCode() != appconstants.ExitCodeError {
|
||||
t.Errorf("expected exit code %d, got %d", appconstants.ExitCodeError, exitErr.ExitCode())
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Fatalf(testutil.TestErrUnexpected, err)
|
||||
} else {
|
||||
t.Error("expected non-zero exit code")
|
||||
}
|
||||
|
||||
// Verify error message appears in output
|
||||
if !strings.Contains(strings.ToLower(stderrStr), strings.ToLower(tc.description)) {
|
||||
t.Errorf("stderr missing expected error description %q, got: %s", tc.description, stderrStr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// runErrorCodeTest handles subprocess execution for error code tests.
|
||||
func runErrorCodeTest() {
|
||||
code := appconstants.ErrorCode(os.Getenv("ERROR_CODE"))
|
||||
desc := os.Getenv("ERROR_DESC")
|
||||
|
||||
output := internal.NewColoredOutput(false)
|
||||
handler := internal.NewErrorHandler(output)
|
||||
|
||||
err := apperrors.New(code, desc)
|
||||
handler.HandleError(err)
|
||||
}
|
||||
|
||||
// TestErrorHandlerWithComplexContext tests error handler with multiple context values and suggestions.
|
||||
func TestErrorHandlerWithComplexContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if os.Getenv(envGoTestSubprocess) == "1" {
|
||||
runComplexContextTest()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
//nolint:gosec // Controlled test arguments
|
||||
cmd := exec.Command(os.Args[0], "-test.run=^TestErrorHandlerWithComplexContext$")
|
||||
cmd.Env = append(os.Environ(), "GO_TEST_SUBPROCESS=1")
|
||||
|
||||
stderr, _ := cmd.StderrPipe()
|
||||
_ = cmd.Start()
|
||||
|
||||
stderrOutput := make([]byte, 8192)
|
||||
n, _ := stderr.Read(stderrOutput)
|
||||
stderrStr := string(stderrOutput[:n])
|
||||
|
||||
_ = cmd.Wait()
|
||||
|
||||
// Verify all context keys are displayed
|
||||
contextKeys := []string{"path", "action", "reason"}
|
||||
for _, key := range contextKeys {
|
||||
if !strings.Contains(stderrStr, key) {
|
||||
t.Errorf("stderr missing context key %q", key)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify suggestions are displayed
|
||||
suggestions := []string{"Check the file path", "Verify YAML syntax", "Consult documentation"}
|
||||
for _, suggestion := range suggestions {
|
||||
if !strings.Contains(stderrStr, suggestion) {
|
||||
t.Errorf("stderr missing suggestion %q", suggestion)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runComplexContextTest handles subprocess execution for complex context test.
|
||||
func runComplexContextTest() {
|
||||
output := internal.NewColoredOutput(false)
|
||||
handler := internal.NewErrorHandler(output)
|
||||
|
||||
err := apperrors.New(appconstants.ErrCodeInvalidYAML, "YAML parsing failed")
|
||||
err = err.WithDetails(map[string]string{
|
||||
"path": "/path/to/action.yml",
|
||||
"action": "parse-workflow",
|
||||
"reason": "invalid syntax at line 42",
|
||||
})
|
||||
err = err.WithSuggestions(
|
||||
"Check the file path is correct",
|
||||
"Verify YAML syntax is valid",
|
||||
"Consult documentation for proper format",
|
||||
)
|
||||
|
||||
handler.HandleError(err)
|
||||
}
|
||||
62
internal/errorhandler_integration_test_helpers.go
Normal file
62
internal/errorhandler_integration_test_helpers.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// spawnTestSubprocess creates and configures a test subprocess.
|
||||
// This helper reduces cognitive complexity in integration tests by centralizing
|
||||
// the subprocess creation logic.
|
||||
//
|
||||
//nolint:unused // Prepared for future use in errorhandler integration tests
|
||||
func spawnTestSubprocess(t *testing.T, testType string) *exec.Cmd {
|
||||
t.Helper()
|
||||
|
||||
//nolint:gosec // G204: Controlled test arguments, not user input
|
||||
cmd := exec.Command(os.Args[0], "-test.run=TestErrorHandlerIntegration")
|
||||
cmd.Env = append(os.Environ(), "GO_TEST_SUBPROCESS=1", "TEST_TYPE="+testType)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// assertSubprocessExit validates subprocess exit code and stderr.
|
||||
// This helper reduces cognitive complexity in integration tests by centralizing
|
||||
// the subprocess validation logic that was repeated across test cases.
|
||||
//
|
||||
//nolint:unused // Prepared for future use in errorhandler integration tests
|
||||
func assertSubprocessExit(t *testing.T, cmd *exec.Cmd, expectedExitCode int, stderrPattern string) {
|
||||
t.Helper()
|
||||
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create stderr pipe: %v", err)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("failed to start subprocess: %v", err)
|
||||
}
|
||||
|
||||
stderrBytes, _ := io.ReadAll(stderr)
|
||||
stderrStr := string(stderrBytes)
|
||||
|
||||
err = cmd.Wait()
|
||||
|
||||
// Validate exit code
|
||||
exitCode := 0
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
exitCode = exitErr.ExitCode()
|
||||
}
|
||||
|
||||
if exitCode != expectedExitCode {
|
||||
t.Errorf("exit code = %d, want %d", exitCode, expectedExitCode)
|
||||
}
|
||||
|
||||
// Validate stderr contains pattern
|
||||
if stderrPattern != "" && !strings.Contains(stderrStr, stderrPattern) {
|
||||
t.Errorf("stderr does not contain %q, got: %s", stderrPattern, stderrStr)
|
||||
}
|
||||
}
|
||||
321
internal/errorhandler_test.go
Normal file
321
internal/errorhandler_test.go
Normal file
@@ -0,0 +1,321 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/apperrors"
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
// newTestErrorHandler creates an ErrorHandler for testing with quiet output.
|
||||
// Reduces duplication across error handler tests.
|
||||
func newTestErrorHandler() *ErrorHandler {
|
||||
return NewErrorHandler(&ColoredOutput{NoColor: true, Quiet: true})
|
||||
}
|
||||
|
||||
// TestNewErrorHandler tests error handler creation.
|
||||
func TestNewErrorHandler(t *testing.T) {
|
||||
output := &ColoredOutput{NoColor: true, Quiet: true}
|
||||
handler := NewErrorHandler(output)
|
||||
|
||||
if handler == nil {
|
||||
t.Fatal("NewErrorHandler() returned nil")
|
||||
}
|
||||
|
||||
if handler.output != output {
|
||||
t.Error("NewErrorHandler() did not set output correctly")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetermineErrorCode tests error code determination.
|
||||
//
|
||||
|
||||
func TestDetermineErrorCode(t *testing.T) {
|
||||
handler := newTestErrorHandler()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
wantCode appconstants.ErrorCode
|
||||
}{
|
||||
{
|
||||
name: "file not found - typed error",
|
||||
err: apperrors.ErrFileNotFound,
|
||||
wantCode: appconstants.ErrCodeFileNotFound,
|
||||
},
|
||||
{
|
||||
name: "file not found - os.ErrNotExist",
|
||||
err: os.ErrNotExist,
|
||||
wantCode: appconstants.ErrCodeFileNotFound,
|
||||
},
|
||||
{
|
||||
name: "permission denied - typed error",
|
||||
err: apperrors.ErrPermissionDenied,
|
||||
wantCode: appconstants.ErrCodePermission,
|
||||
},
|
||||
{
|
||||
name: "permission denied - os.ErrPermission",
|
||||
err: os.ErrPermission,
|
||||
wantCode: appconstants.ErrCodePermission,
|
||||
},
|
||||
{
|
||||
name: "invalid YAML",
|
||||
err: apperrors.ErrInvalidYAML,
|
||||
wantCode: appconstants.ErrCodeInvalidYAML,
|
||||
},
|
||||
{
|
||||
name: "GitHub API error",
|
||||
err: apperrors.ErrGitHubAPI,
|
||||
wantCode: appconstants.ErrCodeGitHubAPI,
|
||||
},
|
||||
{
|
||||
name: "configuration error",
|
||||
err: apperrors.ErrConfiguration,
|
||||
wantCode: appconstants.ErrCodeConfiguration,
|
||||
},
|
||||
{
|
||||
name: testutil.TestCaseNameUnknownError,
|
||||
err: errors.New("some random error"),
|
||||
wantCode: appconstants.ErrCodeUnknown,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := handler.determineErrorCode(tt.err)
|
||||
if got != tt.wantCode {
|
||||
t.Errorf("determineErrorCode() = %v, want %v", got, tt.wantCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckTypedError tests typed error checking.
|
||||
//
|
||||
|
||||
func TestCheckTypedError(t *testing.T) {
|
||||
handler := newTestErrorHandler()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
wantCode appconstants.ErrorCode
|
||||
}{
|
||||
{
|
||||
name: "ErrFileNotFound",
|
||||
err: apperrors.ErrFileNotFound,
|
||||
wantCode: appconstants.ErrCodeFileNotFound,
|
||||
},
|
||||
{
|
||||
name: "os.ErrNotExist",
|
||||
err: os.ErrNotExist,
|
||||
wantCode: appconstants.ErrCodeFileNotFound,
|
||||
},
|
||||
{
|
||||
name: "ErrPermissionDenied",
|
||||
err: apperrors.ErrPermissionDenied,
|
||||
wantCode: appconstants.ErrCodePermission,
|
||||
},
|
||||
{
|
||||
name: "os.ErrPermission",
|
||||
err: os.ErrPermission,
|
||||
wantCode: appconstants.ErrCodePermission,
|
||||
},
|
||||
{
|
||||
name: "ErrInvalidYAML",
|
||||
err: apperrors.ErrInvalidYAML,
|
||||
wantCode: appconstants.ErrCodeInvalidYAML,
|
||||
},
|
||||
{
|
||||
name: "ErrGitHubAPI",
|
||||
err: apperrors.ErrGitHubAPI,
|
||||
wantCode: appconstants.ErrCodeGitHubAPI,
|
||||
},
|
||||
{
|
||||
name: "ErrConfiguration",
|
||||
err: apperrors.ErrConfiguration,
|
||||
wantCode: appconstants.ErrCodeConfiguration,
|
||||
},
|
||||
{
|
||||
name: testutil.TestCaseNameUnknownError,
|
||||
err: errors.New(testutil.UnknownErrorMsg),
|
||||
wantCode: appconstants.ErrCodeUnknown,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := handler.checkTypedError(tt.err)
|
||||
if got != tt.wantCode {
|
||||
t.Errorf("checkTypedError() = %v, want %v", got, tt.wantCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckStringPatterns tests string pattern matching.
|
||||
func TestCheckStringPatterns(t *testing.T) {
|
||||
handler := newTestErrorHandler()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
errStr string
|
||||
wantCode appconstants.ErrorCode
|
||||
}{
|
||||
{
|
||||
name: "file not found pattern",
|
||||
errStr: "no such file or directory",
|
||||
wantCode: appconstants.ErrCodeFileNotFound,
|
||||
},
|
||||
{
|
||||
name: "permission denied pattern",
|
||||
errStr: "permission denied",
|
||||
wantCode: appconstants.ErrCodePermission,
|
||||
},
|
||||
{
|
||||
name: "YAML error pattern",
|
||||
errStr: "yaml: unmarshal error",
|
||||
wantCode: appconstants.ErrCodeInvalidYAML,
|
||||
},
|
||||
{
|
||||
name: "GitHub API pattern",
|
||||
errStr: "GitHub API error",
|
||||
wantCode: appconstants.ErrCodeGitHubAPI,
|
||||
},
|
||||
{
|
||||
name: "configuration pattern",
|
||||
errStr: "configuration error",
|
||||
wantCode: appconstants.ErrCodeConfiguration,
|
||||
},
|
||||
{
|
||||
name: "unknown pattern",
|
||||
errStr: "some random error message",
|
||||
wantCode: appconstants.ErrCodeUnknown,
|
||||
},
|
||||
{
|
||||
name: "case insensitive matching",
|
||||
errStr: "PERMISSION DENIED",
|
||||
wantCode: appconstants.ErrCodePermission,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := handler.checkStringPatterns(tt.errStr)
|
||||
if got != tt.wantCode {
|
||||
t.Errorf("checkStringPatterns(%q) = %v, want %v", tt.errStr, got, tt.wantCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestContains tests the contains helper function.
|
||||
func TestContains(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
s string
|
||||
substr string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "exact match",
|
||||
s: testutil.ValidationHelloWorld,
|
||||
substr: "hello",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "case insensitive match",
|
||||
s: "Hello World",
|
||||
substr: "hello",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: testutil.TestCaseNameNoMatch,
|
||||
s: testutil.ValidationHelloWorld,
|
||||
substr: "goodbye",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "empty substring",
|
||||
s: testutil.ValidationHelloWorld,
|
||||
substr: "",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
s: "",
|
||||
substr: "hello",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "substring in middle",
|
||||
s: "the quick brown fox",
|
||||
substr: "quick",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "case insensitive - uppercase string",
|
||||
s: "ERROR: PERMISSION DENIED",
|
||||
substr: "permission",
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := contains(tt.s, tt.substr)
|
||||
if got != tt.want {
|
||||
t.Errorf("contains(%q, %q) = %v, want %v", tt.s, tt.substr, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: HandleSimpleError testing is covered by TestDetermineErrorCode
|
||||
// since HandleSimpleError calls determineErrorCode and then os.Exit().
|
||||
// Testing os.Exit() directly is not practical in unit tests.
|
||||
|
||||
// TestFatalErrorComponents tests the components used in fatal error handling.
|
||||
// NOTE: We cannot test HandleFatalError directly as it calls os.Exit().
|
||||
// This test verifies that error construction components work correctly.
|
||||
func TestFatalErrorComponents(t *testing.T) {
|
||||
// Test the logic that HandleFatalError uses before calling os.Exit
|
||||
|
||||
handler := newTestErrorHandler()
|
||||
|
||||
// Test that HandleFatalError correctly constructs contextual errors
|
||||
code := appconstants.ErrCodeFileNotFound
|
||||
message := "test error message"
|
||||
context := map[string]string{"file": "test.yml"}
|
||||
|
||||
// Verify suggestions and help URL are retrieved
|
||||
suggestions := apperrors.GetSuggestions(code, context)
|
||||
helpURL := apperrors.GetHelpURL(code)
|
||||
|
||||
// ErrCodeFileNotFound should have suggestions and help URL
|
||||
if len(suggestions) == 0 {
|
||||
t.Errorf("GetSuggestions(%v) returned empty, expected non-empty for ErrCodeFileNotFound", code)
|
||||
}
|
||||
|
||||
if helpURL == "" {
|
||||
t.Errorf("GetHelpURL(%v) returned empty string, expected URL for ErrCodeFileNotFound", code)
|
||||
}
|
||||
|
||||
// Verify error construction (without calling HandleFatalError which exits)
|
||||
contextualErr := apperrors.New(code, message).
|
||||
WithSuggestions(suggestions...).
|
||||
WithHelpURL(helpURL).
|
||||
WithDetails(context)
|
||||
|
||||
if contextualErr == nil {
|
||||
t.Error("failed to construct contextual error")
|
||||
}
|
||||
|
||||
// Verify handler is properly initialized
|
||||
if handler.output == nil {
|
||||
t.Error("handler output is nil")
|
||||
}
|
||||
}
|
||||
@@ -1,385 +0,0 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGetSuggestions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
code ErrorCode
|
||||
context map[string]string
|
||||
contains []string
|
||||
}{
|
||||
{
|
||||
name: "file not found with path",
|
||||
code: ErrCodeFileNotFound,
|
||||
context: map[string]string{
|
||||
"path": "/path/to/action.yml",
|
||||
},
|
||||
contains: []string{
|
||||
"Check if the file exists: /path/to/action.yml",
|
||||
"Verify the file path is correct",
|
||||
"--recursive flag",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "file not found action file",
|
||||
code: ErrCodeFileNotFound,
|
||||
context: map[string]string{
|
||||
"path": "/project/action.yml",
|
||||
},
|
||||
contains: []string{
|
||||
"Common action file names: action.yml, action.yaml",
|
||||
"Check if the file is in a subdirectory",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "permission denied",
|
||||
code: ErrCodePermission,
|
||||
context: map[string]string{
|
||||
"path": "/restricted/file.txt",
|
||||
},
|
||||
contains: []string{
|
||||
"Check file permissions: ls -la /restricted/file.txt",
|
||||
"chmod 644 /restricted/file.txt",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid YAML with line number",
|
||||
code: ErrCodeInvalidYAML,
|
||||
context: map[string]string{
|
||||
"line": "25",
|
||||
},
|
||||
contains: []string{
|
||||
"Error near line 25",
|
||||
"Check YAML indentation",
|
||||
"use spaces, not tabs",
|
||||
"YAML validator",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid YAML with tab error",
|
||||
code: ErrCodeInvalidYAML,
|
||||
context: map[string]string{
|
||||
"error": "found character that cannot start any token (tab)",
|
||||
},
|
||||
contains: []string{
|
||||
"YAML files must use spaces for indentation, not tabs",
|
||||
"Replace all tabs with spaces",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid action with missing fields",
|
||||
code: ErrCodeInvalidAction,
|
||||
context: map[string]string{
|
||||
"missing_fields": "name, description",
|
||||
},
|
||||
contains: []string{
|
||||
"Missing required fields: name, description",
|
||||
"required fields: name, description",
|
||||
"gh-action-readme schema",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no action files",
|
||||
code: ErrCodeNoActionFiles,
|
||||
context: map[string]string{
|
||||
"directory": "/project",
|
||||
},
|
||||
contains: []string{
|
||||
"Current directory: /project",
|
||||
"find /project -name 'action.y*ml'",
|
||||
"--recursive flag",
|
||||
"action.yml or action.yaml",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "GitHub API 401 error",
|
||||
code: ErrCodeGitHubAPI,
|
||||
context: map[string]string{
|
||||
"status_code": "401",
|
||||
},
|
||||
contains: []string{
|
||||
"Authentication failed",
|
||||
"check your GitHub token",
|
||||
"Token may be expired",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "GitHub API 403 error",
|
||||
code: ErrCodeGitHubAPI,
|
||||
context: map[string]string{
|
||||
"status_code": "403",
|
||||
},
|
||||
contains: []string{
|
||||
"Access forbidden",
|
||||
"check token permissions",
|
||||
"rate limit",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "GitHub API 404 error",
|
||||
code: ErrCodeGitHubAPI,
|
||||
context: map[string]string{
|
||||
"status_code": "404",
|
||||
},
|
||||
contains: []string{
|
||||
"Repository or resource not found",
|
||||
"repository is private",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "GitHub rate limit",
|
||||
code: ErrCodeGitHubRateLimit,
|
||||
context: map[string]string{},
|
||||
contains: []string{
|
||||
"rate limit exceeded",
|
||||
"GITHUB_TOKEN",
|
||||
"gh auth login",
|
||||
"Rate limits reset every hour",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "GitHub auth",
|
||||
code: ErrCodeGitHubAuth,
|
||||
context: map[string]string{},
|
||||
contains: []string{
|
||||
"export GITHUB_TOKEN",
|
||||
"gh auth login",
|
||||
"https://github.com/settings/tokens",
|
||||
"'repo' scope",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "configuration error with path",
|
||||
code: ErrCodeConfiguration,
|
||||
context: map[string]string{
|
||||
"config_path": "~/.config/gh-action-readme/config.yaml",
|
||||
},
|
||||
contains: []string{
|
||||
"Config path: ~/.config/gh-action-readme/config.yaml",
|
||||
"ls -la ~/.config/gh-action-readme/config.yaml",
|
||||
"gh-action-readme config init",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "validation error with invalid fields",
|
||||
code: ErrCodeValidation,
|
||||
context: map[string]string{
|
||||
"invalid_fields": "runs.using, inputs.test",
|
||||
},
|
||||
contains: []string{
|
||||
"Invalid fields: runs.using, inputs.test",
|
||||
"Check spelling and nesting",
|
||||
"gh-action-readme schema",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "template error with theme",
|
||||
code: ErrCodeTemplateRender,
|
||||
context: map[string]string{
|
||||
"theme": "custom",
|
||||
},
|
||||
contains: []string{
|
||||
"Current theme: custom",
|
||||
"Try using a different theme",
|
||||
"Available themes:",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "file write error with output path",
|
||||
code: ErrCodeFileWrite,
|
||||
context: map[string]string{
|
||||
"output_path": "/output/README.md",
|
||||
},
|
||||
contains: []string{
|
||||
"Output directory: /output",
|
||||
"Check permissions: ls -la /output",
|
||||
"mkdir -p /output",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dependency analysis error",
|
||||
code: ErrCodeDependencyAnalysis,
|
||||
context: map[string]string{
|
||||
"action": "my-action",
|
||||
},
|
||||
contains: []string{
|
||||
"Analyzing action: my-action",
|
||||
"GitHub token is set",
|
||||
"composite actions",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "cache access error",
|
||||
code: ErrCodeCacheAccess,
|
||||
context: map[string]string{
|
||||
"cache_path": "~/.cache/gh-action-readme",
|
||||
},
|
||||
contains: []string{
|
||||
"Cache path: ~/.cache/gh-action-readme",
|
||||
"gh-action-readme cache clear",
|
||||
"permissions: ls -la ~/.cache/gh-action-readme",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unknown error code",
|
||||
code: "UNKNOWN_TEST_CODE",
|
||||
context: map[string]string{},
|
||||
contains: []string{
|
||||
"Check the error message",
|
||||
"--verbose flag",
|
||||
"project documentation",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(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"),
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPermissionSuggestions_OSSpecific(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")
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSuggestions_EmptyContext(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,
|
||||
}
|
||||
|
||||
for _, code := range errorCodes {
|
||||
t.Run(string(code), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
suggestions := GetSuggestions(code, map[string]string{})
|
||||
if len(suggestions) == 0 {
|
||||
t.Errorf("GetSuggestions(%s, {}) returned empty slice", code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFileNotFoundSuggestions_ActionFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
context := map[string]string{
|
||||
"path": "/project/action.yml",
|
||||
}
|
||||
|
||||
suggestions := getFileNotFoundSuggestions(context)
|
||||
allSuggestions := strings.Join(suggestions, " ")
|
||||
|
||||
// Should suggest common action file names when path contains "action"
|
||||
if !strings.Contains(allSuggestions, "action.yml, action.yaml") {
|
||||
t.Error("Should suggest common action file names for action file paths")
|
||||
}
|
||||
|
||||
if !strings.Contains(allSuggestions, "subdirectory") {
|
||||
t.Error("Should suggest checking subdirectories for action files")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetInvalidYAMLSuggestions_TabError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
context := map[string]string{
|
||||
"error": "found character that cannot start any token, tab character",
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetGitHubAPISuggestions_StatusCodes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
statusCodes := map[string]string{
|
||||
"401": "Authentication failed",
|
||||
"403": "Access forbidden",
|
||||
"404": "not found",
|
||||
}
|
||||
|
||||
for code, expectedText := range statusCodes {
|
||||
t.Run("status_"+code, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -73,13 +74,13 @@ func (tp *TaskProgress) ReportProgress(task string, step int, total int) {
|
||||
}
|
||||
|
||||
// ConfigAwareComponent demonstrates a component that only needs to check configuration.
|
||||
// It depends only on OutputConfig, not the entire output system.
|
||||
// It depends only on QuietChecker, not the entire output system.
|
||||
type ConfigAwareComponent struct {
|
||||
config OutputConfig
|
||||
config QuietChecker
|
||||
}
|
||||
|
||||
// NewConfigAwareComponent creates a component that checks output configuration.
|
||||
func NewConfigAwareComponent(config OutputConfig) *ConfigAwareComponent {
|
||||
func NewConfigAwareComponent(config QuietChecker) *ConfigAwareComponent {
|
||||
return &ConfigAwareComponent{config: config}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
284
internal/focused_consumers_test.go
Normal file
284
internal/focused_consumers_test.go
Normal file
@@ -0,0 +1,284 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/apperrors"
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
// compositeOutputWriterForTest wraps testutil mocks to satisfy OutputWriter interface.
|
||||
type compositeOutputWriterForTest struct {
|
||||
*testutil.MessageLoggerMock
|
||||
*testutil.ProgressReporterMock
|
||||
*testutil.QuietCheckerMock
|
||||
}
|
||||
|
||||
// errorManagerForTest wraps testutil mocks to satisfy ErrorManager interface.
|
||||
type errorManagerForTest struct {
|
||||
*testutil.ErrorReporterMock
|
||||
*testutil.ErrorFormatterMock
|
||||
}
|
||||
|
||||
// FormatContextualError implements ErrorManager interface.
|
||||
func (e *errorManagerForTest) FormatContextualError(err *apperrors.ContextualError) string {
|
||||
if err != nil {
|
||||
return e.ErrorFormatterMock.FormatContextualError(err)
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// ErrorWithSuggestions implements ErrorManager interface.
|
||||
func (e *errorManagerForTest) ErrorWithSuggestions(err *apperrors.ContextualError) {
|
||||
e.ErrorReporterMock.ErrorWithSuggestions(err)
|
||||
}
|
||||
|
||||
// TestNewCompositeOutputWriter tests the composite output writer constructor.
|
||||
func TestNewCompositeOutputWriter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
writer := &compositeOutputWriterForTest{
|
||||
MessageLoggerMock: &testutil.MessageLoggerMock{},
|
||||
ProgressReporterMock: &testutil.ProgressReporterMock{},
|
||||
QuietCheckerMock: &testutil.QuietCheckerMock{},
|
||||
}
|
||||
cow := NewCompositeOutputWriter(writer)
|
||||
|
||||
if cow == nil {
|
||||
t.Fatal("NewCompositeOutputWriter() returned nil")
|
||||
}
|
||||
|
||||
if cow.writer != writer {
|
||||
t.Error("NewCompositeOutputWriter() did not set writer correctly")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCompositeOutputWriterProcessWithOutput tests processing with output.
|
||||
func TestCompositeOutputWriterProcessWithOutput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
isQuiet bool
|
||||
items []string
|
||||
wantMessages int
|
||||
wantInfo bool
|
||||
wantProgress bool
|
||||
wantSuccess bool
|
||||
}{
|
||||
{
|
||||
name: "with items not quiet",
|
||||
isQuiet: false,
|
||||
items: []string{"item1", "item2", "item3"},
|
||||
wantMessages: 5, // 1 info + 3 progress + 1 success
|
||||
wantInfo: true,
|
||||
wantProgress: true,
|
||||
wantSuccess: true,
|
||||
},
|
||||
{
|
||||
name: "with quiet mode",
|
||||
isQuiet: true,
|
||||
items: []string{"item1", "item2"},
|
||||
wantMessages: 0,
|
||||
wantInfo: false,
|
||||
wantProgress: false,
|
||||
wantSuccess: false,
|
||||
},
|
||||
{
|
||||
name: "with empty items",
|
||||
isQuiet: false,
|
||||
items: []string{},
|
||||
wantMessages: 2, // 1 info + 1 success
|
||||
wantInfo: true,
|
||||
wantProgress: false,
|
||||
wantSuccess: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := &testutil.MessageLoggerMock{}
|
||||
progress := &testutil.ProgressReporterMock{}
|
||||
writer := &compositeOutputWriterForTest{
|
||||
MessageLoggerMock: logger,
|
||||
ProgressReporterMock: progress,
|
||||
QuietCheckerMock: &testutil.QuietCheckerMock{QuietMode: tt.isQuiet},
|
||||
}
|
||||
cow := NewCompositeOutputWriter(writer)
|
||||
|
||||
cow.ProcessWithOutput(tt.items)
|
||||
|
||||
totalMessages := len(logger.InfoCalls) + len(progress.ProgressCalls) + len(logger.SuccessCalls)
|
||||
if totalMessages != tt.wantMessages {
|
||||
t.Errorf("ProcessWithOutput() produced %d messages, want %d",
|
||||
totalMessages, tt.wantMessages)
|
||||
}
|
||||
|
||||
hasInfo := len(logger.InfoCalls) > 0
|
||||
hasProgress := len(progress.ProgressCalls) > 0
|
||||
hasSuccess := len(logger.SuccessCalls) > 0
|
||||
|
||||
if hasInfo != tt.wantInfo {
|
||||
t.Errorf("ProcessWithOutput() hasInfo = %v, want %v", hasInfo, tt.wantInfo)
|
||||
}
|
||||
if hasProgress != tt.wantProgress {
|
||||
t.Errorf("ProcessWithOutput() hasProgress = %v, want %v", hasProgress, tt.wantProgress)
|
||||
}
|
||||
if hasSuccess != tt.wantSuccess {
|
||||
t.Errorf("ProcessWithOutput() hasSuccess = %v, want %v", hasSuccess, tt.wantSuccess)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewValidationComponent tests the validation component constructor.
|
||||
func TestNewValidationComponent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
errorManager := &errorManagerForTest{
|
||||
ErrorReporterMock: &testutil.ErrorReporterMock{},
|
||||
ErrorFormatterMock: &testutil.ErrorFormatterMock{},
|
||||
}
|
||||
logger := &testutil.MessageLoggerMock{}
|
||||
|
||||
vc := NewValidationComponent(errorManager, logger)
|
||||
|
||||
if vc == nil {
|
||||
t.Fatal("NewValidationComponent() returned nil")
|
||||
}
|
||||
|
||||
if vc.errorManager != errorManager {
|
||||
t.Error("NewValidationComponent() did not set errorManager correctly")
|
||||
}
|
||||
|
||||
if vc.logger != logger {
|
||||
t.Error("NewValidationComponent() did not set logger correctly")
|
||||
}
|
||||
}
|
||||
|
||||
// getErrorCallType returns the type of error call that was made.
|
||||
func getErrorCallType(reporter *testutil.ErrorReporterMock) string {
|
||||
switch {
|
||||
case len(reporter.ErrorWithSuggestionsCalls) > 0:
|
||||
return "ErrorWithSuggestions"
|
||||
case len(reporter.ErrorCalls) > 0:
|
||||
return "Error"
|
||||
case len(reporter.ErrorWithSimpleFixCalls) > 0:
|
||||
return "ErrorWithSimpleFix"
|
||||
case len(reporter.ErrorWithContextCalls) > 0:
|
||||
return "ErrorWithContext"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidationComponentValidateAndReport tests validation reporting.
|
||||
func TestValidationComponentValidateAndReport(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
item string
|
||||
isValid bool
|
||||
err error
|
||||
wantLoggerCalls int
|
||||
wantErrorCalls int
|
||||
wantErrorCallType string
|
||||
}{
|
||||
{
|
||||
name: "valid item",
|
||||
item: testutil.TestItemName,
|
||||
isValid: true,
|
||||
err: nil,
|
||||
wantLoggerCalls: 1,
|
||||
wantErrorCalls: 0,
|
||||
wantErrorCallType: "",
|
||||
},
|
||||
{
|
||||
name: "invalid with contextual error",
|
||||
item: testutil.TestItemName,
|
||||
isValid: false,
|
||||
err: apperrors.New(appconstants.ErrCodeValidation, "validation failed"),
|
||||
wantLoggerCalls: 0,
|
||||
wantErrorCalls: 1,
|
||||
wantErrorCallType: "ErrorWithSuggestions",
|
||||
},
|
||||
{
|
||||
name: "invalid with regular error",
|
||||
item: testutil.TestItemName,
|
||||
isValid: false,
|
||||
err: errors.New("regular error"),
|
||||
wantLoggerCalls: 0,
|
||||
wantErrorCalls: 1,
|
||||
wantErrorCallType: "Error",
|
||||
},
|
||||
{
|
||||
name: "invalid without error",
|
||||
item: testutil.TestItemName,
|
||||
isValid: false,
|
||||
err: nil,
|
||||
wantLoggerCalls: 0,
|
||||
wantErrorCalls: 1,
|
||||
wantErrorCallType: "ErrorWithSimpleFix",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
errorReporter := &testutil.ErrorReporterMock{}
|
||||
errorManager := &errorManagerForTest{
|
||||
ErrorReporterMock: errorReporter,
|
||||
ErrorFormatterMock: &testutil.ErrorFormatterMock{},
|
||||
}
|
||||
logger := &testutil.MessageLoggerMock{}
|
||||
vc := NewValidationComponent(errorManager, logger)
|
||||
|
||||
vc.ValidateAndReport(tt.item, tt.isValid, tt.err)
|
||||
|
||||
totalLoggerCalls := len(
|
||||
logger.InfoCalls,
|
||||
) + len(
|
||||
logger.SuccessCalls,
|
||||
) + len(
|
||||
logger.WarningCalls,
|
||||
) + len(
|
||||
logger.BoldCalls,
|
||||
) + len(
|
||||
logger.PrintfCalls,
|
||||
)
|
||||
if totalLoggerCalls != tt.wantLoggerCalls {
|
||||
t.Errorf("ValidateAndReport() logger calls = %d, want %d",
|
||||
totalLoggerCalls, tt.wantLoggerCalls)
|
||||
}
|
||||
|
||||
totalErrorCalls := len(
|
||||
errorReporter.ErrorCalls,
|
||||
) + len(
|
||||
errorReporter.ErrorWithSuggestionsCalls,
|
||||
) + len(
|
||||
errorReporter.ErrorWithContextCalls,
|
||||
) + len(
|
||||
errorReporter.ErrorWithSimpleFixCalls,
|
||||
)
|
||||
if totalErrorCalls != tt.wantErrorCalls {
|
||||
t.Errorf("ValidateAndReport() error calls = %d, want %d",
|
||||
totalErrorCalls, tt.wantErrorCalls)
|
||||
}
|
||||
|
||||
if tt.wantErrorCallType != "" {
|
||||
actualCallType := getErrorCallType(errorReporter)
|
||||
if actualCallType != tt.wantErrorCallType {
|
||||
t.Errorf("ValidateAndReport() error call type = %s, want %s",
|
||||
actualCallType, tt.wantErrorCallType)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -56,7 +48,13 @@ func isUnitTestEnvironment() bool {
|
||||
// NewGenerator creates a new generator instance with the provided configuration.
|
||||
// This constructor maintains backward compatibility by using concrete implementations.
|
||||
// In unit test environments, it automatically uses NullOutput to suppress output.
|
||||
// If config is nil, it uses DefaultAppConfig() to prevent panics.
|
||||
func NewGenerator(config *AppConfig) *Generator {
|
||||
// Handle nil config gracefully
|
||||
if config == nil {
|
||||
config = DefaultAppConfig()
|
||||
}
|
||||
|
||||
// Use null output in unit test environments to keep tests clean
|
||||
// Integration tests need real output to verify CLI behavior
|
||||
if isUnitTestEnvironment() {
|
||||
@@ -147,8 +145,8 @@ func (g *Generator) GenerateFromFile(actionPath string) error {
|
||||
|
||||
// DiscoverActionFiles finds action.yml and action.yaml files in the given directory
|
||||
// using the centralized parser function and adds verbose logging.
|
||||
func (g *Generator) DiscoverActionFiles(dir string, recursive bool) ([]string, error) {
|
||||
actionFiles, err := DiscoverActionFiles(dir, recursive)
|
||||
func (g *Generator) DiscoverActionFiles(dir string, recursive bool, ignoredDirs []string) ([]string, error) {
|
||||
actionFiles, err := DiscoverActionFiles(dir, recursive, ignoredDirs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -169,18 +167,23 @@ func (g *Generator) DiscoverActionFiles(dir string, recursive bool) ([]string, e
|
||||
|
||||
// DiscoverActionFilesWithValidation discovers action files with centralized error handling and validation.
|
||||
// This function consolidates the duplicated file discovery logic across the codebase.
|
||||
func (g *Generator) DiscoverActionFilesWithValidation(dir string, recursive bool, context string) ([]string, error) {
|
||||
func (g *Generator) DiscoverActionFilesWithValidation(
|
||||
dir string,
|
||||
recursive bool,
|
||||
ignoredDirs []string,
|
||||
context string,
|
||||
) ([]string, error) {
|
||||
// Discover action files
|
||||
actionFiles, err := g.DiscoverActionFiles(dir, recursive)
|
||||
actionFiles, err := g.DiscoverActionFiles(dir, recursive, ignoredDirs)
|
||||
if err != nil {
|
||||
g.Output.ErrorWithContext(
|
||||
errCodes.ErrCodeFileNotFound,
|
||||
appconstants.ErrCodeFileNotFound,
|
||||
"failed to discover action files for "+context,
|
||||
map[string]string{
|
||||
"directory": dir,
|
||||
"recursive": strconv.FormatBool(recursive),
|
||||
"context": context,
|
||||
ContextKeyError: err.Error(),
|
||||
"directory": dir,
|
||||
"recursive": strconv.FormatBool(recursive),
|
||||
"context": context,
|
||||
appconstants.ContextKeyError: err.Error(),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -191,7 +194,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,48 +260,85 @@ 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 markdown template: %w", err)
|
||||
return "", fmt.Errorf("failed to render template: %w", err)
|
||||
}
|
||||
|
||||
outputPath := g.resolveOutputPath(outputDir, "README.md")
|
||||
if err := os.WriteFile(outputPath, []byte(content), FilePermDefault); err != nil {
|
||||
return content, nil
|
||||
}
|
||||
|
||||
// generateSimpleFormat is a helper for generating simple text-based formats (Markdown, AsciiDoc).
|
||||
// It consolidates the common pattern of template rendering, file writing, and success messaging.
|
||||
func (g *Generator) generateSimpleFormat(
|
||||
action *ActionYML,
|
||||
outputDir, actionPath string,
|
||||
format, defaultFilename, successMsg string,
|
||||
) error {
|
||||
templatePath := g.resolveTemplatePathForFormat()
|
||||
|
||||
opts := TemplateOptions{
|
||||
TemplatePath: templatePath,
|
||||
Format: format,
|
||||
}
|
||||
|
||||
content, err := g.renderTemplateForAction(action, outputDir, actionPath, opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to render %s template: %w", format, err)
|
||||
}
|
||||
|
||||
outputPath, err := g.resolveOutputPath(outputDir, defaultFilename)
|
||||
if err != nil {
|
||||
return fmt.Errorf(appconstants.ErrFailedToResolveOutputPath, err)
|
||||
}
|
||||
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)
|
||||
return fmt.Errorf("failed to write %s to %s: %w", format, outputPath, err)
|
||||
}
|
||||
|
||||
g.Output.Success("Generated README.md: %s", outputPath)
|
||||
g.Output.Success("%s: %s", successMsg, outputPath)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateMarkdown creates a README.md file using the template.
|
||||
func (g *Generator) generateMarkdown(action *ActionYML, outputDir, actionPath string) error {
|
||||
return g.generateSimpleFormat(
|
||||
action, outputDir, actionPath,
|
||||
"md", appconstants.ReadmeMarkdown, "Generated README.md",
|
||||
)
|
||||
}
|
||||
|
||||
// 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 +347,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)
|
||||
}
|
||||
@@ -325,7 +359,10 @@ func (g *Generator) generateHTML(action *ActionYML, outputDir, actionPath string
|
||||
}
|
||||
|
||||
defaultFilename := action.Name + ".html"
|
||||
outputPath := g.resolveOutputPath(outputDir, defaultFilename)
|
||||
outputPath, err := g.resolveOutputPath(outputDir, defaultFilename)
|
||||
if err != nil {
|
||||
return fmt.Errorf(appconstants.ErrFailedToResolveOutputPath, err)
|
||||
}
|
||||
if err := writer.Write(content, outputPath); err != nil {
|
||||
return fmt.Errorf("failed to write HTML to %s: %w", outputPath, err)
|
||||
}
|
||||
@@ -339,7 +376,10 @@ 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, err := g.resolveOutputPath(outputDir, appconstants.ActionDocsJSON)
|
||||
if err != nil {
|
||||
return fmt.Errorf(appconstants.ErrFailedToResolveOutputPath, err)
|
||||
}
|
||||
if err := writer.Write(action, outputPath); err != nil {
|
||||
return fmt.Errorf("failed to write JSON to %s: %w", outputPath, err)
|
||||
}
|
||||
@@ -351,34 +391,10 @@ 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")
|
||||
|
||||
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)
|
||||
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 {
|
||||
// #nosec G306 -- output file permissions
|
||||
return fmt.Errorf("failed to write AsciiDoc to %s: %w", outputPath, err)
|
||||
}
|
||||
|
||||
g.Output.Success("Generated AsciiDoc: %s", outputPath)
|
||||
|
||||
return nil
|
||||
return g.generateSimpleFormat(
|
||||
action, outputDir, actionPath,
|
||||
"asciidoc", appconstants.ReadmeASCIIDoc, "Generated AsciiDoc",
|
||||
)
|
||||
}
|
||||
|
||||
// processFiles processes each file and tracks results.
|
||||
@@ -431,7 +447,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",
|
||||
@@ -462,29 +479,68 @@ func (g *Generator) determineOutputDir(actionPath string) string {
|
||||
return g.Config.OutputDir
|
||||
}
|
||||
|
||||
// resolveOutputPath resolves the final output path, considering custom filename.
|
||||
func (g *Generator) resolveOutputPath(outputDir, defaultFilename string) string {
|
||||
// resolveOutputPath resolves the final output path and validates it prevents path traversal.
|
||||
// Returns an error if the resolved path would escape the outputDir.
|
||||
func (g *Generator) resolveOutputPath(outputDir, defaultFilename string) (string, error) {
|
||||
// Determine the filename to use
|
||||
filename := defaultFilename
|
||||
if g.Config.OutputFilename != "" {
|
||||
if filepath.IsAbs(g.Config.OutputFilename) {
|
||||
return g.Config.OutputFilename
|
||||
}
|
||||
|
||||
return filepath.Join(outputDir, g.Config.OutputFilename)
|
||||
filename = g.Config.OutputFilename
|
||||
}
|
||||
|
||||
return filepath.Join(outputDir, defaultFilename)
|
||||
// Reject paths containing .. components (path traversal attempt)
|
||||
if strings.Contains(filename, "..") {
|
||||
return "", fmt.Errorf(appconstants.ErrPathTraversal, filename, outputDir)
|
||||
}
|
||||
|
||||
// Handle absolute paths - allow them as-is (user's explicit choice)
|
||||
if filepath.IsAbs(filename) {
|
||||
cleaned := filepath.Clean(filename)
|
||||
if cleaned != filename {
|
||||
return "", fmt.Errorf("absolute path contains extraneous components: %s", filename)
|
||||
}
|
||||
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
// For relative paths, join with output directory
|
||||
finalPath := filepath.Join(outputDir, filename)
|
||||
|
||||
// Validate the final path stays within outputDir
|
||||
absOutputDir, err := filepath.Abs(outputDir)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf(appconstants.ErrInvalidOutputPath, err)
|
||||
}
|
||||
|
||||
absFinalPath, err := filepath.Abs(finalPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf(appconstants.ErrInvalidOutputPath, err)
|
||||
}
|
||||
|
||||
// Check if final path is within output directory using filepath.Rel
|
||||
relPath, err := filepath.Rel(absOutputDir, absFinalPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf(appconstants.ErrInvalidOutputPath, err)
|
||||
}
|
||||
|
||||
// If relative path starts with "..", it's outside the output directory
|
||||
if strings.HasPrefix(relPath, "..") {
|
||||
return "", fmt.Errorf(appconstants.ErrPathTraversal, filename, outputDir)
|
||||
}
|
||||
|
||||
return absFinalPath, nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -5,12 +5,13 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
// TestGenerator_ComprehensiveGeneration demonstrates the new table-driven testing framework
|
||||
// TestGeneratorComprehensiveGeneration demonstrates the new table-driven testing framework
|
||||
// by testing generation across all fixtures, themes, and formats systematically.
|
||||
func TestGenerator_ComprehensiveGeneration(t *testing.T) {
|
||||
func TestGeneratorComprehensiveGeneration(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Create test cases using the new helper functions
|
||||
cases := testutil.CreateGeneratorTestCases()
|
||||
@@ -32,8 +33,8 @@ func TestGenerator_ComprehensiveGeneration(t *testing.T) {
|
||||
testutil.RunGeneratorTests(t, filteredCases)
|
||||
}
|
||||
|
||||
// TestGenerator_AllValidFixtures tests generation with all valid fixtures.
|
||||
func TestGenerator_AllValidFixtures(t *testing.T) {
|
||||
// TestGeneratorAllValidFixtures tests generation with all valid fixtures.
|
||||
func TestGeneratorAllValidFixtures(t *testing.T) {
|
||||
t.Parallel()
|
||||
validFixtures := testutil.GetValidFixtures()
|
||||
|
||||
@@ -64,8 +65,8 @@ func TestGenerator_AllValidFixtures(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerator_AllInvalidFixtures tests that invalid fixtures produce expected errors.
|
||||
func TestGenerator_AllInvalidFixtures(t *testing.T) {
|
||||
// TestGeneratorAllInvalidFixtures tests that invalid fixtures produce expected errors.
|
||||
func TestGeneratorAllInvalidFixtures(t *testing.T) {
|
||||
t.Parallel()
|
||||
invalidFixtures := testutil.GetInvalidFixtures()
|
||||
|
||||
@@ -106,8 +107,8 @@ func TestGenerator_AllInvalidFixtures(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerator_AllThemes demonstrates theme testing using helper functions.
|
||||
func TestGenerator_AllThemes(t *testing.T) {
|
||||
// TestGeneratorAllThemes demonstrates theme testing using helper functions.
|
||||
func TestGeneratorAllThemes(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Use the helper function to test all themes
|
||||
testutil.TestAllThemes(t, func(t *testing.T, theme string) {
|
||||
@@ -129,8 +130,8 @@ func TestGenerator_AllThemes(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestGenerator_AllFormats demonstrates format testing using helper functions.
|
||||
func TestGenerator_AllFormats(t *testing.T) {
|
||||
// TestGeneratorAllFormats demonstrates format testing using helper functions.
|
||||
func TestGeneratorAllFormats(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Use the helper function to test all formats
|
||||
testutil.TestAllFormats(t, func(t *testing.T, format string) {
|
||||
@@ -152,8 +153,8 @@ func TestGenerator_AllFormats(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestGenerator_ByActionType demonstrates testing by action type.
|
||||
func TestGenerator_ByActionType(t *testing.T) {
|
||||
// TestGeneratorByActionType demonstrates testing by action type.
|
||||
func TestGeneratorByActionType(t *testing.T) {
|
||||
t.Parallel()
|
||||
actionTypes := []testutil.ActionType{
|
||||
testutil.ActionTypeJavaScript,
|
||||
@@ -190,8 +191,8 @@ func TestGenerator_ByActionType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerator_WithMockEnvironment demonstrates testing with a complete mock environment.
|
||||
func TestGenerator_WithMockEnvironment(t *testing.T) {
|
||||
// TestGeneratorWithMockEnvironment demonstrates testing with a complete mock environment.
|
||||
func TestGeneratorWithMockEnvironment(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Create a complete test environment
|
||||
envConfig := &testutil.EnvironmentConfig{
|
||||
@@ -227,8 +228,8 @@ func TestGenerator_WithMockEnvironment(t *testing.T) {
|
||||
testutil.AssertNoError(t, err)
|
||||
}
|
||||
|
||||
// TestGenerator_FixtureValidation demonstrates fixture validation.
|
||||
func TestGenerator_FixtureValidation(t *testing.T) {
|
||||
// TestGeneratorFixtureValidation demonstrates fixture validation.
|
||||
func TestGeneratorFixtureValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Test that all valid fixtures pass validation
|
||||
validFixtures := testutil.GetValidFixtures()
|
||||
@@ -271,7 +272,7 @@ func createGeneratorTestExecutor() testutil.TestExecutor {
|
||||
}
|
||||
|
||||
// Create temporary action file
|
||||
actionPath = filepath.Join(ctx.TempDir, "action.yml")
|
||||
actionPath = filepath.Join(ctx.TempDir, appconstants.ActionFileNameYML)
|
||||
testutil.WriteTestFile(t, actionPath, fixture.Content)
|
||||
}
|
||||
|
||||
|
||||
139
internal/generator_helper_test.go
Normal file
139
internal/generator_helper_test.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
)
|
||||
|
||||
// TestDefaultTestConfig_Helper tests the defaultTestConfig helper function.
|
||||
func TestDefaultTestConfigHelper(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Call the helper multiple times to verify consistency
|
||||
cfg1 := defaultTestConfig()
|
||||
cfg2 := defaultTestConfig()
|
||||
|
||||
// Verify expected defaults
|
||||
if cfg1.Quiet != true {
|
||||
t.Error("expected Quiet=true for test config")
|
||||
}
|
||||
if cfg1.Theme != appconstants.ThemeDefault {
|
||||
t.Errorf("expected default theme, got %s", cfg1.Theme)
|
||||
}
|
||||
if cfg1.OutputFormat != appconstants.OutputFormatMarkdown {
|
||||
t.Errorf("expected markdown format, got %s", cfg1.OutputFormat)
|
||||
}
|
||||
if cfg1.OutputDir != "." {
|
||||
t.Errorf("expected OutputDir='.', got %s", cfg1.OutputDir)
|
||||
}
|
||||
|
||||
// Verify immutability - modifying one shouldn't affect others
|
||||
cfg1.Quiet = false
|
||||
cfg1.Theme = "custom"
|
||||
|
||||
if cfg2.Quiet != true {
|
||||
t.Error("defaultTestConfig should return independent configs")
|
||||
}
|
||||
if cfg2.Theme != appconstants.ThemeDefault {
|
||||
t.Error("defaultTestConfig should return independent configs")
|
||||
}
|
||||
|
||||
// Verify getting a fresh config after modification
|
||||
cfg3 := defaultTestConfig()
|
||||
if cfg3.Quiet != true {
|
||||
t.Error("defaultTestConfig should always return Quiet=true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssertActionFiles_Helper tests the assertActionFiles helper function.
|
||||
func TestAssertActionFilesHelper(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
files []string
|
||||
setup func(*testing.T) []string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "empty file list",
|
||||
setup: func(t *testing.T) []string {
|
||||
t.Helper()
|
||||
|
||||
return []string{}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "valid action.yml files",
|
||||
setup: func(t *testing.T) []string {
|
||||
t.Helper()
|
||||
tmpDir1 := t.TempDir()
|
||||
tmpDir2 := t.TempDir()
|
||||
file1 := filepath.Join(tmpDir1, appconstants.ActionFileNameYML)
|
||||
file2 := filepath.Join(tmpDir2, appconstants.ActionFileNameYML)
|
||||
|
||||
err := os.WriteFile(file1, []byte("name: test"), appconstants.FilePermDefault)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to write file1: %v", err)
|
||||
}
|
||||
|
||||
err = os.WriteFile(file2, []byte("name: test2"), appconstants.FilePermDefault)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to write file2: %v", err)
|
||||
}
|
||||
|
||||
return []string{file1, file2}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "valid action.yaml files",
|
||||
setup: func(t *testing.T) []string {
|
||||
t.Helper()
|
||||
tmpDir := t.TempDir()
|
||||
file := filepath.Join(tmpDir, "action.yaml")
|
||||
|
||||
err := os.WriteFile(file, []byte("name: test"), appconstants.FilePermDefault)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to write file: %v", err)
|
||||
}
|
||||
|
||||
return []string{file}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mixed yml and yaml extensions",
|
||||
setup: func(t *testing.T) []string {
|
||||
t.Helper()
|
||||
tmpDir1 := t.TempDir()
|
||||
tmpDir2 := t.TempDir()
|
||||
file1 := filepath.Join(tmpDir1, appconstants.ActionFileNameYML)
|
||||
file2 := filepath.Join(tmpDir2, "action.yaml")
|
||||
|
||||
_ = os.WriteFile(file1, []byte("name: test1"), appconstants.FilePermDefault)
|
||||
|
||||
_ = os.WriteFile(file2, []byte("name: test2"), appconstants.FilePermDefault)
|
||||
|
||||
return []string{file1, file2}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
files := tt.setup(t)
|
||||
|
||||
// Call the helper - it will verify files exist and have correct extensions
|
||||
// For invalid files, it will call t.Error (which is expected)
|
||||
assertActionFiles(t, files)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Note: Invalid test cases (wrong extensions, nonexistent files) are not included
|
||||
// because testing error paths would require mocking testing.T, which is complex.
|
||||
// The helper is already well-tested through the main test suite for error cases.
|
||||
File diff suppressed because it is too large
Load Diff
153
internal/generator_test_helper.go
Normal file
153
internal/generator_test_helper.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
// testFormatGeneration is a generic helper for testing format generation methods.
|
||||
// It consolidates the common pattern across HTML, JSON, and AsciiDoc generation tests.
|
||||
func testFormatGeneration(
|
||||
t *testing.T,
|
||||
generateFunc func(*Generator, *ActionYML, string, string) error,
|
||||
expectedFile, formatName string,
|
||||
needsActionPath bool,
|
||||
) {
|
||||
t.Helper()
|
||||
t.Parallel()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
action := createTestAction()
|
||||
gen := createQuietGenerator()
|
||||
|
||||
var err error
|
||||
if needsActionPath {
|
||||
actionPath := filepath.Join(tmpDir, appconstants.ActionFileNameYML)
|
||||
err = generateFunc(gen, action, tmpDir, actionPath)
|
||||
} else {
|
||||
// For JSON which doesn't need actionPath
|
||||
err = generateFunc(gen, action, tmpDir, "")
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("%s generation unexpected error = %v", formatName, err)
|
||||
}
|
||||
|
||||
verifyFileExists(t, filepath.Join(tmpDir, expectedFile), expectedFile)
|
||||
}
|
||||
|
||||
// testHTMLGeneration tests HTML generation creates the expected output file.
|
||||
func testHTMLGeneration(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
testFormatGeneration(
|
||||
t,
|
||||
func(g *Generator, a *ActionYML, out, path string) error {
|
||||
return g.generateHTML(a, out, path)
|
||||
},
|
||||
"Test Action.html",
|
||||
"HTML",
|
||||
true, // needs actionPath
|
||||
)
|
||||
}
|
||||
|
||||
// testJSONGeneration tests JSON generation creates the expected output file.
|
||||
func testJSONGeneration(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
testFormatGeneration(
|
||||
t,
|
||||
func(g *Generator, a *ActionYML, out, _ string) error {
|
||||
return g.generateJSON(a, out)
|
||||
},
|
||||
"action-docs.json",
|
||||
"JSON",
|
||||
false, // doesn't need actionPath
|
||||
)
|
||||
}
|
||||
|
||||
// testASCIIDocGeneration tests AsciiDoc generation creates the expected output file.
|
||||
func testASCIIDocGeneration(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
testFormatGeneration(
|
||||
t,
|
||||
func(g *Generator, a *ActionYML, out, path string) error {
|
||||
return g.generateASCIIDoc(a, out, path)
|
||||
},
|
||||
"README.adoc",
|
||||
"AsciiDoc",
|
||||
true, // needs actionPath
|
||||
)
|
||||
}
|
||||
|
||||
// createTestAction creates a basic test action for generator tests.
|
||||
func createTestAction() *ActionYML {
|
||||
return &ActionYML{
|
||||
Name: testutil.TestActionName,
|
||||
Description: testutil.TestActionDesc,
|
||||
Runs: map[string]any{"using": "composite"},
|
||||
}
|
||||
}
|
||||
|
||||
// createQuietGenerator creates a generator with quiet output for testing.
|
||||
func createQuietGenerator() *Generator {
|
||||
config := DefaultAppConfig()
|
||||
config.Quiet = true
|
||||
|
||||
return NewGenerator(config)
|
||||
}
|
||||
|
||||
// verifyFileExists checks that a file was created at the expected path.
|
||||
func verifyFileExists(t *testing.T, fullPath, expectedFileName string) {
|
||||
t.Helper()
|
||||
|
||||
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
|
||||
t.Errorf("Expected %s to be created", expectedFileName)
|
||||
}
|
||||
}
|
||||
|
||||
// createTestDirs creates multiple test directories with given names.
|
||||
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)
|
||||
testutil.CreateTestDir(t, dirPath)
|
||||
dirs[i] = dirPath
|
||||
}
|
||||
|
||||
return dirs
|
||||
}
|
||||
|
||||
// createMultiActionSetup creates a setupFunc for batch processing tests with multiple actions.
|
||||
// It generates separate directories for each action and writes the specified fixtures.
|
||||
func createMultiActionSetup(dirNames, fixtures []string) func(t *testing.T, tmpDir string) []string {
|
||||
return func(t *testing.T, tmpDir string) []string {
|
||||
t.Helper()
|
||||
|
||||
// Create separate directories for each action
|
||||
dirs := createTestDirs(t, tmpDir, dirNames...)
|
||||
|
||||
// Build file paths and write fixtures
|
||||
files := make([]string, len(dirs))
|
||||
for i, dir := range dirs {
|
||||
files[i] = filepath.Join(dir, appconstants.ActionFileNameYML)
|
||||
testutil.WriteTestFile(t, files[i], testutil.MustReadFixture(fixtures[i]))
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
}
|
||||
|
||||
// setupNonexistentFiles returns a setupFunc that creates paths to nonexistent files.
|
||||
// This is used in multiple tests to verify error handling for missing files.
|
||||
func setupNonexistentFiles(filename string) func(*testing.T, string) []string {
|
||||
return func(_ *testing.T, tmpDir string) []string {
|
||||
return []string{filepath.Join(tmpDir, filename)}
|
||||
}
|
||||
}
|
||||
85
internal/generator_validation_helper_test.go
Normal file
85
internal/generator_validation_helper_test.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
// TestAssertMessageCounts_Helper tests the assertMessageCounts helper function.
|
||||
func TestAssertMessageCountsHelper(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
output *capturedOutput
|
||||
want messageCountExpectations
|
||||
}{
|
||||
{
|
||||
name: "all counts zero",
|
||||
output: &capturedOutput{
|
||||
CapturedOutput: &testutil.CapturedOutput{
|
||||
BoldMessages: []string{},
|
||||
SuccessMessages: []string{},
|
||||
WarningMessages: []string{},
|
||||
ErrorMessages: []string{},
|
||||
InfoMessages: []string{},
|
||||
},
|
||||
},
|
||||
want: messageCountExpectations{
|
||||
bold: 0,
|
||||
success: 0,
|
||||
warning: 0,
|
||||
error: 0,
|
||||
info: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "some messages",
|
||||
output: &capturedOutput{
|
||||
CapturedOutput: &testutil.CapturedOutput{
|
||||
BoldMessages: []string{"bold1", "bold2"},
|
||||
SuccessMessages: []string{"success1"},
|
||||
WarningMessages: []string{},
|
||||
ErrorMessages: []string{"error1", "error2", "error3"},
|
||||
InfoMessages: []string{"info1"},
|
||||
},
|
||||
},
|
||||
want: messageCountExpectations{
|
||||
bold: 2,
|
||||
success: 1,
|
||||
warning: 0,
|
||||
error: 3,
|
||||
info: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "only bold and success",
|
||||
output: &capturedOutput{
|
||||
CapturedOutput: &testutil.CapturedOutput{
|
||||
BoldMessages: []string{"bold"},
|
||||
SuccessMessages: []string{"success"},
|
||||
WarningMessages: []string{},
|
||||
ErrorMessages: []string{},
|
||||
InfoMessages: []string{},
|
||||
},
|
||||
},
|
||||
want: messageCountExpectations{
|
||||
bold: 1,
|
||||
success: 1,
|
||||
warning: 0,
|
||||
error: 0,
|
||||
info: 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Call the helper - it validates message counts
|
||||
assertMessageCounts(t, tt.output, tt.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
551
internal/generator_validation_test.go
Normal file
551
internal/generator_validation_test.go
Normal file
@@ -0,0 +1,551 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/apperrors"
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
// capturedOutput wraps testutil.CapturedOutput to satisfy CompleteOutput interface.
|
||||
type capturedOutput struct {
|
||||
*testutil.CapturedOutput
|
||||
}
|
||||
|
||||
// ErrorWithSuggestions wraps the testutil version to match interface signature.
|
||||
func (c *capturedOutput) ErrorWithSuggestions(err *apperrors.ContextualError) {
|
||||
c.CapturedOutput.ErrorWithSuggestions(err)
|
||||
}
|
||||
|
||||
// FormatContextualError wraps the testutil version to match interface signature.
|
||||
func (c *capturedOutput) FormatContextualError(err *apperrors.ContextualError) string {
|
||||
return c.CapturedOutput.FormatContextualError(err)
|
||||
}
|
||||
|
||||
// newCapturedOutput creates a new capturedOutput instance.
|
||||
func newCapturedOutput() *capturedOutput {
|
||||
return &capturedOutput{
|
||||
CapturedOutput: &testutil.CapturedOutput{},
|
||||
}
|
||||
}
|
||||
|
||||
// TestCountValidationStats tests the validation statistics counting function.
|
||||
func TestCountValidationStats(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
results []ValidationResult
|
||||
wantValidFiles int
|
||||
wantTotalIssues int
|
||||
}{
|
||||
{
|
||||
name: testutil.TestCaseNameAllValidFiles,
|
||||
results: []ValidationResult{
|
||||
{MissingFields: []string{testutil.ValidationTestFile1}},
|
||||
{MissingFields: []string{testutil.ValidationTestFile2}},
|
||||
},
|
||||
wantValidFiles: 2,
|
||||
wantTotalIssues: 0,
|
||||
},
|
||||
{
|
||||
name: "all invalid files",
|
||||
results: []ValidationResult{
|
||||
{MissingFields: []string{testutil.ValidationTestFile1, "name", "description"}},
|
||||
{MissingFields: []string{testutil.ValidationTestFile2, "runs"}},
|
||||
},
|
||||
wantValidFiles: 0,
|
||||
wantTotalIssues: 3, // 2 issues in first file + 1 in second
|
||||
},
|
||||
{
|
||||
name: "mixed valid and invalid",
|
||||
results: []ValidationResult{
|
||||
{MissingFields: []string{testutil.ValidationTestFile1}}, // Valid
|
||||
{MissingFields: []string{testutil.ValidationTestFile2, "name", "description"}}, // 2 issues
|
||||
{MissingFields: []string{"file: action3.yml"}}, // Valid
|
||||
{MissingFields: []string{"file: action4.yml", "runs"}}, // 1 issue
|
||||
},
|
||||
wantValidFiles: 2,
|
||||
wantTotalIssues: 3,
|
||||
},
|
||||
{
|
||||
name: "empty results",
|
||||
results: []ValidationResult{},
|
||||
wantValidFiles: 0,
|
||||
wantTotalIssues: 0,
|
||||
},
|
||||
{
|
||||
name: "single valid file",
|
||||
results: []ValidationResult{
|
||||
{MissingFields: []string{testutil.ValidationTestFile3}},
|
||||
},
|
||||
wantValidFiles: 1,
|
||||
wantTotalIssues: 0,
|
||||
},
|
||||
{
|
||||
name: "single invalid file with multiple issues",
|
||||
results: []ValidationResult{
|
||||
{MissingFields: []string{testutil.ValidationTestFile3, "name", "description", "runs"}},
|
||||
},
|
||||
wantValidFiles: 0,
|
||||
wantTotalIssues: 3,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gen := &Generator{}
|
||||
gotValid, gotIssues := gen.countValidationStats(tt.results)
|
||||
|
||||
if gotValid != tt.wantValidFiles {
|
||||
t.Errorf("countValidationStats() validFiles = %d, want %d", gotValid, tt.wantValidFiles)
|
||||
}
|
||||
if gotIssues != tt.wantTotalIssues {
|
||||
t.Errorf("countValidationStats() totalIssues = %d, want %d", gotIssues, tt.wantTotalIssues)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// messageCountExpectations defines expected message counts for validation tests.
|
||||
type messageCountExpectations struct {
|
||||
bold int
|
||||
success int
|
||||
warning int
|
||||
error int
|
||||
info int
|
||||
}
|
||||
|
||||
// assertMessageCounts checks that message counts match expectations.
|
||||
func assertMessageCounts(t *testing.T, output *capturedOutput, want messageCountExpectations) {
|
||||
t.Helper()
|
||||
|
||||
checks := []struct {
|
||||
name string
|
||||
got int
|
||||
expected int
|
||||
}{
|
||||
{"bold messages", len(output.BoldMessages), want.bold},
|
||||
{"success messages", len(output.SuccessMessages), want.success},
|
||||
{"warning messages", len(output.WarningMessages), want.warning},
|
||||
{"error messages", len(output.ErrorMessages), want.error},
|
||||
{"info messages", len(output.InfoMessages), want.info},
|
||||
}
|
||||
|
||||
for _, check := range checks {
|
||||
if check.got != check.expected {
|
||||
t.Errorf("showValidationSummary() %s = %d, want %d", check.name, check.got, check.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestShowValidationSummary tests the validation summary display function.
|
||||
func TestShowValidationSummary(t *testing.T) {
|
||||
tests := []validationSummaryTestCase{
|
||||
createValidationSummaryTest(validationSummaryParams{
|
||||
name: testutil.TestCaseNameAllValidFiles,
|
||||
totalFiles: 3,
|
||||
validFiles: 3,
|
||||
totalIssues: 0,
|
||||
resultCount: 3,
|
||||
errorCount: 0,
|
||||
wantWarning: 0,
|
||||
wantError: 0,
|
||||
wantInfo: 0,
|
||||
}),
|
||||
createValidationSummaryTest(validationSummaryParams{
|
||||
name: "some files with issues",
|
||||
totalFiles: 3,
|
||||
validFiles: 1,
|
||||
totalIssues: 5,
|
||||
resultCount: 3,
|
||||
errorCount: 0,
|
||||
wantWarning: 1,
|
||||
wantError: 0,
|
||||
wantInfo: 1,
|
||||
}),
|
||||
createValidationSummaryTest(validationSummaryParams{
|
||||
name: "parse errors present",
|
||||
totalFiles: 5,
|
||||
validFiles: 2,
|
||||
totalIssues: 3,
|
||||
resultCount: 3,
|
||||
errorCount: 2,
|
||||
wantWarning: 1,
|
||||
wantError: 1,
|
||||
wantInfo: 1,
|
||||
}),
|
||||
createValidationSummaryTest(validationSummaryParams{
|
||||
name: "only parse errors",
|
||||
totalFiles: 2,
|
||||
validFiles: 0,
|
||||
totalIssues: 0,
|
||||
resultCount: 0,
|
||||
errorCount: 2,
|
||||
wantWarning: 0,
|
||||
wantError: 1,
|
||||
wantInfo: 0,
|
||||
}),
|
||||
createValidationSummaryTest(validationSummaryParams{
|
||||
name: testutil.TestCaseNameZeroFiles,
|
||||
totalFiles: 0,
|
||||
validFiles: 0,
|
||||
totalIssues: 0,
|
||||
resultCount: 0,
|
||||
errorCount: 0,
|
||||
wantWarning: 0,
|
||||
wantError: 0,
|
||||
wantInfo: 0,
|
||||
}),
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output := newCapturedOutput()
|
||||
gen := &Generator{Output: output}
|
||||
|
||||
gen.showValidationSummary(tt.totalFiles, tt.validFiles, tt.totalIssues, tt.resultCount, tt.errorCount)
|
||||
|
||||
assertMessageCounts(t, output, messageCountExpectations{
|
||||
bold: tt.wantBold,
|
||||
success: tt.wantSuccess,
|
||||
warning: tt.wantWarning,
|
||||
error: tt.wantError,
|
||||
info: tt.wantInfo,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestShowParseErrors tests the parse error display function.
|
||||
func TestShowParseErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
errors []string
|
||||
wantBold int
|
||||
wantError int
|
||||
wantContains string
|
||||
}{
|
||||
{
|
||||
name: "no parse errors",
|
||||
errors: []string{},
|
||||
wantBold: 0,
|
||||
wantError: 0,
|
||||
wantContains: "",
|
||||
},
|
||||
{
|
||||
name: "single parse error",
|
||||
errors: []string{"Failed to parse action.yml: invalid YAML"},
|
||||
wantBold: 1,
|
||||
wantError: 1,
|
||||
wantContains: "Failed to parse",
|
||||
},
|
||||
{
|
||||
name: "multiple parse errors",
|
||||
errors: []string{
|
||||
"Failed to parse action1.yml: invalid YAML",
|
||||
"Failed to parse action2.yml: file not found",
|
||||
"Failed to parse action3.yml: permission denied",
|
||||
},
|
||||
wantBold: 1,
|
||||
wantError: 3,
|
||||
wantContains: "Failed to parse",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output := newCapturedOutput()
|
||||
gen := &Generator{Output: output}
|
||||
|
||||
gen.showParseErrors(tt.errors)
|
||||
|
||||
testutil.AssertMessageCounts(t, tt.name, output.CapturedOutput, 0, tt.wantError, 0, tt.wantBold)
|
||||
|
||||
if tt.wantContains != "" && !output.ContainsError(tt.wantContains) {
|
||||
t.Errorf(
|
||||
"showParseErrors() error messages should contain %q, got %v",
|
||||
tt.wantContains,
|
||||
output.ErrorMessages,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestShowFileIssues tests the file-specific issue display function.
|
||||
func TestShowFileIssues(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
result ValidationResult
|
||||
wantInfo int
|
||||
wantError int
|
||||
wantWarning int
|
||||
wantContains string
|
||||
}{
|
||||
{
|
||||
name: "file with missing fields only",
|
||||
result: ValidationResult{
|
||||
MissingFields: []string{testutil.ValidationTestFile3, "name", "description"},
|
||||
},
|
||||
wantInfo: 1, // File name only (no suggestions)
|
||||
wantError: 2, // 2 missing fields
|
||||
wantWarning: 0,
|
||||
wantContains: "name",
|
||||
},
|
||||
{
|
||||
name: "file with warnings only",
|
||||
result: ValidationResult{
|
||||
MissingFields: []string{testutil.ValidationTestFile3},
|
||||
Warnings: []string{"author field is recommended", "icon field is recommended"},
|
||||
},
|
||||
wantInfo: 1, // File name
|
||||
wantError: 0,
|
||||
wantWarning: 2,
|
||||
wantContains: "author",
|
||||
},
|
||||
{
|
||||
name: "file with missing fields and warnings",
|
||||
result: ValidationResult{
|
||||
MissingFields: []string{testutil.ValidationTestFile3, "name"},
|
||||
Warnings: []string{"author field is recommended"},
|
||||
},
|
||||
wantInfo: 1,
|
||||
wantError: 1,
|
||||
wantWarning: 1,
|
||||
wantContains: "name",
|
||||
},
|
||||
{
|
||||
name: "file with suggestions",
|
||||
result: ValidationResult{
|
||||
MissingFields: []string{testutil.ValidationTestFile3, "name"},
|
||||
Suggestions: []string{"Add a descriptive name field", "See documentation for examples"},
|
||||
},
|
||||
wantInfo: 2, // File name + Suggestions header
|
||||
wantError: 1,
|
||||
wantWarning: 0,
|
||||
wantContains: "descriptive name",
|
||||
},
|
||||
{
|
||||
name: "valid file (no issues)",
|
||||
result: ValidationResult{
|
||||
MissingFields: []string{testutil.ValidationTestFile3},
|
||||
},
|
||||
wantInfo: 1, // Just file name
|
||||
wantError: 0,
|
||||
wantWarning: 0,
|
||||
wantContains: appconstants.ActionFileNameYML,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output := newCapturedOutput()
|
||||
gen := &Generator{Output: output}
|
||||
|
||||
gen.showFileIssues(tt.result)
|
||||
|
||||
if len(output.InfoMessages) < tt.wantInfo {
|
||||
t.Errorf("showFileIssues() info messages = %d, want at least %d", len(output.InfoMessages), tt.wantInfo)
|
||||
}
|
||||
if len(output.ErrorMessages) != tt.wantError {
|
||||
t.Errorf("showFileIssues() error messages = %d, want %d", len(output.ErrorMessages), tt.wantError)
|
||||
}
|
||||
if len(output.WarningMessages) != tt.wantWarning {
|
||||
t.Errorf("showFileIssues() warning messages = %d, want %d", len(output.WarningMessages), tt.wantWarning)
|
||||
}
|
||||
|
||||
// Check if expected content appears somewhere in the output
|
||||
if tt.wantContains != "" && !output.ContainsMessage(tt.wantContains) {
|
||||
t.Errorf("showFileIssues() output should contain %q, got info=%v, error=%v, warning=%v",
|
||||
tt.wantContains, output.InfoMessages, output.ErrorMessages, output.WarningMessages)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestShowDetailedIssues tests the detailed issues display function.
|
||||
func TestShowDetailedIssues(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
results []ValidationResult
|
||||
totalIssues int
|
||||
verbose bool
|
||||
wantBold int // Expected number of bold messages
|
||||
}{
|
||||
{
|
||||
name: "no issues, not verbose",
|
||||
results: []ValidationResult{
|
||||
{MissingFields: []string{"file: action1.yml"}},
|
||||
{MissingFields: []string{"file: action2.yml"}},
|
||||
},
|
||||
totalIssues: 0,
|
||||
verbose: false,
|
||||
wantBold: 0, // Should not show details
|
||||
},
|
||||
{
|
||||
name: "no issues, verbose mode",
|
||||
results: []ValidationResult{
|
||||
{MissingFields: []string{testutil.ValidationTestFile1}},
|
||||
{MissingFields: []string{testutil.ValidationTestFile2}},
|
||||
},
|
||||
totalIssues: 0,
|
||||
verbose: true,
|
||||
wantBold: 1, // Should show header even with no issues
|
||||
},
|
||||
{
|
||||
name: "some issues",
|
||||
results: []ValidationResult{
|
||||
{MissingFields: []string{testutil.ValidationTestFile1, "name"}},
|
||||
{MissingFields: []string{testutil.ValidationTestFile2}},
|
||||
},
|
||||
totalIssues: 1,
|
||||
verbose: false,
|
||||
wantBold: 1, // Should show details
|
||||
},
|
||||
{
|
||||
name: "files with warnings",
|
||||
results: []ValidationResult{
|
||||
{MissingFields: []string{testutil.ValidationTestFile1}, Warnings: []string{"author recommended"}},
|
||||
},
|
||||
totalIssues: 0,
|
||||
verbose: false,
|
||||
wantBold: 0, // No bold output (warnings don't count as issues, early return)
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output := newCapturedOutput()
|
||||
gen := &Generator{
|
||||
Output: output,
|
||||
Config: &AppConfig{Verbose: tt.verbose},
|
||||
}
|
||||
|
||||
gen.showDetailedIssues(tt.results, tt.totalIssues)
|
||||
|
||||
if len(output.BoldMessages) != tt.wantBold {
|
||||
t.Errorf("showDetailedIssues() bold messages = %d, want %d", len(output.BoldMessages), tt.wantBold)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestReportValidationResults tests the main validation reporting function.
|
||||
// reportCounts holds the expected counts for validation report output.
|
||||
type reportCounts struct {
|
||||
bold int
|
||||
success bool
|
||||
error bool
|
||||
}
|
||||
|
||||
// validateReportCounts validates that the report output contains expected message counts.
|
||||
func validateReportCounts(
|
||||
t *testing.T,
|
||||
gotBold, gotSuccess, gotError int,
|
||||
want reportCounts,
|
||||
allowUnexpectedErrors bool,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
if gotBold < want.bold {
|
||||
t.Errorf("Bold messages = %d, want at least %d", gotBold, want.bold)
|
||||
}
|
||||
|
||||
if want.success && gotSuccess == 0 {
|
||||
t.Error("Expected success messages, got none")
|
||||
}
|
||||
|
||||
if want.error && gotError == 0 {
|
||||
t.Error("Expected error messages, got none")
|
||||
}
|
||||
|
||||
if !allowUnexpectedErrors && gotError > 0 {
|
||||
t.Errorf("Expected no error messages, got %d", gotError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportValidationResults(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
results []ValidationResult
|
||||
errors []string
|
||||
wantBold int // Minimum number of bold messages
|
||||
wantSuccess bool
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
name: "all valid, no errors",
|
||||
results: []ValidationResult{
|
||||
{MissingFields: []string{testutil.ValidationTestFile1}},
|
||||
{MissingFields: []string{testutil.ValidationTestFile2}},
|
||||
},
|
||||
errors: []string{},
|
||||
wantBold: 1,
|
||||
wantSuccess: true,
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "some invalid files",
|
||||
results: []ValidationResult{
|
||||
{MissingFields: []string{testutil.ValidationTestFile1, "name"}},
|
||||
{MissingFields: []string{testutil.ValidationTestFile2}},
|
||||
},
|
||||
errors: []string{},
|
||||
wantBold: 2, // Summary + Details
|
||||
wantSuccess: true,
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "parse errors only",
|
||||
results: []ValidationResult{},
|
||||
errors: []string{"Failed to parse action.yml"},
|
||||
wantBold: 2, // Summary + Parse Errors
|
||||
wantSuccess: true,
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "mixed validation issues and parse errors",
|
||||
results: []ValidationResult{
|
||||
{MissingFields: []string{testutil.ValidationTestFile1, "name", "description"}},
|
||||
},
|
||||
errors: []string{"Failed to parse action2.yml"},
|
||||
wantBold: 3, // Summary + Details + Parse Errors
|
||||
wantSuccess: true,
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "empty results",
|
||||
results: []ValidationResult{},
|
||||
errors: []string{},
|
||||
wantBold: 1,
|
||||
wantSuccess: true,
|
||||
wantError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output := newCapturedOutput()
|
||||
gen := &Generator{
|
||||
Output: output,
|
||||
Config: &AppConfig{Verbose: false},
|
||||
}
|
||||
|
||||
gen.reportValidationResults(tt.results, tt.errors)
|
||||
|
||||
counts := reportCounts{
|
||||
bold: tt.wantBold,
|
||||
success: tt.wantSuccess,
|
||||
error: tt.wantError,
|
||||
}
|
||||
validateReportCounts(
|
||||
t,
|
||||
len(output.BoldMessages),
|
||||
len(output.SuccessMessages),
|
||||
len(output.ErrorMessages),
|
||||
counts,
|
||||
tt.wantError,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
44
internal/generator_validation_test_helper.go
Normal file
44
internal/generator_validation_test_helper.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package internal
|
||||
|
||||
// validationSummaryTestCase defines a test case for validation summary tests.
|
||||
// This helper reduces duplication in test case definitions by providing
|
||||
// a factory function with sensible defaults.
|
||||
type validationSummaryTestCase struct {
|
||||
name string
|
||||
totalFiles int
|
||||
validFiles int
|
||||
totalIssues int
|
||||
resultCount int
|
||||
errorCount int
|
||||
wantBold int
|
||||
wantSuccess int
|
||||
wantWarning int
|
||||
wantError int
|
||||
wantInfo int
|
||||
}
|
||||
|
||||
// validationSummaryParams holds parameters for creating validation summary test cases.
|
||||
type validationSummaryParams struct {
|
||||
name string
|
||||
totalFiles, validFiles, totalIssues, resultCount, errorCount int
|
||||
wantWarning, wantError, wantInfo int
|
||||
}
|
||||
|
||||
// createValidationSummaryTest creates a validation summary test case with defaults.
|
||||
// Default values: wantBold=1, wantSuccess=1, wantWarning=0, wantError=0, wantInfo=0
|
||||
// Only provide the fields that differ from defaults.
|
||||
func createValidationSummaryTest(params validationSummaryParams) validationSummaryTestCase {
|
||||
return validationSummaryTestCase{
|
||||
name: params.name,
|
||||
totalFiles: params.totalFiles,
|
||||
validFiles: params.validFiles,
|
||||
totalIssues: params.totalIssues,
|
||||
resultCount: params.resultCount,
|
||||
errorCount: params.errorCount,
|
||||
wantBold: 1, // Always 1
|
||||
wantSuccess: 1, // Always 1
|
||||
wantWarning: params.wantWarning,
|
||||
wantError: params.wantError,
|
||||
wantInfo: params.wantInfo,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,19 +155,23 @@ func getRemoteURLFromConfig(repoRoot string) (string, error) {
|
||||
|
||||
// getDefaultBranch gets the default branch name.
|
||||
func getDefaultBranch(repoRoot string) string {
|
||||
cmd := exec.Command("git", "symbolic-ref", "refs/remotes/origin/HEAD")
|
||||
cmd := exec.Command(
|
||||
appconstants.GitCommand,
|
||||
"symbolic-ref",
|
||||
"refs/remotes/origin/HEAD",
|
||||
) // #nosec G204 -- controlled git command
|
||||
cmd.Dir = repoRoot
|
||||
|
||||
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 +180,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
|
||||
@@ -207,7 +213,7 @@ func parseGitHubURL(url string) (organization, repository string) {
|
||||
repo := matches[2]
|
||||
|
||||
// Remove .git suffix if present
|
||||
repo = strings.TrimSuffix(repo, ".git")
|
||||
repo = strings.TrimSuffix(repo, appconstants.DirGit)
|
||||
|
||||
return org, repo
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
@@ -22,18 +21,11 @@ func TestFindRepositoryRoot(t *testing.T) {
|
||||
setupFunc: func(t *testing.T, tmpDir string) string {
|
||||
t.Helper()
|
||||
// Create .git directory
|
||||
gitDir := filepath.Join(tmpDir, ".git")
|
||||
err := os.MkdirAll(gitDir, 0750) // #nosec G301 -- test directory permissions
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create .git directory: %v", err)
|
||||
}
|
||||
testutil.SetupGitDirectory(t, tmpDir)
|
||||
|
||||
// Create subdirectory to test from
|
||||
subDir := filepath.Join(tmpDir, "subdir", "nested")
|
||||
err = os.MkdirAll(subDir, 0750) // #nosec G301 -- test directory permissions
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create subdirectory: %v", err)
|
||||
}
|
||||
testutil.CreateTestDir(t, subDir)
|
||||
|
||||
return subDir
|
||||
},
|
||||
@@ -54,22 +46,19 @@ func TestFindRepositoryRoot(t *testing.T) {
|
||||
expectEmpty: false,
|
||||
},
|
||||
{
|
||||
name: "no git repository",
|
||||
name: testutil.TestCaseNameNoGitRepository,
|
||||
setupFunc: func(t *testing.T, tmpDir string) string {
|
||||
t.Helper()
|
||||
// Create subdirectory without .git
|
||||
subDir := filepath.Join(tmpDir, "subdir")
|
||||
err := os.MkdirAll(subDir, 0750) // #nosec G301 -- test directory permissions
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create subdirectory: %v", err)
|
||||
}
|
||||
testutil.CreateTestDir(t, subDir)
|
||||
|
||||
return subDir
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "nonexistent directory",
|
||||
name: testutil.TestCaseNameNonexistentDir,
|
||||
setupFunc: func(_ *testing.T, tmpDir string) string {
|
||||
t.Helper()
|
||||
|
||||
@@ -109,9 +98,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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -125,19 +112,9 @@ func TestDetectGitRepository(t *testing.T) {
|
||||
setupFunc func(t *testing.T, tmpDir string) string
|
||||
checkFunc func(t *testing.T, info *RepoInfo)
|
||||
}{
|
||||
{
|
||||
createGitRepoTestCase(gitTestCase{
|
||||
name: "GitHub repository",
|
||||
setupFunc: func(t *testing.T, tmpDir string) string {
|
||||
t.Helper()
|
||||
// Create .git directory
|
||||
gitDir := filepath.Join(tmpDir, ".git")
|
||||
err := os.MkdirAll(gitDir, 0750) // #nosec G301 -- test directory permissions
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create .git directory: %v", err)
|
||||
}
|
||||
|
||||
// Create config file with GitHub remote
|
||||
configContent := `[core]
|
||||
configContent: `[core]
|
||||
repositoryformatversion = 0
|
||||
filemode = true
|
||||
bare = false
|
||||
@@ -148,47 +125,23 @@ func TestDetectGitRepository(t *testing.T) {
|
||||
[branch "main"]
|
||||
remote = origin
|
||||
merge = refs/heads/main
|
||||
`
|
||||
configPath := filepath.Join(gitDir, "config")
|
||||
testutil.WriteTestFile(t, configPath, configContent)
|
||||
|
||||
return tmpDir
|
||||
},
|
||||
checkFunc: func(t *testing.T, info *RepoInfo) {
|
||||
t.Helper()
|
||||
testutil.AssertEqual(t, "owner", info.Organization)
|
||||
testutil.AssertEqual(t, "repo", info.Repository)
|
||||
testutil.AssertEqual(t, "https://github.com/owner/repo.git", info.RemoteURL)
|
||||
},
|
||||
},
|
||||
{
|
||||
`,
|
||||
expectedOrg: "owner",
|
||||
expectedRepo: "repo",
|
||||
expectedURL: "https://github.com/owner/repo.git",
|
||||
}),
|
||||
createGitRepoTestCase(gitTestCase{
|
||||
name: "SSH remote URL",
|
||||
setupFunc: func(t *testing.T, tmpDir string) string {
|
||||
t.Helper()
|
||||
gitDir := filepath.Join(tmpDir, ".git")
|
||||
err := os.MkdirAll(gitDir, 0750) // #nosec G301 -- test directory permissions
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create .git directory: %v", err)
|
||||
}
|
||||
|
||||
configContent := `[remote "origin"]
|
||||
configContent: `[remote "origin"]
|
||||
url = git@github.com:owner/repo.git
|
||||
fetch = +refs/heads/*:refs/remotes/origin/*
|
||||
`
|
||||
configPath := filepath.Join(gitDir, "config")
|
||||
testutil.WriteTestFile(t, configPath, configContent)
|
||||
|
||||
return tmpDir
|
||||
},
|
||||
checkFunc: func(t *testing.T, info *RepoInfo) {
|
||||
t.Helper()
|
||||
testutil.AssertEqual(t, "owner", info.Organization)
|
||||
testutil.AssertEqual(t, "repo", info.Repository)
|
||||
testutil.AssertEqual(t, "git@github.com:owner/repo.git", info.RemoteURL)
|
||||
},
|
||||
},
|
||||
`,
|
||||
expectedOrg: "owner",
|
||||
expectedRepo: "repo",
|
||||
expectedURL: "git@github.com:owner/repo.git",
|
||||
}),
|
||||
{
|
||||
name: "no git repository",
|
||||
name: testutil.TestCaseNameNoGitRepository,
|
||||
setupFunc: func(_ *testing.T, tmpDir string) string {
|
||||
return tmpDir
|
||||
},
|
||||
@@ -199,33 +152,16 @@ func TestDetectGitRepository(t *testing.T) {
|
||||
testutil.AssertEqual(t, "", info.Repository)
|
||||
},
|
||||
},
|
||||
{
|
||||
createGitRepoTestCase(gitTestCase{
|
||||
name: "git repository without origin remote",
|
||||
setupFunc: func(t *testing.T, tmpDir string) string {
|
||||
t.Helper()
|
||||
gitDir := filepath.Join(tmpDir, ".git")
|
||||
err := os.MkdirAll(gitDir, 0750) // #nosec G301 -- test directory permissions
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create .git directory: %v", err)
|
||||
}
|
||||
|
||||
configContent := `[core]
|
||||
configContent: `[core]
|
||||
repositoryformatversion = 0
|
||||
filemode = true
|
||||
bare = false
|
||||
`
|
||||
configPath := filepath.Join(gitDir, "config")
|
||||
testutil.WriteTestFile(t, configPath, configContent)
|
||||
|
||||
return tmpDir
|
||||
},
|
||||
checkFunc: func(t *testing.T, info *RepoInfo) {
|
||||
t.Helper()
|
||||
testutil.AssertEqual(t, true, info.IsGitRepo)
|
||||
testutil.AssertEqual(t, "", info.Organization)
|
||||
testutil.AssertEqual(t, "", info.Repository)
|
||||
},
|
||||
},
|
||||
`,
|
||||
expectedOrg: "",
|
||||
expectedRepo: "",
|
||||
}),
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -263,7 +199,7 @@ func TestParseGitHubURL(t *testing.T) {
|
||||
expectedRepo: "repo",
|
||||
},
|
||||
{
|
||||
name: "SSH GitHub URL",
|
||||
name: testutil.TestCaseNameSSHGitHub,
|
||||
remoteURL: "git@github.com:owner/repo.git",
|
||||
expectedOrg: "owner",
|
||||
expectedRepo: "repo",
|
||||
@@ -300,7 +236,7 @@ func TestParseGitHubURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoInfo_GetRepositoryName(t *testing.T) {
|
||||
func TestRepoInfoGetRepositoryName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
@@ -346,3 +282,532 @@ func TestRepoInfo_GetRepositoryName(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRepoInfoGenerateUsesStatement tests the GenerateUsesStatement method.
|
||||
func TestRepoInfoGenerateUsesStatement(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
repoInfo *RepoInfo
|
||||
actionName string
|
||||
version string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "repository-level action",
|
||||
repoInfo: &RepoInfo{
|
||||
Organization: "actions",
|
||||
Repository: "checkout",
|
||||
},
|
||||
actionName: "",
|
||||
version: "v3",
|
||||
expected: testutil.TestActionCheckoutV3,
|
||||
},
|
||||
{
|
||||
name: "repository-level action with same name",
|
||||
repoInfo: &RepoInfo{
|
||||
Organization: "actions",
|
||||
Repository: "checkout",
|
||||
},
|
||||
actionName: "checkout",
|
||||
version: "v3",
|
||||
expected: testutil.TestActionCheckoutV3,
|
||||
},
|
||||
{
|
||||
name: testutil.TestCaseNameSubdirAction,
|
||||
repoInfo: &RepoInfo{
|
||||
Organization: "actions",
|
||||
Repository: "toolkit",
|
||||
},
|
||||
actionName: "cache",
|
||||
version: "v2",
|
||||
expected: "actions/toolkit/cache@v2",
|
||||
},
|
||||
{
|
||||
name: "without organization",
|
||||
repoInfo: &RepoInfo{
|
||||
Organization: "",
|
||||
Repository: "",
|
||||
},
|
||||
actionName: "my-action",
|
||||
version: "v1",
|
||||
expected: "your-org/my-action@v1",
|
||||
},
|
||||
{
|
||||
name: "without organization and action name",
|
||||
repoInfo: &RepoInfo{
|
||||
Organization: "",
|
||||
Repository: "",
|
||||
},
|
||||
actionName: "",
|
||||
version: "v1",
|
||||
expected: "your-org/your-action@v1",
|
||||
},
|
||||
{
|
||||
name: "with SHA version",
|
||||
repoInfo: &RepoInfo{
|
||||
Organization: "actions",
|
||||
Repository: "checkout",
|
||||
},
|
||||
actionName: "",
|
||||
version: "abc123def456",
|
||||
expected: "actions/checkout@abc123def456",
|
||||
},
|
||||
{
|
||||
name: "with main branch",
|
||||
repoInfo: &RepoInfo{
|
||||
Organization: "actions",
|
||||
Repository: "setup-node",
|
||||
},
|
||||
actionName: "",
|
||||
version: "main",
|
||||
expected: "actions/setup-node@main",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := tt.repoInfo.GenerateUsesStatement(tt.actionName, tt.version)
|
||||
testutil.AssertEqual(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetDefaultBranch_Fallbacks tests branch detection fallback logic.
|
||||
func TestGetDefaultBranchFallbacks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
setupFunc func(t *testing.T, tmpDir string) string
|
||||
expectedBranch string
|
||||
}{
|
||||
createDefaultBranchTestCase(defaultBranchTestCase{
|
||||
name: "git config with main branch",
|
||||
branch: "main",
|
||||
expectedBranch: "main",
|
||||
}),
|
||||
createDefaultBranchTestCase(defaultBranchTestCase{
|
||||
name: "git config with master branch - returns main fallback",
|
||||
branch: "master",
|
||||
expectedBranch: "main",
|
||||
}),
|
||||
createDefaultBranchTestCase(defaultBranchTestCase{
|
||||
name: "git config with develop branch - returns main fallback",
|
||||
branch: "develop",
|
||||
expectedBranch: "main",
|
||||
}),
|
||||
{
|
||||
name: "no git config - returns main fallback",
|
||||
setupFunc: func(t *testing.T, tmpDir string) string {
|
||||
t.Helper()
|
||||
_ = testutil.SetupGitDirectory(t, tmpDir)
|
||||
|
||||
return tmpDir
|
||||
},
|
||||
expectedBranch: "main", // Falls back to "main" when git command fails
|
||||
},
|
||||
{
|
||||
name: "malformed git config - returns main fallback",
|
||||
setupFunc: func(t *testing.T, tmpDir string) string {
|
||||
t.Helper()
|
||||
gitDir := testutil.SetupGitDirectory(t, tmpDir)
|
||||
|
||||
configContent := `[branch this is malformed`
|
||||
configPath := filepath.Join(gitDir, "config")
|
||||
testutil.WriteTestFile(t, configPath, configContent)
|
||||
|
||||
return tmpDir
|
||||
},
|
||||
expectedBranch: "main", // Falls back to "main" when git command fails
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmpDir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
repoDir := tt.setupFunc(t, tmpDir)
|
||||
branch := getDefaultBranch(repoDir)
|
||||
|
||||
testutil.AssertEqual(t, tt.expectedBranch, branch)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetRemoteURL_AllSources tests all remote URL detection methods.
|
||||
func TestGetRemoteURLAllSources(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
setupFunc func(t *testing.T, tmpDir string) string
|
||||
expectError bool
|
||||
expectedURL string
|
||||
}{
|
||||
createGitURLTestCase(gitURLTestCase{
|
||||
name: "remote from git config - https",
|
||||
configContent: `[remote "origin"]
|
||||
url = https://github.com/test/repo.git
|
||||
`,
|
||||
expectError: false,
|
||||
expectedURL: "https://github.com/test/repo.git",
|
||||
}),
|
||||
createGitURLTestCase(gitURLTestCase{
|
||||
name: "remote from git config - ssh",
|
||||
configContent: `[remote "origin"]
|
||||
url = git@github.com:user/repo.git
|
||||
`,
|
||||
expectError: false,
|
||||
expectedURL: "git@github.com:user/repo.git",
|
||||
}),
|
||||
createGitURLTestCase(gitURLTestCase{
|
||||
name: "multiple remotes - origin takes precedence",
|
||||
configContent: `[remote "upstream"]
|
||||
url = https://github.com/upstream/repo
|
||||
[remote "origin"]
|
||||
url = https://github.com/origin/repo
|
||||
`,
|
||||
expectError: false,
|
||||
expectedURL: "https://github.com/origin/repo",
|
||||
}),
|
||||
{
|
||||
name: "no remote configured",
|
||||
setupFunc: func(t *testing.T, tmpDir string) string {
|
||||
t.Helper()
|
||||
_ = testutil.SetupGitDirectory(t, tmpDir)
|
||||
|
||||
return tmpDir
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmpDir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
repoDir := tt.setupFunc(t, tmpDir)
|
||||
url, err := getRemoteURL(repoDir)
|
||||
|
||||
if tt.expectError {
|
||||
testutil.AssertError(t, err)
|
||||
} else {
|
||||
testutil.AssertNoError(t, err)
|
||||
testutil.AssertEqual(t, tt.expectedURL, url)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetRemoteURLFromConfig_EdgeCases tests git config parsing with edge cases.
|
||||
func TestGetRemoteURLFromConfigEdgeCases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
configContent string
|
||||
expectError bool
|
||||
expectedURL string
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "standard git config",
|
||||
configContent: `[remote "origin"]
|
||||
url = ` + testutil.TestURLGitHubUserRepo + `
|
||||
`,
|
||||
expectError: false,
|
||||
expectedURL: testutil.TestURLGitHubUserRepo,
|
||||
description: "Standard git config",
|
||||
},
|
||||
{
|
||||
name: "config with comments",
|
||||
configContent: `# This is a comment
|
||||
[remote "origin"]
|
||||
# Another comment
|
||||
url = ` + testutil.TestURLGitHubUserRepo + `
|
||||
fetch = +refs/heads/*:refs/remotes/origin/*
|
||||
`,
|
||||
expectError: false,
|
||||
expectedURL: testutil.TestURLGitHubUserRepo,
|
||||
description: "Config with comments should be parsed",
|
||||
},
|
||||
{
|
||||
name: "empty config",
|
||||
configContent: ``,
|
||||
expectError: true,
|
||||
description: "Empty config",
|
||||
},
|
||||
{
|
||||
name: "incomplete section",
|
||||
configContent: `[remote "origin"
|
||||
url = ` + testutil.TestURLGitHubUserRepo + `
|
||||
`,
|
||||
expectError: true,
|
||||
description: "Malformed section",
|
||||
},
|
||||
{
|
||||
name: "url with spaces",
|
||||
configContent: `[remote "origin"]
|
||||
url = https://github.com/user name/repo name
|
||||
`,
|
||||
expectError: false,
|
||||
expectedURL: "https://github.com/user name/repo name",
|
||||
description: "URL with spaces should be preserved",
|
||||
},
|
||||
{
|
||||
name: "multiple origin sections - first wins",
|
||||
configContent: `[remote "origin"]
|
||||
url = https://github.com/first/repo
|
||||
[remote "origin"]
|
||||
url = https://github.com/second/repo
|
||||
`,
|
||||
expectError: false,
|
||||
expectedURL: "https://github.com/first/repo",
|
||||
description: "First origin section takes precedence",
|
||||
},
|
||||
{
|
||||
name: "ssh url format",
|
||||
configContent: `[remote "origin"]
|
||||
url = git@gitlab.com:user/repo.git
|
||||
`,
|
||||
expectError: false,
|
||||
expectedURL: "git@gitlab.com:user/repo.git",
|
||||
description: "SSH URL format",
|
||||
},
|
||||
{
|
||||
name: "url with trailing whitespace",
|
||||
configContent: `[remote "origin"]
|
||||
url = ` + testutil.TestURLGitHubUserRepo + `
|
||||
`,
|
||||
expectError: false,
|
||||
expectedURL: testutil.TestURLGitHubUserRepo,
|
||||
description: "Trailing whitespace should be trimmed",
|
||||
},
|
||||
{
|
||||
name: "config without url field",
|
||||
configContent: `[remote "origin"]
|
||||
fetch = +refs/heads/*:refs/remotes/origin/*
|
||||
`,
|
||||
expectError: true,
|
||||
description: "Remote without URL",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmpDir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
gitDir := testutil.SetupGitDirectory(t, tmpDir)
|
||||
|
||||
if tt.configContent != "" {
|
||||
configPath := filepath.Join(gitDir, "config")
|
||||
testutil.WriteTestFile(t, configPath, tt.configContent)
|
||||
}
|
||||
|
||||
url, err := getRemoteURLFromConfig(tmpDir)
|
||||
|
||||
if tt.expectError {
|
||||
testutil.AssertError(t, err)
|
||||
} else {
|
||||
testutil.AssertNoError(t, err)
|
||||
testutil.AssertEqual(t, tt.expectedURL, url)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFindRepositoryRoot_EdgeCases tests additional edge cases for repository root detection.
|
||||
func TestFindRepositoryRootEdgeCases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
setupFunc func(t *testing.T, tmpDir string) string
|
||||
expectError bool
|
||||
checkFunc func(t *testing.T, tmpDir, repoRoot string)
|
||||
}{
|
||||
{
|
||||
name: "deeply nested subdirectory",
|
||||
setupFunc: func(t *testing.T, tmpDir string) string {
|
||||
t.Helper()
|
||||
testutil.SetupGitDirectory(t, tmpDir)
|
||||
|
||||
deepPath := filepath.Join(tmpDir, "a", "b", "c", "d", "e")
|
||||
testutil.CreateTestDir(t, deepPath)
|
||||
|
||||
return deepPath
|
||||
},
|
||||
expectError: false,
|
||||
checkFunc: func(t *testing.T, tmpDir, repoRoot string) {
|
||||
t.Helper()
|
||||
testutil.AssertEqual(t, tmpDir, repoRoot)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "git worktree with .git file",
|
||||
setupFunc: func(t *testing.T, tmpDir string) string {
|
||||
t.Helper()
|
||||
gitFile := filepath.Join(tmpDir, ".git")
|
||||
testutil.WriteTestFile(t, gitFile, "gitdir: /path/to/worktree")
|
||||
|
||||
return tmpDir
|
||||
},
|
||||
expectError: false,
|
||||
checkFunc: func(t *testing.T, tmpDir, repoRoot string) {
|
||||
t.Helper()
|
||||
testutil.AssertEqual(t, tmpDir, repoRoot)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "current directory is repo root",
|
||||
setupFunc: func(t *testing.T, tmpDir string) string {
|
||||
t.Helper()
|
||||
testutil.SetupGitDirectory(t, tmpDir)
|
||||
|
||||
return tmpDir
|
||||
},
|
||||
expectError: false,
|
||||
checkFunc: func(t *testing.T, tmpDir, repoRoot string) {
|
||||
t.Helper()
|
||||
testutil.AssertEqual(t, tmpDir, repoRoot)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "path with spaces",
|
||||
setupFunc: func(t *testing.T, tmpDir string) string {
|
||||
t.Helper()
|
||||
testutil.SetupGitDirectory(t, tmpDir)
|
||||
|
||||
spacePath := filepath.Join(tmpDir, "path with spaces")
|
||||
testutil.CreateTestDir(t, spacePath)
|
||||
|
||||
return spacePath
|
||||
},
|
||||
expectError: false,
|
||||
checkFunc: func(t *testing.T, tmpDir, repoRoot string) {
|
||||
t.Helper()
|
||||
testutil.AssertEqual(t, tmpDir, repoRoot)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmpDir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
testDir := tt.setupFunc(t, tmpDir)
|
||||
repoRoot, err := FindRepositoryRoot(testDir)
|
||||
|
||||
if tt.expectError {
|
||||
testutil.AssertError(t, err)
|
||||
} else {
|
||||
testutil.AssertNoError(t, err)
|
||||
tt.checkFunc(t, tmpDir, repoRoot)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseGitHubURL_EdgeCases tests additional URL parsing edge cases.
|
||||
func TestParseGitHubURLEdgeCases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
remoteURL string
|
||||
expectedOrg string
|
||||
expectedRepo string
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "gitlab https url",
|
||||
remoteURL: "https://gitlab.com/owner/repo.git",
|
||||
expectedOrg: "",
|
||||
expectedRepo: "",
|
||||
description: "Non-GitHub URLs return empty",
|
||||
},
|
||||
{
|
||||
name: "github url with subgroups",
|
||||
remoteURL: "https://github.com/org/subgroup/repo.git",
|
||||
expectedOrg: "org",
|
||||
expectedRepo: "subgroup", // Regex only captures first two path segments
|
||||
description: "GitHub URLs with subpaths only capture org/subgroup",
|
||||
},
|
||||
{
|
||||
name: "ssh url without git suffix",
|
||||
remoteURL: "git@github.com:owner/repo",
|
||||
expectedOrg: "owner",
|
||||
expectedRepo: "repo",
|
||||
description: "SSH URL without .git suffix",
|
||||
},
|
||||
{
|
||||
name: "url with trailing slash",
|
||||
remoteURL: "https://github.com/owner/repo/",
|
||||
expectedOrg: "owner",
|
||||
expectedRepo: "repo",
|
||||
description: "Handles trailing slash",
|
||||
},
|
||||
{
|
||||
name: "url with query parameters",
|
||||
remoteURL: "https://github.com/owner/repo?param=value",
|
||||
expectedOrg: "owner",
|
||||
expectedRepo: "repo?param=value", // Regex doesn't strip query params
|
||||
description: "Query parameters are not stripped by regex",
|
||||
},
|
||||
{
|
||||
name: "malformed ssh url",
|
||||
remoteURL: "git@github.com/owner/repo.git",
|
||||
expectedOrg: "owner",
|
||||
expectedRepo: "repo", // Actually matches the pattern
|
||||
description: "Malformed SSH URL still matches pattern",
|
||||
},
|
||||
{
|
||||
name: "url with username",
|
||||
remoteURL: "https://user@github.com/owner/repo.git",
|
||||
expectedOrg: "owner",
|
||||
expectedRepo: "repo",
|
||||
description: "Handles URL with username",
|
||||
},
|
||||
{
|
||||
name: "github enterprise url",
|
||||
remoteURL: "https://github.company.com/owner/repo.git",
|
||||
expectedOrg: "",
|
||||
expectedRepo: "",
|
||||
description: "GitHub Enterprise URLs return empty (not github.com)",
|
||||
},
|
||||
{
|
||||
name: "short ssh format",
|
||||
remoteURL: "github.com:owner/repo.git",
|
||||
expectedOrg: "owner",
|
||||
expectedRepo: "repo", // Actually matches the pattern with ':'
|
||||
description: "Short SSH format matches the regex pattern",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
org, repo := parseGitHubURL(tt.remoteURL)
|
||||
|
||||
testutil.AssertEqual(t, tt.expectedOrg, org)
|
||||
testutil.AssertEqual(t, tt.expectedRepo, repo)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
126
internal/git/detector_test_helper.go
Normal file
126
internal/git/detector_test_helper.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
// gitTestCase defines the configuration for a git repository test case.
|
||||
type gitTestCase struct {
|
||||
name string
|
||||
configContent string
|
||||
expectedOrg string
|
||||
expectedRepo string
|
||||
expectedBranch string
|
||||
expectedURL string
|
||||
}
|
||||
|
||||
// createGitRepoTestCase creates a test table entry for git repository detection tests.
|
||||
// setupGitTestRepo creates a test git directory with the specified config content.
|
||||
// This helper is used by multiple test case creators to eliminate duplicate setup logic.
|
||||
func setupGitTestRepo(t *testing.T, tmpDir, configContent string) string {
|
||||
t.Helper()
|
||||
gitDir := testutil.SetupGitDirectory(t, tmpDir)
|
||||
configPath := filepath.Join(gitDir, "config")
|
||||
testutil.WriteTestFile(t, configPath, configContent)
|
||||
|
||||
return tmpDir
|
||||
}
|
||||
|
||||
// This helper reduces duplication by standardizing the setup and assertion patterns
|
||||
// for git repository test cases.
|
||||
func createGitRepoTestCase(tc gitTestCase) struct {
|
||||
name string
|
||||
setupFunc func(t *testing.T, tmpDir string) string
|
||||
checkFunc func(t *testing.T, info *RepoInfo)
|
||||
} {
|
||||
return struct {
|
||||
name string
|
||||
setupFunc func(t *testing.T, tmpDir string) string
|
||||
checkFunc func(t *testing.T, info *RepoInfo)
|
||||
}{
|
||||
name: tc.name,
|
||||
setupFunc: func(t *testing.T, tmpDir string) string {
|
||||
t.Helper()
|
||||
|
||||
return setupGitTestRepo(t, tmpDir, tc.configContent)
|
||||
},
|
||||
checkFunc: func(t *testing.T, info *RepoInfo) {
|
||||
t.Helper()
|
||||
testutil.AssertEqual(t, tc.expectedOrg, info.Organization)
|
||||
testutil.AssertEqual(t, tc.expectedRepo, info.Repository)
|
||||
if tc.expectedBranch != "" {
|
||||
testutil.AssertEqual(t, tc.expectedBranch, info.DefaultBranch)
|
||||
}
|
||||
if tc.expectedURL != "" {
|
||||
testutil.AssertEqual(t, tc.expectedURL, info.RemoteURL)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// gitURLTestCase defines the configuration for git remote URL test cases.
|
||||
type gitURLTestCase struct {
|
||||
name string
|
||||
configContent string
|
||||
expectError bool
|
||||
expectedURL string
|
||||
}
|
||||
|
||||
// createGitURLTestCase creates a test table entry for git remote URL detection tests.
|
||||
// This helper reduces duplication by standardizing the setup pattern for URL tests.
|
||||
func createGitURLTestCase(tc gitURLTestCase) struct {
|
||||
name string
|
||||
setupFunc func(t *testing.T, tmpDir string) string
|
||||
expectError bool
|
||||
expectedURL string
|
||||
} {
|
||||
return struct {
|
||||
name string
|
||||
setupFunc func(t *testing.T, tmpDir string) string
|
||||
expectError bool
|
||||
expectedURL string
|
||||
}{
|
||||
name: tc.name,
|
||||
setupFunc: func(t *testing.T, tmpDir string) string {
|
||||
t.Helper()
|
||||
|
||||
return setupGitTestRepo(t, tmpDir, tc.configContent)
|
||||
},
|
||||
expectError: tc.expectError,
|
||||
expectedURL: tc.expectedURL,
|
||||
}
|
||||
}
|
||||
|
||||
// defaultBranchTestCase defines the configuration for default branch detection tests.
|
||||
type defaultBranchTestCase struct {
|
||||
name string
|
||||
branch string
|
||||
expectedBranch string
|
||||
}
|
||||
|
||||
// createDefaultBranchTestCase creates a test table entry for default branch tests.
|
||||
// This helper reduces duplication for tests that set up git repos with different branches.
|
||||
func createDefaultBranchTestCase(tc defaultBranchTestCase) struct {
|
||||
name string
|
||||
setupFunc func(t *testing.T, tmpDir string) string
|
||||
expectedBranch string
|
||||
} {
|
||||
return struct {
|
||||
name string
|
||||
setupFunc func(t *testing.T, tmpDir string) string
|
||||
expectedBranch string
|
||||
}{
|
||||
name: tc.name,
|
||||
setupFunc: func(t *testing.T, tmpDir string) string {
|
||||
t.Helper()
|
||||
gitDir := testutil.SetupGitDirectory(t, tmpDir)
|
||||
testutil.CreateGitConfigWithRemote(t, gitDir, testutil.TestURLGitHubUserRepo, tc.branch)
|
||||
|
||||
return tmpDir
|
||||
},
|
||||
expectedBranch: tc.expectedBranch,
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ func TestCreateAnalyzerOrExit(t *testing.T) {
|
||||
// In a real-world scenario, we might refactor to return errors instead
|
||||
}
|
||||
|
||||
func TestCreateAnalyzer_Integration(t *testing.T) {
|
||||
func TestCreateAnalyzerIntegration(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Test integration with actual generator functionality
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -28,9 +27,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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -119,14 +116,11 @@ func TestFindGitRepoRoot(t *testing.T) {
|
||||
setupFunc: func(t *testing.T, tmpDir string) string {
|
||||
t.Helper()
|
||||
// Create .git directory
|
||||
gitDir := filepath.Join(tmpDir, ".git")
|
||||
err := os.MkdirAll(gitDir, 0750) // #nosec G301 -- test directory permissions
|
||||
testutil.AssertNoError(t, err)
|
||||
_ = testutil.SetupGitDirectory(t, tmpDir)
|
||||
|
||||
// Create subdirectory to test from
|
||||
subDir := filepath.Join(tmpDir, "subdir")
|
||||
err = os.MkdirAll(subDir, 0750) // #nosec G301 -- test directory permissions
|
||||
testutil.AssertNoError(t, err)
|
||||
testutil.CreateTestDir(t, subDir)
|
||||
|
||||
return subDir
|
||||
},
|
||||
@@ -145,14 +139,11 @@ func TestFindGitRepoRoot(t *testing.T) {
|
||||
setupFunc: func(t *testing.T, tmpDir string) string {
|
||||
t.Helper()
|
||||
// Create .git directory at root
|
||||
gitDir := filepath.Join(tmpDir, ".git")
|
||||
err := os.MkdirAll(gitDir, 0750) // #nosec G301 -- test directory permissions
|
||||
testutil.AssertNoError(t, err)
|
||||
_ = testutil.SetupGitDirectory(t, tmpDir)
|
||||
|
||||
// Create deeply nested subdirectory
|
||||
nestedDir := filepath.Join(tmpDir, "a", "b", "c")
|
||||
err = os.MkdirAll(nestedDir, 0750) // #nosec G301 -- test directory permissions
|
||||
testutil.AssertNoError(t, err)
|
||||
testutil.CreateTestDir(t, nestedDir)
|
||||
|
||||
return nestedDir
|
||||
},
|
||||
@@ -243,9 +234,7 @@ func TestGetGitRepoRootAndInfo(t *testing.T) {
|
||||
func setupCompleteGitRepo(t *testing.T, tmpDir string) string {
|
||||
t.Helper()
|
||||
// Create .git directory
|
||||
gitDir := filepath.Join(tmpDir, ".git")
|
||||
err := os.MkdirAll(gitDir, 0750) // #nosec G301 -- test directory permissions
|
||||
testutil.AssertNoError(t, err)
|
||||
gitDir := testutil.SetupGitDirectory(t, tmpDir)
|
||||
|
||||
// Create a basic git config to make it look like a real repo
|
||||
configContent := `[core]
|
||||
@@ -260,8 +249,7 @@ func setupCompleteGitRepo(t *testing.T, tmpDir string) string {
|
||||
merge = refs/heads/main
|
||||
`
|
||||
configPath := filepath.Join(gitDir, "config")
|
||||
err = os.WriteFile(configPath, []byte(configContent), 0600) // #nosec G306 -- test file permissions
|
||||
testutil.AssertNoError(t, err)
|
||||
testutil.WriteTestFile(t, configPath, configContent)
|
||||
|
||||
return tmpDir
|
||||
}
|
||||
@@ -269,9 +257,7 @@ func setupCompleteGitRepo(t *testing.T, tmpDir string) string {
|
||||
func setupMinimalGitRepo(t *testing.T, tmpDir string) string {
|
||||
t.Helper()
|
||||
// Create .git directory but with minimal content
|
||||
gitDir := filepath.Join(tmpDir, ".git")
|
||||
err := os.MkdirAll(gitDir, 0750) // #nosec G301 -- test directory permissions
|
||||
testutil.AssertNoError(t, err)
|
||||
_ = testutil.SetupGitDirectory(t, tmpDir)
|
||||
|
||||
return tmpDir
|
||||
}
|
||||
@@ -284,10 +270,10 @@ func verifyRepoRoot(t *testing.T, repoRoot, tmpDir string) {
|
||||
}
|
||||
|
||||
// Test error handling in GetGitRepoRootAndInfo.
|
||||
func TestGetGitRepoRootAndInfo_ErrorHandling(t *testing.T) {
|
||||
func TestGetGitRepoRootAndInfoErrorHandling(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("nonexistent directory", func(t *testing.T) {
|
||||
t.Run(testutil.TestCaseNameNonexistentDir, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
nonexistentPath := "/this/path/should/not/exist"
|
||||
|
||||
@@ -10,7 +10,7 @@ type HTMLWriter struct {
|
||||
Footer string
|
||||
}
|
||||
|
||||
func (w *HTMLWriter) Write(output string, path string) error {
|
||||
func (w *HTMLWriter) Write(output, path string) error {
|
||||
f, err := os.Create(path) // #nosec G304 -- path from function parameter
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
318
internal/html_test.go
Normal file
318
internal/html_test.go
Normal file
@@ -0,0 +1,318 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
// mustSafePath validates that a path is safe (no "..", matches cleaned version).
|
||||
// Fails the test if path is unsafe.
|
||||
func mustSafePath(t *testing.T, p string) string {
|
||||
t.Helper()
|
||||
cleaned := filepath.Clean(p)
|
||||
if cleaned != p {
|
||||
t.Fatalf("path %q does not match cleaned path %q", p, cleaned)
|
||||
}
|
||||
if strings.Contains(cleaned, "..") {
|
||||
t.Fatalf("path %q contains unsafe .. component", p)
|
||||
}
|
||||
|
||||
return cleaned
|
||||
}
|
||||
|
||||
// TestHTMLWriterWrite tests the HTMLWriter.Write function.
|
||||
func TestHTMLWriterWrite(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
header string
|
||||
footer string
|
||||
content string
|
||||
wantString string
|
||||
}{
|
||||
{
|
||||
name: "no header or footer",
|
||||
header: "",
|
||||
footer: "",
|
||||
content: "<h1>Test Content</h1>",
|
||||
wantString: "<h1>Test Content</h1>",
|
||||
},
|
||||
{
|
||||
name: "with header only",
|
||||
header: "<!DOCTYPE html>\n<html>\n",
|
||||
footer: "",
|
||||
content: "<body>Content</body>",
|
||||
wantString: "<!DOCTYPE html>\n<html>\n<body>Content</body>",
|
||||
},
|
||||
{
|
||||
name: "with footer only",
|
||||
header: "",
|
||||
footer: testutil.TestHTMLClosingTag,
|
||||
content: "<body>Content</body>",
|
||||
wantString: "<body>Content</body>\n</html>",
|
||||
},
|
||||
{
|
||||
name: "with both header and footer",
|
||||
header: "<!DOCTYPE html>\n<html>\n<body>\n",
|
||||
footer: "\n</body>\n</html>",
|
||||
content: "<h1>Main Content</h1>",
|
||||
wantString: "<!DOCTYPE html>\n<html>\n<body>\n<h1>Main Content</h1>\n</body>\n</html>",
|
||||
},
|
||||
{
|
||||
name: "empty content",
|
||||
header: "<header>",
|
||||
footer: "</footer>",
|
||||
content: "",
|
||||
wantString: "<header></footer>",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
outputPath := filepath.Join(tmpDir, "test.html")
|
||||
|
||||
writer := &HTMLWriter{
|
||||
Header: tt.header,
|
||||
Footer: tt.footer,
|
||||
}
|
||||
|
||||
err := writer.Write(tt.content, outputPath)
|
||||
if err != nil {
|
||||
t.Errorf("Write() unexpected error = %v", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Read the file and verify content
|
||||
content, err := os.ReadFile(mustSafePath(t, outputPath))
|
||||
if err != nil {
|
||||
t.Fatalf(testutil.TestMsgFailedToReadOutput, err)
|
||||
}
|
||||
|
||||
got := string(content)
|
||||
if got != tt.wantString {
|
||||
t.Errorf("Write() content = %q, want %q", got, tt.wantString)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTMLWriterWriteErrorPaths tests error handling in HTMLWriter.Write.
|
||||
func TestHTMLWriterWriteErrorPaths(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
setupPath func(t *testing.T) string
|
||||
skipReason string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "invalid path - directory doesn't exist",
|
||||
setupPath: func(t *testing.T) string {
|
||||
t.Helper()
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
return filepath.Join(tmpDir, "nonexistent", "file.html")
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "permission denied - unwritable directory",
|
||||
setupPath: func(t *testing.T) string {
|
||||
t.Helper()
|
||||
// Skip on Windows (chmod behavior differs)
|
||||
if runtime.GOOS == "windows" {
|
||||
return ""
|
||||
}
|
||||
// Skip if running as root (can write anywhere)
|
||||
if os.Geteuid() == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
restrictedDir := filepath.Join(tmpDir, "restricted")
|
||||
if err := os.Mkdir(restrictedDir, 0700); err != nil {
|
||||
t.Fatalf("failed to create restricted dir: %v", err)
|
||||
}
|
||||
|
||||
// Make directory unwritable
|
||||
if err := os.Chmod(restrictedDir, 0000); err != nil {
|
||||
t.Fatalf("failed to chmod: %v", err)
|
||||
}
|
||||
|
||||
// Restore permissions in cleanup
|
||||
t.Cleanup(func() {
|
||||
_ = os.Chmod(restrictedDir, 0700) // #nosec G302 -- directory needs exec bit for cleanup
|
||||
})
|
||||
|
||||
return filepath.Join(restrictedDir, "file.html")
|
||||
},
|
||||
skipReason: "skipped on Windows or when running as root",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := tt.setupPath(t)
|
||||
if path == "" {
|
||||
t.Skip(tt.skipReason)
|
||||
}
|
||||
|
||||
writer := &HTMLWriter{
|
||||
Header: "<header>",
|
||||
Footer: "</footer>",
|
||||
}
|
||||
|
||||
err := writer.Write("<content>", path)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Write() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTMLWriterWriteLargeContent tests writing large HTML content.
|
||||
func TestHTMLWriterWriteLargeContent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
outputPath := filepath.Join(tmpDir, "large.html")
|
||||
|
||||
// Create large content (10MB)
|
||||
largeContent := strings.Repeat("<p>Test content line</p>\n", 500000)
|
||||
|
||||
writer := &HTMLWriter{
|
||||
Header: "<!DOCTYPE html>\n",
|
||||
Footer: testutil.TestHTMLClosingTag,
|
||||
}
|
||||
|
||||
err := writer.Write(largeContent, outputPath)
|
||||
if err != nil {
|
||||
t.Errorf("Write() failed for large content: %v", err)
|
||||
}
|
||||
|
||||
// Verify file was created and has correct size
|
||||
info, err := os.Stat(outputPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to stat output file: %v", err)
|
||||
}
|
||||
|
||||
expectedSize := len("<!DOCTYPE html>\n") + len(largeContent) + len(testutil.TestHTMLClosingTag)
|
||||
if int(info.Size()) != expectedSize {
|
||||
t.Errorf("File size = %d, want %d", info.Size(), expectedSize)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTMLWriterWriteSpecialCharacters tests writing HTML with special characters.
|
||||
func TestHTMLWriterWriteSpecialCharacters(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
outputPath := filepath.Join(tmpDir, "special.html")
|
||||
|
||||
// Content with HTML entities and special characters
|
||||
content := `<div><script>alert("test")</script></div>
|
||||
<p>Special chars: & " ' < ></p>
|
||||
<p>Unicode: 你好 مرحبا привет 🎉</p>`
|
||||
|
||||
writer := &HTMLWriter{}
|
||||
err := writer.Write(content, outputPath)
|
||||
if err != nil {
|
||||
t.Errorf("Write() failed for special characters: %v", err)
|
||||
}
|
||||
|
||||
// Verify content was written correctly
|
||||
readContent, err := os.ReadFile(mustSafePath(t, outputPath))
|
||||
if err != nil {
|
||||
t.Fatalf(testutil.TestMsgFailedToReadOutput, err)
|
||||
}
|
||||
|
||||
if string(readContent) != content {
|
||||
t.Errorf("Content mismatch for special characters")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTMLWriterWriteOverwrite tests overwriting an existing file.
|
||||
func TestHTMLWriterWriteOverwrite(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
outputPath := filepath.Join(tmpDir, "overwrite.html")
|
||||
|
||||
// Write initial content
|
||||
writer := &HTMLWriter{}
|
||||
err := writer.Write("Initial content", outputPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Initial write failed: %v", err)
|
||||
}
|
||||
|
||||
// Overwrite with new content
|
||||
err = writer.Write(testutil.TestHTMLNewContent, outputPath)
|
||||
if err != nil {
|
||||
t.Errorf("Overwrite failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify new content
|
||||
content, err := os.ReadFile(mustSafePath(t, outputPath))
|
||||
if err != nil {
|
||||
t.Fatalf(testutil.TestMsgFailedToReadOutput, err)
|
||||
}
|
||||
|
||||
if string(content) != testutil.TestHTMLNewContent {
|
||||
t.Errorf("Content = %q, want %q", string(content), testutil.TestHTMLNewContent)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTMLWriterWriteEmptyPath tests writing to an empty path.
|
||||
func TestHTMLWriterWriteEmptyPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
writer := &HTMLWriter{}
|
||||
err := writer.Write("content", "")
|
||||
|
||||
// Empty path should cause an error
|
||||
if err == nil {
|
||||
t.Error("Write() with empty path should return error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTMLWriterWriteValidPath tests writing to a valid nested path.
|
||||
func TestHTMLWriterWriteValidPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create nested directory structure
|
||||
nestedDir := filepath.Join(tmpDir, "nested", "directory")
|
||||
testutil.CreateTestDir(t, nestedDir)
|
||||
|
||||
outputPath := filepath.Join(nestedDir, "nested.html")
|
||||
|
||||
writer := &HTMLWriter{
|
||||
Header: "<html>",
|
||||
Footer: "</html>",
|
||||
}
|
||||
|
||||
err := writer.Write("<body>Nested content</body>", outputPath)
|
||||
if err != nil {
|
||||
t.Errorf("Write() to nested path failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify file exists
|
||||
if _, err := os.Stat(outputPath); os.IsNotExist(err) {
|
||||
t.Error("File was not created in nested path")
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -37,8 +38,8 @@ type ProgressReporter interface {
|
||||
Progress(format string, args ...any)
|
||||
}
|
||||
|
||||
// OutputConfig provides configuration queries for output behavior.
|
||||
type OutputConfig interface {
|
||||
// QuietChecker provides queries for quiet mode behavior.
|
||||
type QuietChecker interface {
|
||||
IsQuiet() bool
|
||||
}
|
||||
|
||||
@@ -60,7 +61,7 @@ type ProgressManager interface {
|
||||
type OutputWriter interface {
|
||||
MessageLogger
|
||||
ProgressReporter
|
||||
OutputConfig
|
||||
QuietChecker
|
||||
}
|
||||
|
||||
// ErrorManager combines error reporting and formatting for comprehensive error handling.
|
||||
@@ -76,5 +77,5 @@ type CompleteOutput interface {
|
||||
ErrorReporter
|
||||
ErrorFormatter
|
||||
ProgressReporter
|
||||
OutputConfig
|
||||
QuietChecker
|
||||
}
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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"
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
// MockMessageLogger implements MessageLogger for testing.
|
||||
@@ -21,28 +24,33 @@ type MockMessageLogger struct {
|
||||
}
|
||||
|
||||
func (m *MockMessageLogger) Info(format string, args ...any) {
|
||||
m.InfoCalls = append(m.InfoCalls, formatMessage(format, args...))
|
||||
m.recordCall(&m.InfoCalls, format, args...)
|
||||
}
|
||||
|
||||
func (m *MockMessageLogger) Success(format string, args ...any) {
|
||||
m.SuccessCalls = append(m.SuccessCalls, formatMessage(format, args...))
|
||||
m.recordCall(&m.SuccessCalls, format, args...)
|
||||
}
|
||||
|
||||
func (m *MockMessageLogger) Warning(format string, args ...any) {
|
||||
m.WarningCalls = append(m.WarningCalls, formatMessage(format, args...))
|
||||
m.recordCall(&m.WarningCalls, format, args...)
|
||||
}
|
||||
|
||||
func (m *MockMessageLogger) Bold(format string, args ...any) {
|
||||
m.BoldCalls = append(m.BoldCalls, formatMessage(format, args...))
|
||||
m.recordCall(&m.BoldCalls, format, args...)
|
||||
}
|
||||
|
||||
func (m *MockMessageLogger) Printf(format string, args ...any) {
|
||||
m.PrintfCalls = append(m.PrintfCalls, formatMessage(format, args...))
|
||||
m.recordCall(&m.PrintfCalls, format, args...)
|
||||
}
|
||||
|
||||
func (m *MockMessageLogger) Fprintf(_ *os.File, format string, args ...any) {
|
||||
// For testing, just track the formatted message
|
||||
m.PrintfCalls = append(m.PrintfCalls, formatMessage(format, args...))
|
||||
m.recordCall(&m.PrintfCalls, format, args...)
|
||||
}
|
||||
|
||||
// recordCall is a helper to reduce duplication in mock methods.
|
||||
func (m *MockMessageLogger) recordCall(callSlice *[]string, format string, args ...any) {
|
||||
*callSlice = append(*callSlice, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// MockErrorReporter implements ErrorReporter for testing.
|
||||
@@ -54,16 +62,16 @@ type MockErrorReporter struct {
|
||||
}
|
||||
|
||||
func (m *MockErrorReporter) Error(format string, args ...any) {
|
||||
m.ErrorCalls = append(m.ErrorCalls, formatMessage(format, args...))
|
||||
m.recordCall(&m.ErrorCalls, 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)
|
||||
}
|
||||
|
||||
@@ -71,21 +79,31 @@ func (m *MockErrorReporter) ErrorWithSimpleFix(message, suggestion string) {
|
||||
m.ErrorWithSimpleFixCalls = append(m.ErrorWithSimpleFixCalls, message+": "+suggestion)
|
||||
}
|
||||
|
||||
// recordCall is a helper to reduce duplication in mock methods.
|
||||
func (m *MockErrorReporter) recordCall(callSlice *[]string, format string, args ...any) {
|
||||
*callSlice = append(*callSlice, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// MockProgressReporter implements ProgressReporter for testing.
|
||||
type MockProgressReporter struct {
|
||||
ProgressCalls []string
|
||||
}
|
||||
|
||||
func (m *MockProgressReporter) Progress(format string, args ...any) {
|
||||
m.ProgressCalls = append(m.ProgressCalls, formatMessage(format, args...))
|
||||
m.recordCall(&m.ProgressCalls, format, args...)
|
||||
}
|
||||
|
||||
// MockOutputConfig implements OutputConfig for testing.
|
||||
type MockOutputConfig struct {
|
||||
// recordCall is a helper to reduce duplication in mock methods.
|
||||
func (m *MockProgressReporter) recordCall(callSlice *[]string, format string, args ...any) {
|
||||
*callSlice = append(*callSlice, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// MockQuietChecker implements QuietChecker for testing.
|
||||
type MockQuietChecker struct {
|
||||
QuietMode bool
|
||||
}
|
||||
|
||||
func (m *MockOutputConfig) IsQuiet() bool {
|
||||
func (m *MockQuietChecker) IsQuiet() bool {
|
||||
return m.QuietMode
|
||||
}
|
||||
|
||||
@@ -100,7 +118,7 @@ type MockProgressManager struct {
|
||||
}
|
||||
|
||||
func (m *MockProgressManager) CreateProgressBar(description string, total int) *progressbar.ProgressBar {
|
||||
m.CreateProgressBarCalls = append(m.CreateProgressBarCalls, formatMessage("%s (total: %d)", description, total))
|
||||
m.CreateProgressBarCalls = append(m.CreateProgressBarCalls, fmt.Sprintf("%s (total: %d)", description, total))
|
||||
|
||||
return nil // Return nil for mock to avoid actual progress bar
|
||||
}
|
||||
@@ -108,7 +126,7 @@ func (m *MockProgressManager) CreateProgressBar(description string, total int) *
|
||||
func (m *MockProgressManager) CreateProgressBarForFiles(description string, files []string) *progressbar.ProgressBar {
|
||||
m.CreateProgressBarForFilesCalls = append(
|
||||
m.CreateProgressBarForFilesCalls,
|
||||
formatMessage("%s (files: %d)", description, len(files)),
|
||||
fmt.Sprintf("%s (files: %d)", description, len(files)),
|
||||
)
|
||||
|
||||
return nil // Return nil for mock to avoid actual progress bar
|
||||
@@ -133,7 +151,7 @@ func (m *MockProgressManager) ProcessWithProgressBar(
|
||||
) {
|
||||
m.ProcessWithProgressBarCalls = append(
|
||||
m.ProcessWithProgressBarCalls,
|
||||
formatMessage("%s (items: %d)", description, len(items)),
|
||||
fmt.Sprintf("%s (items: %d)", description, len(items)),
|
||||
)
|
||||
// Execute the process function for each item
|
||||
for _, item := range items {
|
||||
@@ -141,67 +159,18 @@ func (m *MockProgressManager) ProcessWithProgressBar(
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to format messages consistently.
|
||||
func formatMessage(format string, args ...any) string {
|
||||
if len(args) == 0 {
|
||||
return format
|
||||
}
|
||||
// Simple formatting for test purposes
|
||||
result := format
|
||||
for _, arg := range args {
|
||||
result = strings.Replace(result, "%s", toString(arg), 1)
|
||||
result = strings.Replace(result, "%d", toString(arg), 1)
|
||||
result = strings.Replace(result, "%v", toString(arg), 1)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func toString(v any) string {
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
return val
|
||||
case int:
|
||||
return formatInt(val)
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func formatInt(i int) string {
|
||||
// Simple int to string conversion for testing
|
||||
if i == 0 {
|
||||
return "0"
|
||||
}
|
||||
result := ""
|
||||
negative := i < 0
|
||||
if negative {
|
||||
i = -i
|
||||
}
|
||||
for i > 0 {
|
||||
digit := i % 10
|
||||
result = string(rune('0'+digit)) + result
|
||||
i /= 10
|
||||
}
|
||||
if negative {
|
||||
result = "-" + result
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Test that demonstrates improved testability with focused interfaces.
|
||||
func TestFocusedInterfaces_SimpleLogger(t *testing.T) {
|
||||
func TestFocusedInterfacesSimpleLogger(t *testing.T) {
|
||||
t.Parallel()
|
||||
mockLogger := &MockMessageLogger{}
|
||||
simpleLogger := NewSimpleLogger(mockLogger)
|
||||
|
||||
// Test successful operation
|
||||
simpleLogger.LogOperation("test-operation", true)
|
||||
simpleLogger.LogOperation(testutil.TestOperationName, true)
|
||||
|
||||
// Verify the expected calls were made
|
||||
if len(mockLogger.InfoCalls) != 1 {
|
||||
t.Errorf("expected 1 Info call, got %d", len(mockLogger.InfoCalls))
|
||||
t.Errorf(testutil.TestMsgExpected1InfoCall, len(mockLogger.InfoCalls))
|
||||
}
|
||||
if len(mockLogger.SuccessCalls) != 1 {
|
||||
t.Errorf("expected 1 Success call, got %d", len(mockLogger.SuccessCalls))
|
||||
@@ -211,16 +180,20 @@ func TestFocusedInterfaces_SimpleLogger(t *testing.T) {
|
||||
}
|
||||
|
||||
// Check message content
|
||||
if !strings.Contains(mockLogger.InfoCalls[0], "test-operation") {
|
||||
t.Errorf("expected Info call to contain 'test-operation', got: %s", mockLogger.InfoCalls[0])
|
||||
if !strings.Contains(mockLogger.InfoCalls[0], testutil.TestOperationName) {
|
||||
t.Errorf("expected Info call to contain '%s', got: %s", testutil.TestOperationName, mockLogger.InfoCalls[0])
|
||||
}
|
||||
|
||||
if !strings.Contains(mockLogger.SuccessCalls[0], "test-operation") {
|
||||
t.Errorf("expected Success call to contain 'test-operation', got: %s", mockLogger.SuccessCalls[0])
|
||||
if !strings.Contains(mockLogger.SuccessCalls[0], testutil.TestOperationName) {
|
||||
t.Errorf(
|
||||
"expected Success call to contain '%s', got: %s",
|
||||
testutil.TestOperationName,
|
||||
mockLogger.SuccessCalls[0],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFocusedInterfaces_SimpleLogger_WithFailure(t *testing.T) {
|
||||
func TestFocusedInterfacesSimpleLoggerWithFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
mockLogger := &MockMessageLogger{}
|
||||
simpleLogger := NewSimpleLogger(mockLogger)
|
||||
@@ -230,7 +203,7 @@ func TestFocusedInterfaces_SimpleLogger_WithFailure(t *testing.T) {
|
||||
|
||||
// Verify the expected calls were made
|
||||
if len(mockLogger.InfoCalls) != 1 {
|
||||
t.Errorf("expected 1 Info call, got %d", len(mockLogger.InfoCalls))
|
||||
t.Errorf(testutil.TestMsgExpected1InfoCall, len(mockLogger.InfoCalls))
|
||||
}
|
||||
if len(mockLogger.SuccessCalls) != 0 {
|
||||
t.Errorf("expected 0 Success calls, got %d", len(mockLogger.SuccessCalls))
|
||||
@@ -240,10 +213,10 @@ func TestFocusedInterfaces_SimpleLogger_WithFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFocusedInterfaces_ErrorManager(t *testing.T) {
|
||||
func TestFocusedInterfacesErrorManager(t *testing.T) {
|
||||
t.Parallel()
|
||||
mockReporter := &MockErrorReporter{}
|
||||
mockFormatter := &MockErrorFormatter{}
|
||||
mockFormatter := &errorFormatterWrapper{&testutil.ErrorFormatterMock{}}
|
||||
mockManager := &mockErrorManager{
|
||||
reporter: mockReporter,
|
||||
formatter: mockFormatter,
|
||||
@@ -263,7 +236,7 @@ func TestFocusedInterfaces_ErrorManager(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFocusedInterfaces_TaskProgress(t *testing.T) {
|
||||
func TestFocusedInterfacesTaskProgress(t *testing.T) {
|
||||
t.Parallel()
|
||||
mockReporter := &MockProgressReporter{}
|
||||
taskProgress := NewTaskProgress(mockReporter)
|
||||
@@ -281,7 +254,7 @@ func TestFocusedInterfaces_TaskProgress(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFocusedInterfaces_ConfigAwareComponent(t *testing.T) {
|
||||
func TestFocusedInterfacesConfigAwareComponent(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -303,7 +276,7 @@ func TestFocusedInterfaces_ConfigAwareComponent(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
mockConfig := &MockOutputConfig{QuietMode: tt.quietMode}
|
||||
mockConfig := &MockQuietChecker{QuietMode: tt.quietMode}
|
||||
component := NewConfigAwareComponent(mockConfig)
|
||||
|
||||
result := component.ShouldOutput()
|
||||
@@ -315,12 +288,12 @@ func TestFocusedInterfaces_ConfigAwareComponent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFocusedInterfaces_CompositeOutputWriter(t *testing.T) {
|
||||
func TestFocusedInterfacesCompositeOutputWriter(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Create a composite mock that implements OutputWriter
|
||||
mockLogger := &MockMessageLogger{}
|
||||
mockProgress := &MockProgressReporter{}
|
||||
mockConfig := &MockOutputConfig{QuietMode: false}
|
||||
mockConfig := &MockQuietChecker{QuietMode: false}
|
||||
|
||||
compositeWriter := &CompositeOutputWriter{
|
||||
writer: &mockOutputWriter{
|
||||
@@ -336,7 +309,7 @@ func TestFocusedInterfaces_CompositeOutputWriter(t *testing.T) {
|
||||
// Verify that the composite writer uses both message logging and progress reporting
|
||||
// Should have called Info and Success for overall status
|
||||
if len(mockLogger.InfoCalls) != 1 {
|
||||
t.Errorf("expected 1 Info call, got %d", len(mockLogger.InfoCalls))
|
||||
t.Errorf(testutil.TestMsgExpected1InfoCall, len(mockLogger.InfoCalls))
|
||||
}
|
||||
if len(mockLogger.SuccessCalls) != 1 {
|
||||
t.Errorf("expected 1 Success call, got %d", len(mockLogger.SuccessCalls))
|
||||
@@ -348,15 +321,15 @@ func TestFocusedInterfaces_CompositeOutputWriter(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFocusedInterfaces_GeneratorWithDependencyInjection(t *testing.T) {
|
||||
func TestFocusedInterfacesGeneratorWithDependencyInjection(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Create focused mocks
|
||||
mockOutput := &mockCompleteOutput{
|
||||
logger: &MockMessageLogger{},
|
||||
reporter: &MockErrorReporter{},
|
||||
formatter: &MockErrorFormatter{},
|
||||
formatter: &errorFormatterWrapper{&testutil.ErrorFormatterMock{}},
|
||||
progress: &MockProgressReporter{},
|
||||
config: &MockOutputConfig{QuietMode: false},
|
||||
config: &MockQuietChecker{QuietMode: false},
|
||||
}
|
||||
mockProgress := &MockProgressManager{}
|
||||
|
||||
@@ -393,7 +366,7 @@ type mockCompleteOutput struct {
|
||||
reporter ErrorReporter
|
||||
formatter ErrorFormatter
|
||||
progress ProgressReporter
|
||||
config OutputConfig
|
||||
config QuietChecker
|
||||
}
|
||||
|
||||
func (m *mockCompleteOutput) Info(format string, args ...any) { m.logger.Info(format, args...) }
|
||||
@@ -405,16 +378,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) {
|
||||
@@ -425,7 +398,7 @@ func (m *mockCompleteOutput) IsQuiet() bool { return m.config.IsQuiet() }
|
||||
type mockOutputWriter struct {
|
||||
logger MessageLogger
|
||||
reporter ProgressReporter
|
||||
config OutputConfig
|
||||
config QuietChecker
|
||||
}
|
||||
|
||||
func (m *mockOutputWriter) Info(format string, args ...any) { m.logger.Info(format, args...) }
|
||||
@@ -439,20 +412,14 @@ func (m *mockOutputWriter) Fprintf(w *os.File, format string, args ...any) {
|
||||
func (m *mockOutputWriter) Progress(format string, args ...any) { m.reporter.Progress(format, args...) }
|
||||
func (m *mockOutputWriter) IsQuiet() bool { return m.config.IsQuiet() }
|
||||
|
||||
// MockErrorFormatter implements ErrorFormatter for testing.
|
||||
type MockErrorFormatter struct {
|
||||
FormatContextualErrorCalls []string
|
||||
// errorFormatterWrapper wraps testutil.ErrorFormatterMock to implement ErrorFormatter interface.
|
||||
type errorFormatterWrapper struct {
|
||||
*testutil.ErrorFormatterMock
|
||||
}
|
||||
|
||||
func (m *MockErrorFormatter) FormatContextualError(err *errors.ContextualError) string {
|
||||
if err != nil {
|
||||
formatted := err.Error()
|
||||
m.FormatContextualErrorCalls = append(m.FormatContextualErrorCalls, formatted)
|
||||
|
||||
return formatted
|
||||
}
|
||||
|
||||
return ""
|
||||
// FormatContextualError adapts the generic error interface to ContextualError.
|
||||
func (w *errorFormatterWrapper) FormatContextualError(err *apperrors.ContextualError) string {
|
||||
return w.ErrorFormatterMock.FormatContextualError(err)
|
||||
}
|
||||
|
||||
// mockErrorManager implements ErrorManager for testing.
|
||||
@@ -462,15 +429,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,7 +6,7 @@ import (
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
func TestParseActionYML_Valid(t *testing.T) {
|
||||
func TestParseActionYMLValid(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Create temporary action file using fixture
|
||||
actionPath := testutil.CreateTemporaryAction(t, "actions/javascript/simple.yml")
|
||||
@@ -25,7 +25,7 @@ func TestParseActionYML_Valid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseActionYML_MissingFile(t *testing.T) {
|
||||
func TestParseActionYMLMissingFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := ParseActionYML("notfound/action.yml")
|
||||
if err == nil {
|
||||
|
||||
@@ -21,7 +21,7 @@ func TestRenderReadme(t *testing.T) {
|
||||
"foo": {Description: "Foo input", Required: true},
|
||||
},
|
||||
}
|
||||
tmpl := filepath.Join(tmpDir, "templates", "readme.tmpl")
|
||||
tmpl := filepath.Join(tmpDir, "templates", testutil.TestTemplateReadme)
|
||||
opts := TemplateOptions{TemplatePath: tmpl, Format: "md"}
|
||||
out, err := RenderReadme(action, opts)
|
||||
if err != nil {
|
||||
|
||||
@@ -2,7 +2,7 @@ package internal
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateActionYML_Required(t *testing.T) {
|
||||
func TestValidateActionYMLRequired(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
a := &ActionYML{
|
||||
@@ -16,7 +16,7 @@ func TestValidateActionYML_Required(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateActionYML_Valid(t *testing.T) {
|
||||
func TestValidateActionYMLValid(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := &ActionYML{
|
||||
Name: "MyAction",
|
||||
|
||||
@@ -4,7 +4,10 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
)
|
||||
|
||||
// getVersion returns the current version - can be overridden at build time.
|
||||
@@ -37,6 +40,7 @@ type ActionYMLForJSON struct {
|
||||
Outputs map[string]ActionOutputForJSON `json:"outputs,omitempty"`
|
||||
Runs map[string]any `json:"runs"`
|
||||
Branding *BrandingForJSON `json:"branding,omitempty"`
|
||||
Permissions map[string]string `json:"permissions,omitempty"`
|
||||
}
|
||||
|
||||
// ActionInputForJSON represents an input parameter in JSON format.
|
||||
@@ -118,7 +122,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.
|
||||
@@ -215,6 +219,7 @@ func (jw *JSONWriter) convertToJSONOutput(action *ActionYML) *JSONOutput {
|
||||
Outputs: outputs,
|
||||
Runs: action.Runs,
|
||||
Branding: branding,
|
||||
Permissions: action.Permissions,
|
||||
},
|
||||
Documentation: DocumentationInfo{
|
||||
Title: action.Name,
|
||||
@@ -223,8 +228,8 @@ func (jw *JSONWriter) convertToJSONOutput(action *ActionYML) *JSONOutput {
|
||||
Badges: badges,
|
||||
Sections: sections,
|
||||
Links: map[string]string{
|
||||
"action.yml": "./action.yml",
|
||||
"repository": "https://github.com/your-org/" + action.Name,
|
||||
appconstants.ActionFileNameYML: "./" + appconstants.ActionFileNameYML,
|
||||
"repository": "https://github.com/your-org/" + action.Name,
|
||||
},
|
||||
},
|
||||
Examples: examples,
|
||||
@@ -244,6 +249,7 @@ func (jw *JSONWriter) generateBasicExample(action *ActionYML) string {
|
||||
|
||||
if len(action.Inputs) > 0 {
|
||||
example += "\n with:"
|
||||
var exampleSb247 strings.Builder
|
||||
for key, input := range action.Inputs {
|
||||
value := "value"
|
||||
if input.Default != nil {
|
||||
@@ -253,8 +259,9 @@ func (jw *JSONWriter) generateBasicExample(action *ActionYML) string {
|
||||
value = fmt.Sprintf("%v", input.Default)
|
||||
}
|
||||
}
|
||||
example += "\n " + key + ": \"" + value + "\""
|
||||
exampleSb247.WriteString("\n " + key + ": \"" + value + "\"")
|
||||
}
|
||||
example += exampleSb247.String()
|
||||
}
|
||||
|
||||
return example
|
||||
|
||||
@@ -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.
|
||||
@@ -23,7 +24,7 @@ var (
|
||||
_ ErrorReporter = (*ColoredOutput)(nil)
|
||||
_ ErrorFormatter = (*ColoredOutput)(nil)
|
||||
_ ProgressReporter = (*ColoredOutput)(nil)
|
||||
_ OutputConfig = (*ColoredOutput)(nil)
|
||||
_ QuietChecker = (*ColoredOutput)(nil)
|
||||
_ CompleteOutput = (*ColoredOutput)(nil)
|
||||
)
|
||||
|
||||
@@ -42,14 +43,7 @@ func (co *ColoredOutput) IsQuiet() bool {
|
||||
|
||||
// Success prints a success message in green.
|
||||
func (co *ColoredOutput) Success(format string, args ...any) {
|
||||
if co.Quiet {
|
||||
return
|
||||
}
|
||||
if co.NoColor {
|
||||
fmt.Printf("✅ "+format+"\n", args...)
|
||||
} else {
|
||||
color.Green("✅ "+format, args...)
|
||||
}
|
||||
co.printWithIcon("✅", format, color.Green, args...)
|
||||
}
|
||||
|
||||
// Error prints an error message in red to stderr.
|
||||
@@ -63,38 +57,17 @@ func (co *ColoredOutput) Error(format string, args ...any) {
|
||||
|
||||
// Warning prints a warning message in yellow.
|
||||
func (co *ColoredOutput) Warning(format string, args ...any) {
|
||||
if co.Quiet {
|
||||
return
|
||||
}
|
||||
if co.NoColor {
|
||||
fmt.Printf("⚠️ "+format+"\n", args...)
|
||||
} else {
|
||||
color.Yellow("⚠️ "+format, args...)
|
||||
}
|
||||
co.printWithIcon("⚠️ ", format, color.Yellow, args...)
|
||||
}
|
||||
|
||||
// Info prints an info message in blue.
|
||||
func (co *ColoredOutput) Info(format string, args ...any) {
|
||||
if co.Quiet {
|
||||
return
|
||||
}
|
||||
if co.NoColor {
|
||||
fmt.Printf("ℹ️ "+format+"\n", args...)
|
||||
} else {
|
||||
color.Blue("ℹ️ "+format, args...)
|
||||
}
|
||||
co.printWithIcon("ℹ️ ", format, color.Blue, args...)
|
||||
}
|
||||
|
||||
// Progress prints a progress message in cyan.
|
||||
func (co *ColoredOutput) Progress(format string, args ...any) {
|
||||
if co.Quiet {
|
||||
return
|
||||
}
|
||||
if co.NoColor {
|
||||
fmt.Printf("🔄 "+format+"\n", args...)
|
||||
} else {
|
||||
color.Cyan("🔄 "+format, args...)
|
||||
}
|
||||
co.printWithIcon("🔄", format, color.Cyan, args...)
|
||||
}
|
||||
|
||||
// Bold prints text in bold.
|
||||
@@ -123,7 +96,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 +111,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 +131,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 ""
|
||||
}
|
||||
@@ -193,8 +166,22 @@ func (co *ColoredOutput) FormatContextualError(err *errors.ContextualError) stri
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
// printWithIcon is a helper for printing messages with icons and colors.
|
||||
// It handles quiet mode, color toggling, and consistent formatting.
|
||||
func (co *ColoredOutput) printWithIcon(icon, format string, colorFunc func(string, ...any), args ...any) {
|
||||
if co.Quiet {
|
||||
return
|
||||
}
|
||||
message := icon + " " + format
|
||||
if co.NoColor {
|
||||
fmt.Printf(message+"\n", args...)
|
||||
} else {
|
||||
colorFunc(message, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -203,21 +190,25 @@ func (co *ColoredOutput) formatMainError(err *errors.ContextualError) string {
|
||||
return color.RedString("❌ ") + mainMsg
|
||||
}
|
||||
|
||||
// formatBoldSection formats a section header with or without color.
|
||||
func (co *ColoredOutput) formatBoldSection(section string) string {
|
||||
if co.NoColor {
|
||||
return section
|
||||
}
|
||||
|
||||
return color.New(color.Bold).Sprint(section)
|
||||
}
|
||||
|
||||
// formatDetailsSection formats the details section.
|
||||
func (co *ColoredOutput) formatDetailsSection(details map[string]string) []string {
|
||||
var parts []string
|
||||
|
||||
if co.NoColor {
|
||||
parts = append(parts, "\nDetails:")
|
||||
} else {
|
||||
parts = append(parts, color.New(color.Bold).Sprint("\nDetails:"))
|
||||
}
|
||||
parts = append(parts, co.formatBoldSection(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)))
|
||||
}
|
||||
@@ -229,12 +220,7 @@ func (co *ColoredOutput) formatDetailsSection(details map[string]string) []strin
|
||||
// formatSuggestionsSection formats the suggestions section.
|
||||
func (co *ColoredOutput) formatSuggestionsSection(suggestions []string) []string {
|
||||
var parts []string
|
||||
|
||||
if co.NoColor {
|
||||
parts = append(parts, "\nSuggestions:")
|
||||
} else {
|
||||
parts = append(parts, color.New(color.Bold).Sprint("\nSuggestions:"))
|
||||
}
|
||||
parts = append(parts, co.formatBoldSection(appconstants.SectionSuggestions))
|
||||
|
||||
for _, suggestion := range suggestions {
|
||||
if co.NoColor {
|
||||
|
||||
542
internal/output_test.go
Normal file
542
internal/output_test.go
Normal file
@@ -0,0 +1,542 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/apperrors"
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
// testOutputMethod is a generic helper for testing output methods that follow the same pattern.
|
||||
func testOutputMethod(t *testing.T, testMessage, expectedEmoji string, methodFunc func(*ColoredOutput, string)) {
|
||||
t.Helper()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
quiet bool
|
||||
message string
|
||||
wantEmpty bool
|
||||
}{
|
||||
{
|
||||
name: "message displayed",
|
||||
quiet: false,
|
||||
message: testMessage,
|
||||
wantEmpty: false,
|
||||
},
|
||||
{
|
||||
name: testutil.TestMsgQuietSuppressOutput,
|
||||
quiet: true,
|
||||
message: testMessage,
|
||||
wantEmpty: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output := &ColoredOutput{Quiet: tt.quiet, NoColor: true}
|
||||
|
||||
captured := testutil.CaptureStdout(func() {
|
||||
methodFunc(output, tt.message)
|
||||
})
|
||||
|
||||
if tt.wantEmpty && captured != "" {
|
||||
t.Errorf(testutil.TestMsgNoOutputInQuiet, captured)
|
||||
}
|
||||
|
||||
if !tt.wantEmpty && !strings.Contains(captured, expectedEmoji) {
|
||||
t.Errorf("Output missing %s emoji: %q", expectedEmoji, captured)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// testErrorStderr is a helper for testing error output methods that write to stderr.
|
||||
// Eliminates the repeated pattern of creating ColoredOutput, capturing stderr, and checking for emoji.
|
||||
func testErrorStderr(t *testing.T, expectedEmoji string, testFunc func(*ColoredOutput)) {
|
||||
t.Helper()
|
||||
|
||||
output := &ColoredOutput{NoColor: true}
|
||||
captured := testutil.CaptureStderr(func() {
|
||||
testFunc(output)
|
||||
})
|
||||
|
||||
if !strings.Contains(captured, expectedEmoji) {
|
||||
t.Errorf("Output missing %s emoji: %q", expectedEmoji, captured)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewColoredOutput tests colored output creation.
|
||||
func TestNewColoredOutput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
quiet bool
|
||||
wantQuiet bool
|
||||
}{
|
||||
{
|
||||
name: testutil.TestScenarioQuietEnabled,
|
||||
quiet: true,
|
||||
wantQuiet: true,
|
||||
},
|
||||
{
|
||||
name: testutil.TestScenarioQuietDisabled,
|
||||
quiet: false,
|
||||
wantQuiet: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output := NewColoredOutput(tt.quiet)
|
||||
|
||||
if output == nil {
|
||||
t.Fatal("NewColoredOutput() returned nil")
|
||||
}
|
||||
|
||||
if output.Quiet != tt.wantQuiet {
|
||||
t.Errorf("Quiet = %v, want %v", output.Quiet, tt.wantQuiet)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsQuiet tests quiet mode detection.
|
||||
func TestIsQuiet(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
quiet bool
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: testutil.TestScenarioQuietEnabled,
|
||||
quiet: true,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: testutil.TestScenarioQuietDisabled,
|
||||
quiet: false,
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output := &ColoredOutput{Quiet: tt.quiet, NoColor: true}
|
||||
got := output.IsQuiet()
|
||||
|
||||
if got != tt.want {
|
||||
t.Errorf("IsQuiet() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSuccess tests success message output.
|
||||
func TestSuccess(t *testing.T) {
|
||||
testOutputMethod(t, testutil.TestMsgOperationCompleted, "✅", func(o *ColoredOutput, msg string) {
|
||||
o.Success(msg)
|
||||
})
|
||||
}
|
||||
|
||||
// TestError tests error message output.
|
||||
func TestError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
message string
|
||||
wantContains string
|
||||
}{
|
||||
{
|
||||
name: "error message displayed",
|
||||
message: testutil.TestMsgFileNotFound,
|
||||
wantContains: "❌ File not found",
|
||||
},
|
||||
{
|
||||
name: "error with formatting",
|
||||
message: "Failed to process %s",
|
||||
wantContains: "❌ Failed to process %!s(MISSING)",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output := &ColoredOutput{NoColor: true}
|
||||
|
||||
captured := testutil.CaptureStderr(func() {
|
||||
output.Error(tt.message)
|
||||
})
|
||||
|
||||
if !strings.Contains(captured, "❌") {
|
||||
t.Errorf(testutil.TestMsgOutputMissingEmoji, captured)
|
||||
}
|
||||
|
||||
if !strings.Contains(captured, strings.TrimPrefix(tt.wantContains, "❌ ")) {
|
||||
t.Errorf("Output doesn't contain expected message. Got: %q", captured)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWarning tests warning message output.
|
||||
func TestWarning(t *testing.T) {
|
||||
testOutputMethod(t, "Deprecated feature", "⚠️", func(o *ColoredOutput, msg string) {
|
||||
o.Warning(msg)
|
||||
})
|
||||
}
|
||||
|
||||
// TestInfo tests info message output.
|
||||
func TestInfo(t *testing.T) {
|
||||
testOutputMethod(t, testutil.TestMsgProcessingStarted, "ℹ️", func(o *ColoredOutput, msg string) {
|
||||
o.Info(msg)
|
||||
})
|
||||
}
|
||||
|
||||
// TestProgress tests progress message output.
|
||||
func TestProgress(t *testing.T) {
|
||||
testOutputMethod(t, "Loading data...", "🔄", func(o *ColoredOutput, msg string) {
|
||||
o.Progress(msg)
|
||||
})
|
||||
}
|
||||
|
||||
// TestBold tests bold text output.
|
||||
func TestBold(t *testing.T) {
|
||||
testOutputMethod(t, "Important Notice", "Important Notice", func(o *ColoredOutput, msg string) {
|
||||
o.Bold(msg)
|
||||
})
|
||||
}
|
||||
|
||||
// TestPrintf tests formatted print output.
|
||||
func TestPrintf(t *testing.T) {
|
||||
testOutputMethod(t, "Test message\n", "Test message", func(o *ColoredOutput, msg string) {
|
||||
o.Printf("%s", msg) // #nosec G104 -- constant format string
|
||||
})
|
||||
}
|
||||
|
||||
// TestFprintf tests file output.
|
||||
func TestFprintf(t *testing.T) {
|
||||
// Create temporary file for testing
|
||||
tmpfile, err := os.CreateTemp(t.TempDir(), "test-fprintf-*.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = os.Remove(tmpfile.Name()) }() // Ignore error
|
||||
defer func() { _ = tmpfile.Close() }() // Ignore error
|
||||
|
||||
output := &ColoredOutput{NoColor: true}
|
||||
output.Fprintf(tmpfile, "Test message: %s\n", "hello")
|
||||
|
||||
// Read back the content
|
||||
_, _ = tmpfile.Seek(0, 0) // Ignore error in test
|
||||
content := make([]byte, 100)
|
||||
n, _ := tmpfile.Read(content)
|
||||
|
||||
got := string(content[:n])
|
||||
want := "Test message: hello\n"
|
||||
|
||||
if got != want {
|
||||
t.Errorf("Fprintf() wrote %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestErrorWithSuggestions tests contextual error output.
|
||||
func TestErrorWithSuggestions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err *apperrors.ContextualError
|
||||
wantContains string
|
||||
}{
|
||||
{
|
||||
name: "nil error does nothing",
|
||||
err: nil,
|
||||
wantContains: "",
|
||||
},
|
||||
{
|
||||
name: "error with suggestions",
|
||||
err: apperrors.New(appconstants.ErrCodeFileNotFound, testutil.TestMsgFileNotFound).
|
||||
WithSuggestions(testutil.TestMsgCheckFilePath),
|
||||
wantContains: "❌",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output := &ColoredOutput{NoColor: true}
|
||||
|
||||
captured := testutil.CaptureStderr(func() {
|
||||
output.ErrorWithSuggestions(tt.err)
|
||||
})
|
||||
|
||||
if tt.wantContains == "" && captured != "" {
|
||||
t.Errorf("Expected no output for nil error, got %q", captured)
|
||||
}
|
||||
|
||||
if tt.wantContains != "" && !strings.Contains(captured, tt.wantContains) {
|
||||
t.Errorf("Output doesn't contain %q. Got: %q", tt.wantContains, captured)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestErrorWithContext tests contextual error creation and output.
|
||||
func TestErrorWithContext(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
code appconstants.ErrorCode
|
||||
message string
|
||||
context map[string]string
|
||||
}{
|
||||
{
|
||||
name: "error with context",
|
||||
code: appconstants.ErrCodeFileNotFound,
|
||||
message: testutil.TestMsgFileNotFound,
|
||||
context: map[string]string{testutil.TestKeyFile: appconstants.ActionFileNameYML},
|
||||
},
|
||||
{
|
||||
name: "error without context",
|
||||
code: appconstants.ErrCodeInvalidYAML,
|
||||
message: testutil.TestMsgInvalidYAML,
|
||||
context: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output := &ColoredOutput{NoColor: true}
|
||||
|
||||
captured := testutil.CaptureStderr(func() {
|
||||
output.ErrorWithContext(tt.code, tt.message, tt.context)
|
||||
})
|
||||
|
||||
if !strings.Contains(captured, "❌") {
|
||||
t.Errorf(testutil.TestMsgOutputMissingEmoji, captured)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestErrorWithSimpleFix tests simple error with fix output.
|
||||
func TestErrorWithSimpleFix(t *testing.T) {
|
||||
testErrorStderr(t, "❌", func(output *ColoredOutput) {
|
||||
output.ErrorWithSimpleFix("Something went wrong", "Try running it again")
|
||||
})
|
||||
}
|
||||
|
||||
// TestFormatContextualError tests contextual error formatting.
|
||||
func TestFormatContextualError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err *apperrors.ContextualError
|
||||
wantContains []string
|
||||
}{
|
||||
{
|
||||
name: "nil error returns empty string",
|
||||
err: nil,
|
||||
wantContains: nil,
|
||||
},
|
||||
{
|
||||
name: "error with all sections",
|
||||
err: apperrors.New(appconstants.ErrCodeFileNotFound, testutil.TestMsgFileNotFound).
|
||||
WithSuggestions(testutil.TestMsgCheckFilePath, testutil.TestMsgVerifyPermissions).
|
||||
WithDetails(map[string]string{testutil.TestKeyFile: appconstants.ActionFileNameYML}).
|
||||
WithHelpURL(testutil.TestURLHelp),
|
||||
wantContains: []string{
|
||||
"❌",
|
||||
testutil.TestMsgFileNotFound,
|
||||
testutil.TestMsgCheckFilePath,
|
||||
testutil.TestURLHelp,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "error without suggestions",
|
||||
err: apperrors.New(appconstants.ErrCodeInvalidYAML, testutil.TestMsgInvalidYAML),
|
||||
wantContains: []string{"❌", testutil.TestMsgInvalidYAML},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output := &ColoredOutput{NoColor: true}
|
||||
got := output.FormatContextualError(tt.err)
|
||||
|
||||
if tt.err == nil && got != "" {
|
||||
t.Errorf("Expected empty string for nil error, got %q", got)
|
||||
}
|
||||
|
||||
for _, want := range tt.wantContains {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("FormatContextualError() missing %q. Got:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatMainError tests main error message formatting.
|
||||
func TestFormatMainError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
noColor bool
|
||||
err *apperrors.ContextualError
|
||||
wantContains []string
|
||||
}{
|
||||
{
|
||||
name: testutil.TestScenarioColorDisabled,
|
||||
noColor: true,
|
||||
err: apperrors.New(appconstants.ErrCodeFileNotFound, testutil.TestMsgFileNotFound),
|
||||
wantContains: []string{"❌", testutil.TestMsgFileNotFound, string(appconstants.ErrCodeFileNotFound)},
|
||||
},
|
||||
{
|
||||
name: testutil.TestScenarioColorEnabled,
|
||||
noColor: false,
|
||||
err: apperrors.New(appconstants.ErrCodeInvalidYAML, testutil.TestMsgInvalidYAML),
|
||||
wantContains: []string{"❌", testutil.TestMsgInvalidYAML, string(appconstants.ErrCodeInvalidYAML)},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output := &ColoredOutput{NoColor: tt.noColor}
|
||||
got := output.formatMainError(tt.err)
|
||||
|
||||
for _, want := range tt.wantContains {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("formatMainError() missing %q. Got: %q", want, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatDetailsSection tests details section formatting.
|
||||
func TestFormatDetailsSection(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
noColor bool
|
||||
details map[string]string
|
||||
wantContains []string
|
||||
}{
|
||||
{
|
||||
name: testutil.TestScenarioColorDisabled,
|
||||
noColor: true,
|
||||
details: map[string]string{testutil.TestKeyFile: appconstants.ActionFileNameYML, "line": "10"},
|
||||
wantContains: []string{
|
||||
testutil.TestMsgDetails,
|
||||
testutil.TestKeyFile,
|
||||
appconstants.ActionFileNameYML,
|
||||
"line",
|
||||
"10",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: testutil.TestScenarioColorEnabled,
|
||||
noColor: false,
|
||||
details: map[string]string{testutil.TestKeyPath: "/tmp/test"},
|
||||
wantContains: []string{testutil.TestMsgDetails, "path", "/tmp/test"},
|
||||
},
|
||||
{
|
||||
name: "empty details",
|
||||
noColor: true,
|
||||
details: map[string]string{},
|
||||
wantContains: []string{testutil.TestMsgDetails},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output := &ColoredOutput{NoColor: tt.noColor}
|
||||
got := output.formatDetailsSection(tt.details)
|
||||
gotStr := strings.Join(got, "\n")
|
||||
|
||||
for _, want := range tt.wantContains {
|
||||
if !strings.Contains(gotStr, want) {
|
||||
t.Errorf("formatDetailsSection() missing %q. Got:\n%s", want, gotStr)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatSuggestionsSection tests suggestions section formatting.
|
||||
func TestFormatSuggestionsSection(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
noColor bool
|
||||
suggestions []string
|
||||
wantContains []string
|
||||
}{
|
||||
{
|
||||
name: testutil.TestScenarioColorDisabled,
|
||||
noColor: true,
|
||||
suggestions: []string{"Check the file", testutil.TestMsgVerifyPermissions},
|
||||
wantContains: []string{
|
||||
testutil.TestMsgSuggestions,
|
||||
"•",
|
||||
"Check the file",
|
||||
testutil.TestMsgVerifyPermissions,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: testutil.TestScenarioColorEnabled,
|
||||
noColor: false,
|
||||
suggestions: []string{testutil.TestMsgTryAgain},
|
||||
wantContains: []string{testutil.TestMsgSuggestions, "•", testutil.TestMsgTryAgain},
|
||||
},
|
||||
{
|
||||
name: "empty suggestions",
|
||||
noColor: true,
|
||||
suggestions: []string{},
|
||||
wantContains: []string{testutil.TestMsgSuggestions},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output := &ColoredOutput{NoColor: tt.noColor}
|
||||
got := output.formatSuggestionsSection(tt.suggestions)
|
||||
gotStr := strings.Join(got, "\n")
|
||||
|
||||
for _, want := range tt.wantContains {
|
||||
if !strings.Contains(gotStr, want) {
|
||||
t.Errorf("formatSuggestionsSection() missing %q. Got:\n%s", want, gotStr)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatHelpURLSection tests help URL section formatting.
|
||||
func TestFormatHelpURLSection(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
noColor bool
|
||||
helpURL string
|
||||
wantContains []string
|
||||
}{
|
||||
{
|
||||
name: testutil.TestScenarioColorDisabled,
|
||||
noColor: true,
|
||||
helpURL: testutil.TestURLHelp,
|
||||
wantContains: []string{"For more help", testutil.TestURLHelp},
|
||||
},
|
||||
{
|
||||
name: testutil.TestScenarioColorEnabled,
|
||||
noColor: false,
|
||||
helpURL: "https://docs.example.com",
|
||||
wantContains: []string{"For more help", "https://docs.example.com"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output := &ColoredOutput{NoColor: tt.noColor}
|
||||
got := output.formatHelpURLSection(tt.helpURL)
|
||||
|
||||
for _, want := range tt.wantContains {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("formatHelpURLSection() missing %q. Got: %q", want, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"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).
|
||||
@@ -17,6 +20,7 @@ type ActionYML struct {
|
||||
Outputs map[string]ActionOutput `yaml:"outputs"`
|
||||
Runs map[string]any `yaml:"runs"`
|
||||
Branding *Branding `yaml:"branding,omitempty"`
|
||||
Permissions map[string]string `yaml:"permissions,omitempty"`
|
||||
// Add more fields as the schema evolves
|
||||
}
|
||||
|
||||
@@ -40,6 +44,14 @@ type Branding struct {
|
||||
|
||||
// ParseActionYML reads and parses action.yml from given path.
|
||||
func ParseActionYML(path string) (*ActionYML, error) {
|
||||
// Parse permissions from header comments FIRST
|
||||
commentPermissions, err := parsePermissionsFromComments(path)
|
||||
if err != nil {
|
||||
// Don't fail if comment parsing fails, just log and continue
|
||||
commentPermissions = nil
|
||||
}
|
||||
|
||||
// Standard YAML parsing
|
||||
f, err := os.Open(path) // #nosec G304 -- path from function parameter
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -53,49 +65,218 @@ func ParseActionYML(path string) (*ActionYML, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Merge permissions: YAML permissions override comment permissions
|
||||
mergePermissions(&a, commentPermissions)
|
||||
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
// mergePermissions combines comment and YAML permissions.
|
||||
// YAML permissions take precedence when both exist.
|
||||
func mergePermissions(action *ActionYML, commentPerms map[string]string) {
|
||||
if action.Permissions == nil && commentPerms != nil && len(commentPerms) > 0 {
|
||||
action.Permissions = commentPerms
|
||||
} else if action.Permissions != nil && commentPerms != nil && len(commentPerms) > 0 {
|
||||
// Merge: YAML takes precedence, add missing from comments
|
||||
for key, value := range commentPerms {
|
||||
if _, exists := action.Permissions[key]; !exists {
|
||||
action.Permissions[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parsePermissionsFromComments extracts permissions from header comments.
|
||||
// Looks for lines like:
|
||||
//
|
||||
// # permissions:
|
||||
// # - contents: read # Required for checking out repository
|
||||
// # contents: read # Alternative format without dash
|
||||
func parsePermissionsFromComments(path string) (map[string]string, error) {
|
||||
file, err := os.Open(path) // #nosec G304 -- path from function parameter
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
_ = file.Close() // Ignore close error in defer
|
||||
}()
|
||||
|
||||
permissions := make(map[string]string)
|
||||
scanner := bufio.NewScanner(file)
|
||||
|
||||
inPermissionsBlock := false
|
||||
var expectedItemIndent int
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
trimmed := strings.TrimSpace(line)
|
||||
|
||||
// Stop parsing at first non-comment line
|
||||
if !strings.HasPrefix(trimmed, "#") {
|
||||
break
|
||||
}
|
||||
|
||||
// Remove leading # and spaces
|
||||
content := strings.TrimPrefix(trimmed, "#")
|
||||
content = strings.TrimSpace(content)
|
||||
|
||||
// Check for permissions block start
|
||||
if content == "permissions:" {
|
||||
inPermissionsBlock = true
|
||||
// Calculate expected indent for permission items (after the # and any spaces)
|
||||
// We expect items to be indented relative to the content
|
||||
expectedItemIndent = -1 // Will be set on first item
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse permission entries
|
||||
if inPermissionsBlock && content != "" {
|
||||
shouldBreak := processPermissionEntry(line, content, &expectedItemIndent, permissions)
|
||||
if shouldBreak {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return permissions, nil
|
||||
}
|
||||
|
||||
// parsePermissionLine extracts key-value from a permission line.
|
||||
// Supports formats:
|
||||
// - "- contents: read # comment"
|
||||
// - "contents: read # comment"
|
||||
func parsePermissionLine(content string) (key, value string, ok bool) {
|
||||
// Remove leading dash if present
|
||||
content = strings.TrimPrefix(content, "-")
|
||||
content = strings.TrimSpace(content)
|
||||
|
||||
// Remove inline comments
|
||||
if idx := strings.Index(content, "#"); idx > 0 {
|
||||
content = strings.TrimSpace(content[:idx])
|
||||
}
|
||||
|
||||
// Parse key: value
|
||||
parts := strings.SplitN(content, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
key = strings.TrimSpace(parts[0])
|
||||
value = strings.TrimSpace(parts[1])
|
||||
if key != "" && value != "" {
|
||||
return key, value, true
|
||||
}
|
||||
}
|
||||
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// processPermissionEntry processes a single line in the permissions block.
|
||||
// Returns true if parsing should break (dedented out of block), false to continue.
|
||||
func processPermissionEntry(line, content string, expectedItemIndent *int, permissions map[string]string) bool {
|
||||
// Get the indent of the content (after removing #)
|
||||
lineAfterHash := strings.TrimPrefix(line, "#")
|
||||
contentIndent := len(lineAfterHash) - len(strings.TrimLeft(lineAfterHash, " "))
|
||||
|
||||
// Set expected indent on first item
|
||||
if *expectedItemIndent == -1 {
|
||||
*expectedItemIndent = contentIndent
|
||||
}
|
||||
|
||||
// If dedented relative to expected item indent, we've left the permissions block
|
||||
if contentIndent < *expectedItemIndent {
|
||||
return true
|
||||
}
|
||||
|
||||
// Parse permission line and add to map if valid
|
||||
if key, value, ok := parsePermissionLine(content); ok {
|
||||
permissions[key] = value
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// shouldIgnoreDirectory checks if a directory name matches the ignore list.
|
||||
func shouldIgnoreDirectory(dirName string, ignoredDirs []string) bool {
|
||||
for _, ignored := range ignoredDirs {
|
||||
if strings.HasPrefix(ignored, ".") {
|
||||
// Pattern match: ".git" matches ".git", ".github", etc.
|
||||
if strings.HasPrefix(dirName, ignored) {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
// Exact match for non-hidden dirs
|
||||
if dirName == ignored {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// actionFileWalker encapsulates the logic for walking directories and finding action files.
|
||||
type actionFileWalker struct {
|
||||
ignoredDirs []string
|
||||
actionFiles []string
|
||||
}
|
||||
|
||||
// walkFunc is the callback function for filepath.Walk.
|
||||
func (w *actionFileWalker) walkFunc(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
if shouldIgnoreDirectory(info.Name(), w.ignoredDirs) {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check for action.yml or action.yaml files
|
||||
filename := strings.ToLower(info.Name())
|
||||
if filename == appconstants.ActionFileNameYML || filename == appconstants.ActionFileNameYAML {
|
||||
w.actionFiles = append(w.actionFiles, path)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DiscoverActionFiles finds action.yml and action.yaml files in the given directory.
|
||||
// This consolidates the file discovery logic from both generator.go and dependencies/parser.go.
|
||||
func DiscoverActionFiles(dir string, recursive bool) ([]string, error) {
|
||||
var actionFiles []string
|
||||
|
||||
func DiscoverActionFiles(dir string, recursive bool, ignoredDirs []string) ([]string, error) {
|
||||
// Check if dir exists
|
||||
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("directory does not exist: %s", dir)
|
||||
}
|
||||
|
||||
if recursive {
|
||||
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check for action.yml or action.yaml files
|
||||
filename := strings.ToLower(info.Name())
|
||||
if filename == "action.yml" || filename == "action.yaml" {
|
||||
actionFiles = append(actionFiles, path)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
walker := &actionFileWalker{ignoredDirs: ignoredDirs}
|
||||
if err := filepath.Walk(dir, walker.walkFunc); err != nil {
|
||||
return nil, fmt.Errorf("failed to walk directory %s: %w", dir, err)
|
||||
}
|
||||
} else {
|
||||
// Check only the specified directory
|
||||
for _, filename := range []string{"action.yml", "action.yaml"} {
|
||||
path := filepath.Join(dir, filename)
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
actionFiles = append(actionFiles, path)
|
||||
}
|
||||
|
||||
return walker.actionFiles, nil
|
||||
}
|
||||
|
||||
// Check only the specified directory (non-recursive)
|
||||
return DiscoverActionFilesNonRecursive(dir), nil
|
||||
}
|
||||
|
||||
// DiscoverActionFilesNonRecursive finds action files (action.yml or action.yaml) in a single directory.
|
||||
// This is exported for use by other packages that need to discover action files.
|
||||
func DiscoverActionFilesNonRecursive(dir string) []string {
|
||||
var actionFiles []string
|
||||
for _, filename := range []string{appconstants.ActionFileNameYML, appconstants.ActionFileNameYAML} {
|
||||
path := filepath.Join(dir, filename)
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
actionFiles = append(actionFiles, path)
|
||||
}
|
||||
}
|
||||
|
||||
return actionFiles, nil
|
||||
return actionFiles
|
||||
}
|
||||
|
||||
690
internal/parser_mutation_test.go
Normal file
690
internal/parser_mutation_test.go
Normal file
@@ -0,0 +1,690 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
// TestPermissionParsingMutationResistance provides comprehensive test cases designed
|
||||
// to catch mutations in the permission parsing logic. These tests target critical
|
||||
// boundaries, operators, and conditions that are susceptible to mutation.
|
||||
//
|
||||
// permissionParsingTestCase defines a test case for permission parsing tests.
|
||||
type permissionParsingTestCase struct {
|
||||
name string
|
||||
yaml string
|
||||
expected map[string]string
|
||||
critical bool
|
||||
}
|
||||
|
||||
// buildPermissionParsingTestCases returns all test cases for permission parsing.
|
||||
// YAML content is loaded from fixture files in testdata/yaml-fixtures/configs/permissions/mutation/.
|
||||
func buildPermissionParsingTestCases() []permissionParsingTestCase {
|
||||
const fixtureDir = "configs/permissions/mutation/"
|
||||
|
||||
return []permissionParsingTestCase{
|
||||
{
|
||||
name: "off_by_one_indent_two_items",
|
||||
yaml: testutil.MustReadFixture(fixtureDir + "off-by-one-indent-two-items.yaml"),
|
||||
expected: map[string]string{"contents": "read", "issues": "write"},
|
||||
critical: true,
|
||||
},
|
||||
{
|
||||
name: "off_by_one_indent_three_items",
|
||||
yaml: testutil.MustReadFixture(fixtureDir + "off-by-one-indent-three-items.yaml"),
|
||||
expected: map[string]string{
|
||||
"contents": "read",
|
||||
"issues": "write",
|
||||
testutil.TestFixturePullRequests: "read",
|
||||
},
|
||||
critical: true,
|
||||
},
|
||||
{
|
||||
name: "comment_position_at_boundary",
|
||||
yaml: testutil.MustReadFixture(fixtureDir + "comment-position-at-boundary.yaml"),
|
||||
expected: map[string]string{"contents": "read"},
|
||||
critical: true,
|
||||
},
|
||||
{
|
||||
name: "comment_at_position_zero_parses",
|
||||
yaml: testutil.MustReadFixture(fixtureDir + "comment-at-position-zero-parses.yaml"),
|
||||
expected: map[string]string{"contents": "read"},
|
||||
critical: true,
|
||||
},
|
||||
{
|
||||
name: "dash_prefix_with_spaces",
|
||||
yaml: testutil.MustReadFixture(fixtureDir + "dash-prefix-with-spaces.yaml"),
|
||||
expected: map[string]string{"contents": "read", "issues": "write"},
|
||||
critical: true,
|
||||
},
|
||||
{
|
||||
name: "mixed_dash_and_no_dash",
|
||||
yaml: testutil.MustReadFixture(fixtureDir + "mixed-dash-and-no-dash.yaml"),
|
||||
expected: map[string]string{"contents": "read", "issues": "write"},
|
||||
critical: true,
|
||||
},
|
||||
{
|
||||
name: "dedent_stops_parsing",
|
||||
yaml: testutil.MustReadFixture(fixtureDir + "dedent-stops-parsing.yaml"),
|
||||
expected: map[string]string{"contents": "read"},
|
||||
critical: true,
|
||||
},
|
||||
{
|
||||
name: "empty_line_in_block_continues",
|
||||
yaml: testutil.MustReadFixture(fixtureDir + "empty-line-in-block-continues.yaml"),
|
||||
expected: map[string]string{"contents": "read", "issues": "write"},
|
||||
critical: false,
|
||||
},
|
||||
{
|
||||
name: "non_comment_line_stops_parsing",
|
||||
yaml: testutil.MustReadFixture(fixtureDir + "non-comment-line-stops-parsing.yaml"),
|
||||
expected: map[string]string{"contents": "read"},
|
||||
critical: true,
|
||||
},
|
||||
{
|
||||
name: "exact_expected_indent",
|
||||
yaml: testutil.MustReadFixture(fixtureDir + "exact-expected-indent.yaml"),
|
||||
expected: map[string]string{"contents": "read"},
|
||||
critical: true,
|
||||
},
|
||||
{
|
||||
name: "colon_in_value_preserved",
|
||||
yaml: testutil.MustReadFixture(fixtureDir + "colon-in-value-preserved.yaml"),
|
||||
expected: map[string]string{"contents": "read:write"},
|
||||
critical: true,
|
||||
},
|
||||
{
|
||||
name: "empty_key_not_parsed",
|
||||
yaml: testutil.MustReadFixture(fixtureDir + "empty-key-not-parsed.yaml"),
|
||||
expected: map[string]string{},
|
||||
critical: true,
|
||||
},
|
||||
{
|
||||
name: "empty_value_not_parsed",
|
||||
yaml: testutil.MustReadFixture(fixtureDir + "empty-value-not-parsed.yaml"),
|
||||
expected: map[string]string{},
|
||||
critical: true,
|
||||
},
|
||||
{
|
||||
name: "whitespace_only_value_not_parsed",
|
||||
yaml: testutil.MustReadFixture(fixtureDir + "whitespace-only-value-not-parsed.yaml"),
|
||||
expected: map[string]string{},
|
||||
critical: true,
|
||||
},
|
||||
{
|
||||
name: "multiple_colons_splits_at_first",
|
||||
yaml: testutil.MustReadFixture(fixtureDir + "multiple-colons-splits-at-first.yaml"),
|
||||
expected: map[string]string{"url": "https://example.com:8080"},
|
||||
critical: true,
|
||||
},
|
||||
{
|
||||
name: "inline_comment_removal",
|
||||
yaml: testutil.MustReadFixture(fixtureDir + "inline-comment-removal.yaml"),
|
||||
expected: map[string]string{"contents": "read"},
|
||||
critical: true,
|
||||
},
|
||||
{
|
||||
name: "inline_comment_at_start_of_value",
|
||||
yaml: testutil.MustReadFixture(fixtureDir + "inline-comment-at-start-of-value.yaml"),
|
||||
expected: map[string]string{},
|
||||
critical: true,
|
||||
},
|
||||
{
|
||||
name: "deeply_nested_indent",
|
||||
yaml: testutil.MustReadFixture(fixtureDir + "deeply-nested-indent.yaml"),
|
||||
expected: map[string]string{"contents": "read", "issues": "write"},
|
||||
critical: true,
|
||||
},
|
||||
{
|
||||
name: "minimal_valid_permission",
|
||||
yaml: testutil.MustReadFixture(fixtureDir + "minimal-valid-permission.yaml"),
|
||||
expected: map[string]string{"x": "y"},
|
||||
critical: true,
|
||||
},
|
||||
{
|
||||
name: "maximum_realistic_permissions",
|
||||
yaml: testutil.MustReadFixture(fixtureDir + "maximum-realistic-permissions.yaml"),
|
||||
expected: map[string]string{
|
||||
"actions": "write",
|
||||
"attestations": "write",
|
||||
"checks": "write",
|
||||
"contents": "write",
|
||||
"deployments": "write",
|
||||
"discussions": "write",
|
||||
"id-token": "write",
|
||||
"issues": "write",
|
||||
"packages": "write",
|
||||
"pages": "write",
|
||||
testutil.TestFixturePullRequests: "write",
|
||||
"repository-projects": "write",
|
||||
"security-events": "write",
|
||||
"statuses": "write",
|
||||
},
|
||||
critical: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionParsingMutationResistance(t *testing.T) {
|
||||
tests := buildPermissionParsingTestCases()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
testPermissionParsingCase(t, tt.yaml, tt.expected)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testPermissionParsingCase(t *testing.T, yaml string, expected map[string]string) {
|
||||
t.Helper()
|
||||
|
||||
// Create temporary file with test YAML
|
||||
tmpDir := t.TempDir()
|
||||
testFile := filepath.Join(tmpDir, "action.yml")
|
||||
|
||||
testutil.WriteTestFile(t, testFile, yaml)
|
||||
|
||||
// Parse permissions
|
||||
result, err := parsePermissionsFromComments(testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("parsePermissionsFromComments() error = %v", err)
|
||||
}
|
||||
|
||||
// Verify expected permissions
|
||||
if len(result) != len(expected) {
|
||||
t.Errorf("got %d permissions, want %d", len(result), len(expected))
|
||||
t.Logf("got: %v", result)
|
||||
t.Logf("want: %v", expected)
|
||||
}
|
||||
|
||||
for key, expectedValue := range expected {
|
||||
gotValue, exists := result[key]
|
||||
if !exists {
|
||||
t.Errorf(testutil.TestFixtureMissingPermKey, key)
|
||||
|
||||
continue
|
||||
}
|
||||
if gotValue != expectedValue {
|
||||
t.Errorf("permission %q: got value %q, want %q", key, gotValue, expectedValue)
|
||||
}
|
||||
}
|
||||
|
||||
// Check for unexpected keys
|
||||
for key := range result {
|
||||
if _, expected := expected[key]; !expected {
|
||||
t.Errorf("unexpected permission key %q with value %q", key, result[key])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMergePermissionsMutationResistance tests the permission merging logic
|
||||
// for mutations in nil checks, map operations, and precedence logic.
|
||||
//
|
||||
|
||||
func TestMergePermissionsMutationResistance(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
yamlPerms map[string]string
|
||||
commentPerms map[string]string
|
||||
expected map[string]string
|
||||
critical bool
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "nil_yaml_nil_comment",
|
||||
yamlPerms: nil,
|
||||
commentPerms: nil,
|
||||
expected: nil,
|
||||
critical: true,
|
||||
description: "Both nil should stay nil (nil check critical)",
|
||||
},
|
||||
{
|
||||
name: "nil_yaml_with_comment",
|
||||
yamlPerms: nil,
|
||||
commentPerms: map[string]string{"contents": "read"},
|
||||
expected: map[string]string{"contents": "read"},
|
||||
critical: true,
|
||||
description: "Nil YAML replaced by comment perms (first condition)",
|
||||
},
|
||||
{
|
||||
name: "yaml_with_nil_comment",
|
||||
yamlPerms: map[string]string{"contents": "write"},
|
||||
commentPerms: nil,
|
||||
expected: map[string]string{"contents": "write"},
|
||||
critical: true,
|
||||
description: "Nil comment keeps YAML perms (second condition)",
|
||||
},
|
||||
{
|
||||
name: "empty_yaml_empty_comment",
|
||||
yamlPerms: map[string]string{},
|
||||
commentPerms: map[string]string{},
|
||||
expected: map[string]string{},
|
||||
critical: true,
|
||||
description: "Both empty should stay empty",
|
||||
},
|
||||
{
|
||||
name: "yaml_overrides_comment_same_key",
|
||||
yamlPerms: map[string]string{"contents": "write"},
|
||||
commentPerms: map[string]string{"contents": "read"},
|
||||
expected: map[string]string{"contents": "write"},
|
||||
critical: true,
|
||||
description: "YAML value wins conflict (exists check critical)",
|
||||
},
|
||||
{
|
||||
name: "non_conflicting_keys_merged",
|
||||
yamlPerms: map[string]string{"contents": "write"},
|
||||
commentPerms: map[string]string{"issues": "read"},
|
||||
expected: map[string]string{"contents": "write", "issues": "read"},
|
||||
critical: true,
|
||||
description: "Non-conflicting keys both included",
|
||||
},
|
||||
{
|
||||
name: "multiple_yaml_override_multiple_comment",
|
||||
yamlPerms: map[string]string{
|
||||
"contents": "write",
|
||||
"issues": "write",
|
||||
},
|
||||
commentPerms: map[string]string{
|
||||
"contents": "read",
|
||||
testutil.TestFixturePullRequests: "read",
|
||||
},
|
||||
expected: map[string]string{
|
||||
"contents": "write", // YAML wins
|
||||
"issues": "write", // Only in YAML
|
||||
testutil.TestFixturePullRequests: "read", // Only in comment
|
||||
},
|
||||
critical: true,
|
||||
description: "Complex merge with conflicts and unique keys",
|
||||
},
|
||||
{
|
||||
name: "single_key_conflict",
|
||||
yamlPerms: map[string]string{"x": "a"},
|
||||
commentPerms: map[string]string{"x": "b"},
|
||||
expected: map[string]string{"x": "a"},
|
||||
critical: true,
|
||||
description: "Minimal conflict test (YAML precedence)",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
testMergePermissionsCase(t, tt.yamlPerms, tt.commentPerms, tt.expected, tt.description)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testMergePermissionsCase(
|
||||
t *testing.T,
|
||||
yamlPerms, commentPerms, expected map[string]string,
|
||||
description string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
// Create ActionYML with test permissions
|
||||
action := &ActionYML{
|
||||
Permissions: copyStringMap(yamlPerms),
|
||||
}
|
||||
|
||||
// Copy commentPerms to avoid mutation during test
|
||||
commentPermsCopy := copyStringMap(commentPerms)
|
||||
|
||||
// Perform merge
|
||||
mergePermissions(action, commentPermsCopy)
|
||||
|
||||
// Verify result
|
||||
assertPermissionsMatch(t, action.Permissions, expected, description)
|
||||
}
|
||||
|
||||
// copyStringMap creates a deep copy of a string map, returning nil for nil input.
|
||||
func copyStringMap(input map[string]string) map[string]string {
|
||||
if input == nil {
|
||||
return nil
|
||||
}
|
||||
result := make(map[string]string, len(input))
|
||||
for k, v := range input {
|
||||
result[k] = v
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// assertPermissionsMatch verifies that got permissions match expected permissions.
|
||||
func assertPermissionsMatch(
|
||||
t *testing.T,
|
||||
got, want map[string]string,
|
||||
description string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
if want == nil {
|
||||
if got != nil {
|
||||
t.Errorf("expected nil permissions, got %v", got)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if got == nil {
|
||||
t.Errorf("expected non-nil permissions %v, got nil", want)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if len(got) != len(want) {
|
||||
t.Errorf("got %d permissions, want %d", len(got), len(want))
|
||||
t.Logf("got: %v", got)
|
||||
t.Logf("want: %v", want)
|
||||
}
|
||||
|
||||
for key, expectedValue := range want {
|
||||
gotValue, exists := got[key]
|
||||
if !exists {
|
||||
t.Errorf(testutil.TestFixtureMissingPermKey, key)
|
||||
|
||||
continue
|
||||
}
|
||||
if gotValue != expectedValue {
|
||||
t.Errorf("permission %q: got %q, want %q (description: %s)",
|
||||
key, gotValue, expectedValue, description)
|
||||
}
|
||||
}
|
||||
|
||||
for key := range got {
|
||||
if _, expected := want[key]; !expected {
|
||||
t.Errorf("unexpected permission key %q", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// permissionLineTestCase defines a test case for parsePermissionLine tests.
|
||||
type permissionLineTestCase struct {
|
||||
name string
|
||||
content string
|
||||
expectKey string
|
||||
expectValue string
|
||||
expectOk bool
|
||||
critical bool
|
||||
description string
|
||||
}
|
||||
|
||||
// parseFailCase creates a test case expecting parse failure with empty results.
|
||||
func parseFailCase(name, content, description string) permissionLineTestCase {
|
||||
return permissionLineTestCase{
|
||||
name: name,
|
||||
content: content,
|
||||
expectKey: "",
|
||||
expectValue: "",
|
||||
expectOk: false,
|
||||
critical: true,
|
||||
description: description,
|
||||
}
|
||||
}
|
||||
|
||||
// TestParsePermissionLineMutationResistance tests string manipulation boundaries
|
||||
// in permission line parsing that are susceptible to mutation.
|
||||
//
|
||||
|
||||
func TestParsePermissionLineMutationResistance(t *testing.T) {
|
||||
tests := []permissionLineTestCase{
|
||||
{
|
||||
name: "basic_key_value",
|
||||
content: testutil.TestFixtureContentsRead,
|
||||
expectKey: "contents",
|
||||
expectValue: "read",
|
||||
expectOk: true,
|
||||
critical: true,
|
||||
description: "Basic parsing",
|
||||
},
|
||||
{
|
||||
name: "with_leading_dash",
|
||||
content: "- contents: read",
|
||||
expectKey: "contents",
|
||||
expectValue: "read",
|
||||
expectOk: true,
|
||||
critical: true,
|
||||
description: "TrimPrefix(\"-\") critical",
|
||||
},
|
||||
{
|
||||
name: "with_inline_comment_at_position_1",
|
||||
content: "contents: r#comment",
|
||||
expectKey: "contents",
|
||||
expectValue: "r",
|
||||
expectOk: true,
|
||||
critical: true,
|
||||
description: "Index() > 0 boundary (idx=10)",
|
||||
},
|
||||
// Failure test cases with empty expected results
|
||||
parseFailCase(
|
||||
"inline_comment_at_position_0_of_value",
|
||||
"contents: #read",
|
||||
"Index() at position 0 in value (should fail parse)",
|
||||
),
|
||||
{
|
||||
name: "comment_in_middle_of_line",
|
||||
content: "contents: read # Required",
|
||||
expectKey: "contents",
|
||||
expectValue: "read",
|
||||
expectOk: true,
|
||||
critical: true,
|
||||
description: "Comment removal before parse",
|
||||
},
|
||||
parseFailCase("no_colon", "contents read", "len(parts) == 2 check"),
|
||||
{
|
||||
name: "multiple_colons",
|
||||
content: "url: https://example.com:8080",
|
||||
expectKey: "url",
|
||||
expectValue: "https://example.com:8080",
|
||||
expectOk: true,
|
||||
critical: true,
|
||||
description: "SplitN with n=2 preserves colons in value",
|
||||
},
|
||||
parseFailCase("empty_key", ": value", "key != \"\" check critical"),
|
||||
parseFailCase("empty_value", "key:", "value != \"\" check critical"),
|
||||
parseFailCase("whitespace_key", " : value", "TrimSpace on key critical"),
|
||||
parseFailCase("whitespace_value", "key: ", "TrimSpace on value critical"),
|
||||
{
|
||||
name: "single_char_key_value",
|
||||
content: "a: b",
|
||||
expectKey: "a",
|
||||
expectValue: "b",
|
||||
expectOk: true,
|
||||
critical: true,
|
||||
description: "Minimal valid case",
|
||||
},
|
||||
{
|
||||
name: "colon_in_key_should_not_happen",
|
||||
content: "key:name: value",
|
||||
expectKey: "key",
|
||||
expectValue: "name: value",
|
||||
expectOk: true,
|
||||
critical: false,
|
||||
description: "First colon splits (malformed input)",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
testParsePermissionLineCase(
|
||||
t,
|
||||
tt.content,
|
||||
tt.expectKey,
|
||||
tt.expectValue,
|
||||
tt.expectOk,
|
||||
tt.description,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testParsePermissionLineCase(
|
||||
t *testing.T,
|
||||
content, expectKey, expectValue string,
|
||||
expectOk bool,
|
||||
description string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
key, value, ok := parsePermissionLine(content)
|
||||
|
||||
if ok != expectOk {
|
||||
t.Errorf("ok: got %v, want %v (description: %s)", ok, expectOk, description)
|
||||
}
|
||||
|
||||
if ok {
|
||||
if key != expectKey {
|
||||
t.Errorf("key: got %q, want %q (description: %s)", key, expectKey, description)
|
||||
}
|
||||
if value != expectValue {
|
||||
t.Errorf("value: got %q, want %q (description: %s)", value, expectValue, description)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestProcessPermissionEntryMutationResistance tests indentation logic that is
|
||||
// highly susceptible to off-by-one mutations.
|
||||
//
|
||||
|
||||
func TestProcessPermissionEntryMutationResistance(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
line string
|
||||
content string
|
||||
initialExpected int
|
||||
expectBreak bool
|
||||
expectPermissions map[string]string
|
||||
critical bool
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "first_item_sets_indent",
|
||||
line: "# contents: read",
|
||||
content: testutil.TestFixtureContentsRead,
|
||||
initialExpected: -1,
|
||||
expectBreak: false,
|
||||
expectPermissions: map[string]string{"contents": "read"},
|
||||
critical: true,
|
||||
description: "*expectedItemIndent == -1 check",
|
||||
},
|
||||
{
|
||||
name: "same_indent_continues",
|
||||
line: "# issues: write",
|
||||
content: testutil.TestFixtureIssuesWrite,
|
||||
initialExpected: 3,
|
||||
expectBreak: false,
|
||||
expectPermissions: map[string]string{"issues": "write"},
|
||||
critical: true,
|
||||
description: "contentIndent == expectedItemIndent",
|
||||
},
|
||||
{
|
||||
name: "dedent_by_one_breaks",
|
||||
line: "# issues: write",
|
||||
content: testutil.TestFixtureIssuesWrite,
|
||||
initialExpected: 3,
|
||||
expectBreak: true,
|
||||
expectPermissions: map[string]string{},
|
||||
critical: true,
|
||||
description: "contentIndent < expectedItemIndent (2 < 3)",
|
||||
},
|
||||
{
|
||||
name: "dedent_by_two_breaks",
|
||||
line: "# issues: write",
|
||||
content: testutil.TestFixtureIssuesWrite,
|
||||
initialExpected: 3,
|
||||
expectBreak: true,
|
||||
expectPermissions: map[string]string{},
|
||||
critical: true,
|
||||
description: "contentIndent < expectedItemIndent (0 < 3)",
|
||||
},
|
||||
{
|
||||
name: "indent_more_continues",
|
||||
line: "# issues: write",
|
||||
content: testutil.TestFixtureIssuesWrite,
|
||||
initialExpected: 3,
|
||||
expectBreak: false,
|
||||
expectPermissions: map[string]string{"issues": "write"},
|
||||
critical: false,
|
||||
description: "More indent allowed (unusual but valid)",
|
||||
},
|
||||
{
|
||||
name: "zero_indent_with_zero_expected",
|
||||
line: "# contents: read",
|
||||
content: testutil.TestFixtureContentsRead,
|
||||
initialExpected: 0,
|
||||
expectBreak: false,
|
||||
expectPermissions: map[string]string{"contents": "read"},
|
||||
critical: true,
|
||||
description: "Boundary: 0 == 0",
|
||||
},
|
||||
{
|
||||
name: "large_indent_value",
|
||||
line: "# contents: read",
|
||||
content: testutil.TestFixtureContentsRead,
|
||||
initialExpected: -1,
|
||||
expectBreak: false,
|
||||
expectPermissions: map[string]string{"contents": "read"},
|
||||
critical: false,
|
||||
description: "Large indent value (10 spaces)",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
testProcessPermissionEntryCase(
|
||||
t,
|
||||
tt.line,
|
||||
tt.content,
|
||||
tt.initialExpected,
|
||||
tt.expectBreak,
|
||||
tt.expectPermissions,
|
||||
tt.description,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testProcessPermissionEntryCase(
|
||||
t *testing.T,
|
||||
line, content string,
|
||||
initialExpected int,
|
||||
expectBreak bool,
|
||||
expectPermissions map[string]string,
|
||||
description string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
permissions := make(map[string]string)
|
||||
expectedIndent := initialExpected
|
||||
|
||||
shouldBreak := processPermissionEntry(line, content, &expectedIndent, permissions)
|
||||
|
||||
if shouldBreak != expectBreak {
|
||||
t.Errorf("shouldBreak: got %v, want %v (description: %s)",
|
||||
shouldBreak, expectBreak, description)
|
||||
}
|
||||
|
||||
if len(permissions) != len(expectPermissions) {
|
||||
t.Errorf("got %d permissions, want %d (description: %s)",
|
||||
len(permissions), len(expectPermissions), description)
|
||||
}
|
||||
|
||||
for key, expectedValue := range expectPermissions {
|
||||
gotValue, exists := permissions[key]
|
||||
if !exists {
|
||||
t.Errorf(testutil.TestFixtureMissingPermKey, key)
|
||||
|
||||
continue
|
||||
}
|
||||
if gotValue != expectedValue {
|
||||
t.Errorf("permission %q: got %q, want %q", key, gotValue, expectedValue)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify expected indent was set if it was -1
|
||||
if initialExpected == -1 && len(expectPermissions) > 0 {
|
||||
if expectedIndent == -1 {
|
||||
t.Error("expectedIndent should have been set from -1")
|
||||
}
|
||||
}
|
||||
}
|
||||
269
internal/parser_property_test.go
Normal file
269
internal/parser_property_test.go
Normal file
@@ -0,0 +1,269 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/leanovate/gopter"
|
||||
"github.com/leanovate/gopter/gen"
|
||||
"github.com/leanovate/gopter/prop"
|
||||
)
|
||||
|
||||
// TestPermissionMergingProperties verifies properties of permission merging.
|
||||
func TestPermissionMergingProperties(t *testing.T) {
|
||||
properties := gopter.NewProperties(nil)
|
||||
registerPermissionMergingProperties(properties)
|
||||
properties.TestingRun(t)
|
||||
}
|
||||
|
||||
// registerPermissionMergingProperties registers all permission merging property tests.
|
||||
func registerPermissionMergingProperties(properties *gopter.Properties) {
|
||||
registerYAMLOverridesProperty(properties)
|
||||
registerNonConflictingKeysProperty(properties)
|
||||
registerNilPreservesOriginalProperty(properties)
|
||||
registerEmptyMapPreservesOriginalProperty(properties)
|
||||
registerResultSizeBoundedProperty(properties)
|
||||
}
|
||||
|
||||
// registerYAMLOverridesProperty tests that YAML permissions override comment permissions.
|
||||
func registerYAMLOverridesProperty(properties *gopter.Properties) {
|
||||
properties.Property("YAML permissions override comment permissions",
|
||||
prop.ForAll(
|
||||
func(key, yamlVal, commentVal string) bool {
|
||||
if yamlVal == commentVal || yamlVal == "" || key == "" || commentVal == "" {
|
||||
return true
|
||||
}
|
||||
action := &ActionYML{Permissions: map[string]string{key: yamlVal}}
|
||||
mergePermissions(action, map[string]string{key: commentVal})
|
||||
|
||||
return action.Permissions[key] == yamlVal
|
||||
},
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return s != "" }),
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return s != "" }),
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return s != "" }),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// registerNonConflictingKeysProperty tests that non-conflicting keys are preserved.
|
||||
func registerNonConflictingKeysProperty(properties *gopter.Properties) {
|
||||
properties.Property("merge preserves all non-conflicting keys",
|
||||
prop.ForAll(
|
||||
func(yamlKey, commentKey, val string) bool {
|
||||
if yamlKey == commentKey || yamlKey == "" || commentKey == "" || val == "" {
|
||||
return true
|
||||
}
|
||||
action := &ActionYML{Permissions: map[string]string{yamlKey: val}}
|
||||
mergePermissions(action, map[string]string{commentKey: val})
|
||||
_, hasYaml := action.Permissions[yamlKey]
|
||||
_, hasComment := action.Permissions[commentKey]
|
||||
|
||||
return hasYaml && hasComment
|
||||
},
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return s != "" }),
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return s != "" }),
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return s != "" }),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// registerNilPreservesOriginalProperty tests merging with nil preserves original.
|
||||
func registerNilPreservesOriginalProperty(properties *gopter.Properties) {
|
||||
properties.Property("merging with nil preserves original permissions",
|
||||
prop.ForAll(
|
||||
func(key, value string) bool {
|
||||
return verifyMergePreservesOriginal(key, value, nil)
|
||||
},
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return s != "" }),
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return s != "" }),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// registerEmptyMapPreservesOriginalProperty tests merging with empty map preserves original.
|
||||
func registerEmptyMapPreservesOriginalProperty(properties *gopter.Properties) {
|
||||
properties.Property("merging with empty map preserves original permissions",
|
||||
prop.ForAll(
|
||||
func(key, value string) bool {
|
||||
return verifyMergePreservesOriginal(key, value, make(map[string]string))
|
||||
},
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return s != "" }),
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return s != "" }),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// registerResultSizeBoundedProperty tests result size is bounded by sum of inputs.
|
||||
func registerResultSizeBoundedProperty(properties *gopter.Properties) {
|
||||
properties.Property("merged permissions size bounded by sum of inputs",
|
||||
prop.ForAll(
|
||||
verifyMergedSizeBounded,
|
||||
gen.SliceOf(gen.AlphaString()),
|
||||
gen.SliceOf(gen.AlphaString()),
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return s != "" }),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// verifyMergedSizeBounded checks that merged result size is bounded.
|
||||
func verifyMergedSizeBounded(yamlKeys, commentKeys []string, value string) bool {
|
||||
if len(yamlKeys) == 0 || len(commentKeys) == 0 || value == "" {
|
||||
return true
|
||||
}
|
||||
yamlPerms := make(map[string]string)
|
||||
for _, key := range yamlKeys {
|
||||
if key != "" {
|
||||
yamlPerms[key] = value
|
||||
}
|
||||
}
|
||||
commentPerms := make(map[string]string)
|
||||
for _, key := range commentKeys {
|
||||
if key != "" {
|
||||
commentPerms[key] = value
|
||||
}
|
||||
}
|
||||
action := &ActionYML{Permissions: yamlPerms}
|
||||
mergePermissions(action, commentPerms)
|
||||
|
||||
return len(action.Permissions) <= len(yamlPerms)+len(commentPerms)
|
||||
}
|
||||
|
||||
// TestActionYMLNilPermissionsProperties verifies behavior when permissions is nil.
|
||||
func TestActionYMLNilPermissionsProperties(t *testing.T) {
|
||||
properties := gopter.NewProperties(nil)
|
||||
|
||||
// Property 1: Merging into nil permissions creates new map
|
||||
properties.Property("merging into nil permissions creates new map",
|
||||
prop.ForAll(
|
||||
func(key, value string) bool {
|
||||
if key == "" || value == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
action := &ActionYML{
|
||||
Permissions: nil,
|
||||
}
|
||||
|
||||
commentPerms := map[string]string{key: value}
|
||||
mergePermissions(action, commentPerms)
|
||||
|
||||
// Should create new map with comment permissions
|
||||
if action.Permissions == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return action.Permissions[key] == value
|
||||
},
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return s != "" }),
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return s != "" }),
|
||||
),
|
||||
)
|
||||
|
||||
// Property 2: Nil action permissions stays nil when merging with nil
|
||||
properties.Property("nil permissions stays nil when merging with nil",
|
||||
prop.ForAll(
|
||||
func() bool {
|
||||
action := &ActionYML{
|
||||
Permissions: nil,
|
||||
}
|
||||
|
||||
mergePermissions(action, nil)
|
||||
|
||||
// Should remain nil (no map created)
|
||||
return action.Permissions == nil
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
properties.TestingRun(t)
|
||||
}
|
||||
|
||||
// TestCommentPermissionsOnlyProperties verifies behavior when only comment permissions exist.
|
||||
//
|
||||
|
||||
func TestCommentPermissionsOnlyProperties(t *testing.T) {
|
||||
properties := gopter.NewProperties(nil)
|
||||
registerCommentPermissionsOnlyProperties(properties)
|
||||
properties.TestingRun(t)
|
||||
}
|
||||
|
||||
func registerCommentPermissionsOnlyProperties(properties *gopter.Properties) {
|
||||
// Property: All comment permissions transferred when YAML is nil
|
||||
properties.Property("all comment permissions transferred when YAML is nil",
|
||||
prop.ForAll(
|
||||
verifyCommentPermissionsTransferred,
|
||||
gen.SliceOf(gen.AlphaString().SuchThat(func(s string) bool { return s != "" })),
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return s != "" }),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func verifyCommentPermissionsTransferred(keys []string, value string) bool {
|
||||
if len(keys) == 0 || value == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Build comment permissions
|
||||
commentPerms := make(map[string]string)
|
||||
for _, key := range keys {
|
||||
if key != "" {
|
||||
commentPerms[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
if len(commentPerms) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
action := &ActionYML{
|
||||
Permissions: nil,
|
||||
}
|
||||
|
||||
mergePermissions(action, commentPerms)
|
||||
|
||||
// All comment permissions should be in action
|
||||
if action.Permissions == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for key, val := range commentPerms {
|
||||
if action.Permissions[key] != val {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// verifyMergePreservesOriginal is a helper to test that merging with
|
||||
// nil or empty permissions preserves the original permissions.
|
||||
func verifyMergePreservesOriginal(key, value string, mergeWith map[string]string) bool {
|
||||
if key == "" || value == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
action := &ActionYML{
|
||||
Permissions: map[string]string{key: value},
|
||||
}
|
||||
|
||||
// Make a copy to compare
|
||||
originalPerms := make(map[string]string)
|
||||
for k, v := range action.Permissions {
|
||||
originalPerms[k] = v
|
||||
}
|
||||
|
||||
// Merge with provided map (nil or empty)
|
||||
mergePermissions(action, mergeWith)
|
||||
|
||||
// Should be unchanged
|
||||
if len(action.Permissions) != len(originalPerms) {
|
||||
return false
|
||||
}
|
||||
|
||||
for k, v := range originalPerms {
|
||||
if action.Permissions[k] != v {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
787
internal/parser_test.go
Normal file
787
internal/parser_test.go
Normal file
@@ -0,0 +1,787 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
const testPermissionWrite = "write"
|
||||
|
||||
// parseActionFromContent creates a temporary action.yml file with the given content and parses it.
|
||||
func parseActionFromContent(t *testing.T, content string) (*ActionYML, error) {
|
||||
t.Helper()
|
||||
|
||||
actionPath := testutil.CreateTempActionFile(t, content)
|
||||
|
||||
return ParseActionYML(actionPath)
|
||||
}
|
||||
|
||||
// validateDiscoveredFiles checks if discovered files match expected count and paths.
|
||||
func validateDiscoveredFiles(t *testing.T, files []string, wantCount int, wantPaths []string) {
|
||||
t.Helper()
|
||||
|
||||
if len(files) != wantCount {
|
||||
t.Errorf("DiscoverActionFiles() returned %d files, want %d", len(files), wantCount)
|
||||
t.Logf("Got files: %v", files)
|
||||
t.Logf("Want files: %v", wantPaths)
|
||||
}
|
||||
|
||||
// Check that all expected files are present
|
||||
fileMap := make(map[string]bool)
|
||||
for _, f := range files {
|
||||
fileMap[f] = true
|
||||
}
|
||||
|
||||
for _, wantPath := range wantPaths {
|
||||
if !fileMap[wantPath] {
|
||||
t.Errorf("Expected file %s not found in results", wantPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestShouldIgnoreDirectory tests the directory filtering logic.
|
||||
func TestShouldIgnoreDirectory(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dirName string
|
||||
ignoredDirs []string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "exact match - node_modules",
|
||||
dirName: appconstants.DirNodeModules,
|
||||
ignoredDirs: []string{appconstants.DirNodeModules, appconstants.DirVendor},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "exact match - vendor",
|
||||
dirName: appconstants.DirVendor,
|
||||
ignoredDirs: []string{appconstants.DirNodeModules, appconstants.DirVendor},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: testutil.TestCaseNameNoMatch,
|
||||
dirName: "src",
|
||||
ignoredDirs: []string{appconstants.DirNodeModules, appconstants.DirVendor},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "empty ignore list",
|
||||
dirName: appconstants.DirNodeModules,
|
||||
ignoredDirs: []string{},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "dot prefix match - .git",
|
||||
dirName: appconstants.DirGit,
|
||||
ignoredDirs: []string{appconstants.DirGit},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "dot prefix pattern match - .github",
|
||||
dirName: appconstants.DirGitHub,
|
||||
ignoredDirs: []string{appconstants.DirGit},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "dot prefix pattern match - .gitlab",
|
||||
dirName: appconstants.DirGitLab,
|
||||
ignoredDirs: []string{appconstants.DirGit},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "dot prefix no match",
|
||||
dirName: ".config",
|
||||
ignoredDirs: []string{appconstants.DirGit},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "case sensitive - NODE_MODULES vs node_modules",
|
||||
dirName: "NODE_MODULES",
|
||||
ignoredDirs: []string{appconstants.DirNodeModules},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "partial name not matched",
|
||||
dirName: "my_vendor",
|
||||
ignoredDirs: []string{appconstants.DirVendor},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := shouldIgnoreDirectory(tt.dirName, tt.ignoredDirs)
|
||||
if got != tt.want {
|
||||
t.Errorf("shouldIgnoreDirectory(%q, %v) = %v, want %v",
|
||||
tt.dirName, tt.ignoredDirs, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiscoverActionFilesWithIgnoredDirectories tests file discovery with directory filtering.
|
||||
func TestDiscoverActionFilesWithIgnoredDirectories(t *testing.T) {
|
||||
// Create temporary directory structure
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create directory structure:
|
||||
// tmpDir/
|
||||
// action.yml (should be found)
|
||||
// node_modules/
|
||||
// action.yml (should be ignored)
|
||||
// vendor/
|
||||
// action.yml (should be ignored)
|
||||
// .git/
|
||||
// action.yml (should be ignored)
|
||||
// src/
|
||||
// action.yml (should be found)
|
||||
|
||||
// Create root action.yml
|
||||
rootAction := testutil.WriteFileInDir(t, tmpDir, appconstants.ActionFileNameYML, testutil.TestYAMLRoot)
|
||||
|
||||
// Create directories with action.yml files
|
||||
_, nodeModulesAction := testutil.CreateNestedAction(
|
||||
t,
|
||||
tmpDir,
|
||||
appconstants.DirNodeModules,
|
||||
testutil.TestYAMLNodeModules,
|
||||
)
|
||||
_, vendorAction := testutil.CreateNestedAction(t, tmpDir, appconstants.DirVendor, testutil.TestYAMLVendor)
|
||||
_, gitAction := testutil.CreateNestedAction(t, tmpDir, appconstants.DirGit, testutil.TestYAMLGit)
|
||||
_, srcAction := testutil.CreateNestedAction(t, tmpDir, "src", testutil.TestYAMLSrc)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ignoredDirs []string
|
||||
wantCount int
|
||||
wantPaths []string
|
||||
}{
|
||||
{
|
||||
name: "with default ignore list",
|
||||
ignoredDirs: []string{appconstants.DirGit, appconstants.DirNodeModules, appconstants.DirVendor},
|
||||
wantCount: 2,
|
||||
wantPaths: []string{rootAction, srcAction},
|
||||
},
|
||||
{
|
||||
name: "with empty ignore list",
|
||||
ignoredDirs: []string{},
|
||||
wantCount: 5,
|
||||
wantPaths: []string{rootAction, gitAction, nodeModulesAction, srcAction, vendorAction},
|
||||
},
|
||||
{
|
||||
name: "ignore only node_modules",
|
||||
ignoredDirs: []string{appconstants.DirNodeModules},
|
||||
wantCount: 4,
|
||||
wantPaths: []string{rootAction, gitAction, srcAction, vendorAction},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
files, err := DiscoverActionFiles(tmpDir, true, tt.ignoredDirs)
|
||||
if err != nil {
|
||||
t.Fatalf(testutil.ErrDiscoverActionFiles(), err)
|
||||
}
|
||||
|
||||
validateDiscoveredFiles(t, files, tt.wantCount, tt.wantPaths)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiscoverActionFilesNestedIgnoredDirs tests that subdirectories of ignored dirs are skipped.
|
||||
func TestDiscoverActionFilesNestedIgnoredDirs(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create directory structure:
|
||||
// tmpDir/
|
||||
// node_modules/
|
||||
// deep/
|
||||
// nested/
|
||||
// action.yml (should be ignored)
|
||||
|
||||
nodeModulesDir := testutil.CreateTestSubdir(t, tmpDir, appconstants.DirNodeModules, "deep", "nested")
|
||||
|
||||
testutil.WriteFileInDir(t, nodeModulesDir, appconstants.ActionFileNameYML, testutil.TestYAMLNested)
|
||||
|
||||
files, err := DiscoverActionFiles(tmpDir, true, []string{appconstants.DirNodeModules})
|
||||
if err != nil {
|
||||
t.Fatalf(testutil.ErrDiscoverActionFiles(), err)
|
||||
}
|
||||
|
||||
if len(files) != 0 {
|
||||
t.Errorf("DiscoverActionFiles() returned %d files, want 0 (nested dirs should be skipped)", len(files))
|
||||
t.Logf("Got files: %v", files)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiscoverActionFilesNonRecursive tests that non-recursive mode ignores the filter.
|
||||
func TestDiscoverActionFilesNonRecursive(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create action.yml in root
|
||||
rootAction := testutil.WriteFileInDir(t, tmpDir, appconstants.ActionFileNameYML, testutil.TestYAMLRoot)
|
||||
|
||||
// Create subdirectory (should not be searched in non-recursive mode)
|
||||
subDir := filepath.Join(tmpDir, "sub")
|
||||
if err := os.Mkdir(subDir, appconstants.FilePermDir); err != nil {
|
||||
t.Fatalf(testutil.ErrCreateDir("sub"), err)
|
||||
}
|
||||
testutil.WriteFileInDir(t, subDir, appconstants.ActionFileNameYML, testutil.TestYAMLSub)
|
||||
|
||||
files, err := DiscoverActionFiles(tmpDir, false, []string{})
|
||||
if err != nil {
|
||||
t.Fatalf(testutil.ErrDiscoverActionFiles(), err)
|
||||
}
|
||||
|
||||
if len(files) != 1 {
|
||||
t.Errorf("DiscoverActionFiles() non-recursive returned %d files, want 1", len(files))
|
||||
}
|
||||
|
||||
if len(files) > 0 && files[0] != rootAction {
|
||||
t.Errorf("DiscoverActionFiles() = %v, want %v", files[0], rootAction)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParsePermissionsFromComments tests parsing permissions from header comments.
|
||||
func TestParsePermissionsFromComments(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
want map[string]string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "single permission with dash format",
|
||||
content: string(testutil.MustReadFixture(testutil.TestFixturePermissionsDashSingle)),
|
||||
want: map[string]string{
|
||||
"contents": "read",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "multiple permissions",
|
||||
content: string(testutil.MustReadFixture(testutil.TestFixturePermissionsDashMultiple)),
|
||||
want: map[string]string{
|
||||
"contents": "read",
|
||||
"issues": "write",
|
||||
"pull-requests": "write",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "permissions without dash",
|
||||
content: string(testutil.MustReadFixture(testutil.TestFixturePermissionsObject)),
|
||||
want: map[string]string{
|
||||
"contents": "read",
|
||||
"issues": "write",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "no permissions block",
|
||||
content: string(testutil.MustReadFixture(testutil.TestFixturePermissionsNone)),
|
||||
want: map[string]string{},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "permissions with inline comments",
|
||||
content: string(testutil.MustReadFixture(testutil.TestFixturePermissionsInlineComments)),
|
||||
want: map[string]string{
|
||||
"contents": "read",
|
||||
"issues": "write",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty permissions block",
|
||||
content: string(testutil.MustReadFixture(testutil.TestFixturePermissionsEmpty)),
|
||||
want: map[string]string{},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "permissions with mixed formats",
|
||||
content: string(testutil.MustReadFixture(testutil.TestFixturePermissionsMixed)),
|
||||
want: map[string]string{
|
||||
"contents": "read",
|
||||
"issues": "write",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
actionPath := testutil.CreateTempActionFile(t, tt.content)
|
||||
got, err := parsePermissionsFromComments(actionPath)
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("parsePermissionsFromComments() error = %v, wantErr %v", err, tt.wantErr)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("parsePermissionsFromComments() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseActionYMLWithCommentPermissions tests that ParseActionYML includes comment permissions.
|
||||
func TestParseActionYMLWithCommentPermissions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
content := testutil.TestPermissionsHeader +
|
||||
"# - contents: read\n" +
|
||||
testutil.TestActionNameLine +
|
||||
testutil.TestDescriptionLine +
|
||||
testutil.TestRunsLine +
|
||||
testutil.TestCompositeUsing +
|
||||
testutil.TestStepsEmpty
|
||||
|
||||
action, err := parseActionFromContent(t, content)
|
||||
if err != nil {
|
||||
t.Fatalf(testutil.TestErrorFormat, err)
|
||||
}
|
||||
|
||||
if action.Permissions == nil {
|
||||
t.Fatal("Expected permissions to be parsed from comments")
|
||||
}
|
||||
|
||||
if action.Permissions["contents"] != "read" {
|
||||
t.Errorf("Expected contents: read, got %v", action.Permissions)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseActionYMLYAMLPermissionsOverrideComments tests that YAML permissions override comments.
|
||||
func TestParseActionYMLYAMLPermissionsOverrideComments(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
content := testutil.TestPermissionsHeader +
|
||||
"# - contents: read\n" +
|
||||
"# - issues: write\n" +
|
||||
testutil.TestActionNameLine +
|
||||
testutil.TestDescriptionLine +
|
||||
"permissions:\n" +
|
||||
" contents: write # YAML override\n" +
|
||||
testutil.TestRunsLine +
|
||||
testutil.TestCompositeUsing +
|
||||
testutil.TestStepsEmpty
|
||||
|
||||
action, err := parseActionFromContent(t, content)
|
||||
if err != nil {
|
||||
t.Fatalf(testutil.TestErrorFormat, err)
|
||||
}
|
||||
|
||||
// YAML should override comment
|
||||
if action.Permissions["contents"] != testPermissionWrite {
|
||||
t.Errorf(
|
||||
"Expected YAML permissions to override comment permissions, got contents: %v",
|
||||
action.Permissions["contents"],
|
||||
)
|
||||
}
|
||||
|
||||
// Comment permission should be merged in
|
||||
if action.Permissions["issues"] != testPermissionWrite {
|
||||
t.Errorf(
|
||||
"Expected comment permissions to be merged with YAML permissions, got issues: %v",
|
||||
action.Permissions["issues"],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseActionYMLOnlyYAMLPermissions tests parsing when only YAML permissions exist.
|
||||
func TestParseActionYMLOnlyYAMLPermissions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
content := testutil.TestActionNameLine +
|
||||
testutil.TestDescriptionLine +
|
||||
"permissions:\n" +
|
||||
" contents: read\n" +
|
||||
" issues: write\n" +
|
||||
testutil.TestRunsLine +
|
||||
testutil.TestCompositeUsing +
|
||||
testutil.TestStepsEmpty
|
||||
|
||||
action, err := parseActionFromContent(t, content)
|
||||
if err != nil {
|
||||
t.Fatalf(testutil.TestErrorFormat, err)
|
||||
}
|
||||
|
||||
if action.Permissions == nil {
|
||||
t.Fatal("Expected permissions to be parsed from YAML")
|
||||
}
|
||||
|
||||
if action.Permissions["contents"] != "read" {
|
||||
t.Errorf("Expected contents: read, got %v", action.Permissions)
|
||||
}
|
||||
|
||||
if action.Permissions["issues"] != testPermissionWrite {
|
||||
t.Errorf("Expected issues: write, got %v", action.Permissions)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseActionYMLNoPermissions tests parsing when no permissions exist.
|
||||
func TestParseActionYMLNoPermissions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
content := testutil.TestActionNameLine +
|
||||
testutil.TestDescriptionLine +
|
||||
testutil.TestRunsLine +
|
||||
testutil.TestCompositeUsing +
|
||||
testutil.TestStepsEmpty
|
||||
|
||||
action, err := parseActionFromContent(t, content)
|
||||
if err != nil {
|
||||
t.Fatalf(testutil.TestErrorFormat, err)
|
||||
}
|
||||
|
||||
if action.Permissions != nil {
|
||||
t.Errorf("Expected no permissions, got %v", action.Permissions)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseActionYMLMalformedYAML tests parsing with malformed YAML.
|
||||
func TestParseActionYMLMalformedYAML(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
content := testutil.TestActionNameLine +
|
||||
testutil.TestDescriptionLine +
|
||||
"invalid-yaml: [\n" + // Unclosed bracket
|
||||
" - item"
|
||||
|
||||
_, err := parseActionFromContent(t, content)
|
||||
if err == nil {
|
||||
t.Error("Expected error for malformed YAML, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseActionYMLEmptyFile tests parsing an empty file.
|
||||
func TestParseActionYMLEmptyFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
actionPath := testutil.CreateTempActionFile(t, "")
|
||||
_, err := ParseActionYML(actionPath)
|
||||
// Empty file should return EOF error from YAML parser
|
||||
if err == nil {
|
||||
t.Error("Expected EOF error for empty file, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParsePermissionLineEdgeCases tests edge cases in permission line parsing.
|
||||
func TestParsePermissionLineEdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantKey string
|
||||
wantValue string
|
||||
wantOK bool
|
||||
}{
|
||||
{
|
||||
name: "comment at start is parsed",
|
||||
input: "#contents: read",
|
||||
wantKey: "#contents",
|
||||
wantValue: "read",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "empty value after colon",
|
||||
input: "contents:",
|
||||
wantKey: "",
|
||||
wantValue: "",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "only spaces after colon",
|
||||
input: "contents: ",
|
||||
wantKey: "",
|
||||
wantValue: "",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "valid with inline comment",
|
||||
input: "contents: read # required",
|
||||
wantKey: "contents",
|
||||
wantValue: "read",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "valid with leading dash",
|
||||
input: "- issues: write",
|
||||
wantKey: "issues",
|
||||
wantValue: "write",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "no colon",
|
||||
input: "invalid permission line",
|
||||
wantKey: "",
|
||||
wantValue: "",
|
||||
wantOK: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
key, value, ok := parsePermissionLine(tt.input)
|
||||
|
||||
if ok != tt.wantOK {
|
||||
t.Errorf("parsePermissionLine() ok = %v, want %v", ok, tt.wantOK)
|
||||
}
|
||||
|
||||
if key != tt.wantKey {
|
||||
t.Errorf("parsePermissionLine() key = %q, want %q", key, tt.wantKey)
|
||||
}
|
||||
|
||||
if value != tt.wantValue {
|
||||
t.Errorf("parsePermissionLine() value = %q, want %q", value, tt.wantValue)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestProcessPermissionEntryIndentationEdgeCases tests indentation scenarios.
|
||||
func TestProcessPermissionEntryIndentationEdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
line string
|
||||
content string
|
||||
initialIndent int
|
||||
wantBreak bool
|
||||
wantPermissionsLen int
|
||||
}{
|
||||
{
|
||||
name: "first item sets indent",
|
||||
line: testutil.TestContentsRead,
|
||||
content: "contents: read",
|
||||
initialIndent: -1,
|
||||
wantBreak: false,
|
||||
wantPermissionsLen: 1,
|
||||
},
|
||||
{
|
||||
name: "dedented breaks",
|
||||
line: "# contents: read",
|
||||
content: "contents: read",
|
||||
initialIndent: 2,
|
||||
wantBreak: true,
|
||||
wantPermissionsLen: 0,
|
||||
},
|
||||
{
|
||||
name: "same indent continues",
|
||||
line: "# issues: write",
|
||||
content: "issues: write",
|
||||
initialIndent: 3,
|
||||
wantBreak: false,
|
||||
wantPermissionsLen: 1,
|
||||
},
|
||||
{
|
||||
name: "invalid format skipped",
|
||||
line: "# invalid-line-no-colon",
|
||||
content: "invalid-line-no-colon",
|
||||
initialIndent: 3,
|
||||
wantBreak: false,
|
||||
wantPermissionsLen: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
permissions := make(map[string]string)
|
||||
indent := tt.initialIndent
|
||||
|
||||
shouldBreak := processPermissionEntry(tt.line, tt.content, &indent, permissions)
|
||||
|
||||
if shouldBreak != tt.wantBreak {
|
||||
t.Errorf("processPermissionEntry() shouldBreak = %v, want %v", shouldBreak, tt.wantBreak)
|
||||
}
|
||||
|
||||
if len(permissions) != tt.wantPermissionsLen {
|
||||
t.Errorf(
|
||||
"processPermissionEntry() permissions length = %d, want %d",
|
||||
len(permissions),
|
||||
tt.wantPermissionsLen,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParsePermissionsFromCommentsEdgeCases tests edge cases in comment parsing.
|
||||
func TestParsePermissionsFromCommentsEdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
wantPerms map[string]string
|
||||
wantErr bool
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "duplicate permissions",
|
||||
content: testutil.TestPermissionsHeader +
|
||||
testutil.TestContentsRead +
|
||||
"# contents: write\n",
|
||||
wantPerms: map[string]string{"contents": "write"},
|
||||
wantErr: false,
|
||||
description: "last value wins",
|
||||
},
|
||||
{
|
||||
name: "mixed valid and invalid lines",
|
||||
content: testutil.TestPermissionsHeader +
|
||||
testutil.TestContentsRead +
|
||||
"# invalid-line-no-value\n" +
|
||||
"# issues: write\n",
|
||||
wantPerms: map[string]string{"contents": "read", "issues": "write"},
|
||||
wantErr: false,
|
||||
description: "invalid lines skipped",
|
||||
},
|
||||
{
|
||||
name: "permissions block ends at non-comment",
|
||||
content: testutil.TestPermissionsHeader +
|
||||
testutil.TestContentsRead +
|
||||
testutil.TestActionNameLine +
|
||||
"# issues: write\n",
|
||||
wantPerms: map[string]string{"contents": "read"},
|
||||
wantErr: false,
|
||||
description: "stops at first non-comment",
|
||||
},
|
||||
{
|
||||
name: "only permissions header",
|
||||
content: testutil.TestPermissionsHeader +
|
||||
testutil.TestActionNameLine,
|
||||
wantPerms: map[string]string{},
|
||||
wantErr: false,
|
||||
description: "empty permissions block",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
actionPath := testutil.CreateTempActionFile(t, tt.content)
|
||||
perms, err := parsePermissionsFromComments(actionPath)
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("parsePermissionsFromComments() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(perms, tt.wantPerms) {
|
||||
t.Errorf("parsePermissionsFromComments() = %v, want %v (%s)", perms, tt.wantPerms, tt.description)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMergePermissionsEdgeCases tests permission merging edge cases.
|
||||
func TestMergePermissionsEdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
yamlPerms map[string]string
|
||||
commentPerms map[string]string
|
||||
wantPerms map[string]string
|
||||
}{
|
||||
{
|
||||
name: "both nil",
|
||||
yamlPerms: nil,
|
||||
commentPerms: nil,
|
||||
wantPerms: nil,
|
||||
},
|
||||
{
|
||||
name: "yaml nil, comments empty",
|
||||
yamlPerms: nil,
|
||||
commentPerms: map[string]string{},
|
||||
wantPerms: nil,
|
||||
},
|
||||
{
|
||||
name: "yaml empty, comments nil",
|
||||
yamlPerms: map[string]string{},
|
||||
commentPerms: nil,
|
||||
wantPerms: map[string]string{},
|
||||
},
|
||||
{
|
||||
name: "yaml has value, comments override",
|
||||
yamlPerms: map[string]string{"contents": "read"},
|
||||
commentPerms: map[string]string{"issues": "write"},
|
||||
wantPerms: map[string]string{"contents": "read", "issues": "write"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
action := &ActionYML{Permissions: tt.yamlPerms}
|
||||
mergePermissions(action, tt.commentPerms)
|
||||
|
||||
if !reflect.DeepEqual(action.Permissions, tt.wantPerms) {
|
||||
t.Errorf("mergePermissions() = %v, want %v", action.Permissions, tt.wantPerms)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiscoverActionFilesWalkErrors tests error handling during directory walk.
|
||||
func TestDiscoverActionFilesWalkErrors(t *testing.T) {
|
||||
// Test with a path that doesn't exist
|
||||
_, err := DiscoverActionFiles("/nonexistent/path/that/does/not/exist", true, []string{})
|
||||
if err == nil {
|
||||
t.Error("Expected error for nonexistent directory, got nil")
|
||||
}
|
||||
|
||||
// Test that error message mentions the path
|
||||
if err != nil && !strings.Contains(err.Error(), "/nonexistent/path/that/does/not/exist") {
|
||||
t.Errorf("Expected error to mention path, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWalkFuncErrorHandling tests walkFunc error propagation.
|
||||
func TestWalkFuncErrorHandling(t *testing.T) {
|
||||
walker := &actionFileWalker{
|
||||
ignoredDirs: []string{},
|
||||
actionFiles: []string{},
|
||||
}
|
||||
|
||||
// Create a valid FileInfo for testing
|
||||
tmpDir := t.TempDir()
|
||||
info, err := os.Stat(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to stat temp dir: %v", err)
|
||||
}
|
||||
|
||||
// Test with valid directory - should return nil
|
||||
err = walker.walkFunc(tmpDir, info, nil)
|
||||
if err != nil {
|
||||
t.Errorf("walkFunc() with valid directory should return nil, got: %v", err)
|
||||
}
|
||||
|
||||
// Test with pre-existing error - should propagate
|
||||
testErr := filepath.SkipDir
|
||||
err = walker.walkFunc(tmpDir, info, testErr)
|
||||
if err != testErr {
|
||||
t.Errorf("walkFunc() should propagate error, "+testutil.TestMsgGotWant, err, testErr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseActionYMLOnlyComments tests file with only comments.
|
||||
func TestParseActionYMLOnlyComments(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
content := "# This is a comment\n" +
|
||||
"# Another comment\n" +
|
||||
testutil.TestPermissionsHeader +
|
||||
testutil.TestContentsRead
|
||||
|
||||
_, err := parseActionFromContent(t, content)
|
||||
// File with only comments should return EOF error from YAML parser
|
||||
// (comments are parsed separately, but YAML decoder still needs valid YAML)
|
||||
if err == nil {
|
||||
t.Error("Expected EOF error for comment-only file, got nil")
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/schollz/progressbar/v3"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
func TestProgressBarManager_CreateProgressBar(t *testing.T) {
|
||||
func TestProgressBarManagerCreateProgressBar(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -18,28 +21,28 @@ func TestProgressBarManager_CreateProgressBar(t *testing.T) {
|
||||
{
|
||||
name: "normal progress bar",
|
||||
quiet: false,
|
||||
description: "Test progress",
|
||||
description: testutil.TestProgressDescription,
|
||||
total: 10,
|
||||
expectNil: false,
|
||||
},
|
||||
{
|
||||
name: "quiet mode returns nil",
|
||||
quiet: true,
|
||||
description: "Test progress",
|
||||
description: testutil.TestProgressDescription,
|
||||
total: 10,
|
||||
expectNil: true,
|
||||
},
|
||||
{
|
||||
name: "single item returns nil",
|
||||
quiet: false,
|
||||
description: "Test progress",
|
||||
description: testutil.TestProgressDescription,
|
||||
total: 1,
|
||||
expectNil: true,
|
||||
},
|
||||
{
|
||||
name: "zero items returns nil",
|
||||
quiet: false,
|
||||
description: "Test progress",
|
||||
description: testutil.TestProgressDescription,
|
||||
total: 0,
|
||||
expectNil: true,
|
||||
},
|
||||
@@ -64,7 +67,7 @@ func TestProgressBarManager_CreateProgressBar(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressBarManager_CreateProgressBarForFiles(t *testing.T) {
|
||||
func TestProgressBarManagerCreateProgressBarForFiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
pm := NewProgressBarManager(false)
|
||||
files := []string{"file1.yml", "file2.yml", "file3.yml"}
|
||||
@@ -76,33 +79,44 @@ func TestProgressBarManager_CreateProgressBarForFiles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressBarManager_FinishProgressBar(t *testing.T) {
|
||||
func TestProgressBarManagerNilSafeOperations(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Use quiet mode to avoid cluttering test output
|
||||
pm := NewProgressBarManager(true)
|
||||
|
||||
// Test with nil bar (should not panic)
|
||||
pm.FinishProgressBar(nil)
|
||||
tests := []struct {
|
||||
name string
|
||||
operation func(*ProgressBarManager, *progressbar.ProgressBar)
|
||||
}{
|
||||
{
|
||||
name: "FinishProgressBar handles nil",
|
||||
operation: func(pm *ProgressBarManager, bar *progressbar.ProgressBar) {
|
||||
pm.FinishProgressBar(bar)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "UpdateProgressBar handles nil",
|
||||
operation: func(pm *ProgressBarManager, bar *progressbar.ProgressBar) {
|
||||
pm.UpdateProgressBar(bar)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Test with actual bar (will be nil in quiet mode)
|
||||
bar := pm.CreateProgressBar("Test", 5)
|
||||
pm.FinishProgressBar(bar) // Should handle nil gracefully
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Use quiet mode to avoid cluttering test output
|
||||
pm := NewProgressBarManager(true)
|
||||
|
||||
// Should not panic with nil
|
||||
tt.operation(pm, nil)
|
||||
|
||||
// Should not panic with actual bar (will be nil in quiet mode)
|
||||
bar := pm.CreateProgressBar("Test", 5)
|
||||
tt.operation(pm, bar)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressBarManager_UpdateProgressBar(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Use quiet mode to avoid cluttering test output
|
||||
pm := NewProgressBarManager(true)
|
||||
|
||||
// Test with nil bar (should not panic)
|
||||
pm.UpdateProgressBar(nil)
|
||||
|
||||
// Test with actual bar (will be nil in quiet mode)
|
||||
bar := pm.CreateProgressBar("Test", 5)
|
||||
pm.UpdateProgressBar(bar) // Should handle nil gracefully
|
||||
}
|
||||
|
||||
func TestProgressBarManager_ProcessWithProgressBar(t *testing.T) {
|
||||
func TestProgressBarManagerProcessWithProgressBar(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Use NullProgressManager to avoid cluttering test output
|
||||
pm := NewNullProgressManager()
|
||||
@@ -126,7 +140,7 @@ func TestProgressBarManager_ProcessWithProgressBar(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressBarManager_ProcessWithProgressBar_QuietMode(t *testing.T) {
|
||||
func TestProgressBarManagerProcessWithProgressBarQuietMode(t *testing.T) {
|
||||
t.Parallel()
|
||||
pm := NewProgressBarManager(true) // quiet mode
|
||||
items := []string{"item1", "item2"}
|
||||
@@ -146,3 +160,32 @@ func TestProgressBarManager_ProcessWithProgressBar_QuietMode(t *testing.T) {
|
||||
t.Errorf("expected %d processed items, got %d", len(items), len(processedItems))
|
||||
}
|
||||
}
|
||||
|
||||
// TestProgressBarManagerFinishProgressBarWithNewline tests finishing with newline.
|
||||
func TestProgressBarManagerFinishProgressBarWithNewline(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
bar *progressbar.ProgressBar
|
||||
}{
|
||||
{
|
||||
name: "with valid progress bar",
|
||||
bar: progressbar.NewOptions(10, progressbar.OptionSetWriter(io.Discard)),
|
||||
},
|
||||
{
|
||||
name: "with nil progress bar",
|
||||
bar: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pm := NewProgressBarManager(false)
|
||||
// Should not panic
|
||||
pm.FinishProgressBarWithNewline(tt.bar)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,22 +2,18 @@ package internal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"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"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/validation"
|
||||
"github.com/ivuorinen/gh-action-readme/templates_embed"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultOrgPlaceholder = "your-org"
|
||||
defaultRepoPlaceholder = "your-repo"
|
||||
defaultUsesPlaceholder = "your-org/your-action@v1"
|
||||
templatesembed "github.com/ivuorinen/gh-action-readme/templates_embed"
|
||||
)
|
||||
|
||||
// TemplateOptions defines options for rendering templates.
|
||||
@@ -42,6 +38,10 @@ type TemplateData struct {
|
||||
// Computed Values
|
||||
UsesStatement string `json:"uses_statement"`
|
||||
|
||||
// Path information for subdirectory extraction
|
||||
ActionPath string `json:"action_path,omitempty"`
|
||||
RepoRoot string `json:"repo_root,omitempty"`
|
||||
|
||||
// Dependencies (populated by dependency analysis)
|
||||
Dependencies []dependencies.Dependency `json:"dependencies,omitempty"`
|
||||
}
|
||||
@@ -60,46 +60,48 @@ func templateFuncs() template.FuncMap {
|
||||
}
|
||||
}
|
||||
|
||||
// getGitOrg returns the Git organization from template data.
|
||||
func getGitOrg(data any) string {
|
||||
// getFieldWithFallback extracts a field from TemplateData with Git-then-Config fallback logic.
|
||||
func getFieldWithFallback(data any, gitGetter, configGetter func(*TemplateData) string, defaultValue string) string {
|
||||
if td, ok := data.(*TemplateData); ok {
|
||||
if td.Git.Organization != "" {
|
||||
return td.Git.Organization
|
||||
if gitValue := gitGetter(td); gitValue != "" {
|
||||
return gitValue
|
||||
}
|
||||
if td.Config.Organization != "" {
|
||||
return td.Config.Organization
|
||||
if configValue := configGetter(td); configValue != "" {
|
||||
return configValue
|
||||
}
|
||||
}
|
||||
|
||||
return defaultOrgPlaceholder
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// getGitOrg returns the Git organization from template data.
|
||||
func getGitOrg(data any) string {
|
||||
return getFieldWithFallback(data,
|
||||
func(td *TemplateData) string { return td.Git.Organization },
|
||||
func(td *TemplateData) string { return td.Config.Organization },
|
||||
appconstants.DefaultOrgPlaceholder)
|
||||
}
|
||||
|
||||
// getGitRepo returns the Git repository name from template data.
|
||||
func getGitRepo(data any) string {
|
||||
if td, ok := data.(*TemplateData); ok {
|
||||
if td.Git.Repository != "" {
|
||||
return td.Git.Repository
|
||||
}
|
||||
if td.Config.Repository != "" {
|
||||
return td.Config.Repository
|
||||
}
|
||||
}
|
||||
|
||||
return defaultRepoPlaceholder
|
||||
return getFieldWithFallback(data,
|
||||
func(td *TemplateData) string { return td.Git.Repository },
|
||||
func(td *TemplateData) string { return td.Config.Repository },
|
||||
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 +111,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.
|
||||
@@ -125,41 +129,98 @@ func formatVersion(version string) string {
|
||||
return version
|
||||
}
|
||||
|
||||
// buildUsesString constructs the uses string with optional action name.
|
||||
// buildUsesString constructs the uses string with optional subdirectory path.
|
||||
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
|
||||
if td.Name != "" && repo != "" {
|
||||
actionName := validation.SanitizeActionName(td.Name)
|
||||
if actionName != "" && actionName != repo {
|
||||
// Check if this looks like a subdirectory action
|
||||
return validation.FormatUsesStatement(org, repo+"/"+actionName, version)
|
||||
}
|
||||
// For monorepo actions in subdirectories, extract the actual directory path
|
||||
subdir := extractActionSubdirectory(td.ActionPath, td.RepoRoot)
|
||||
|
||||
if subdir != "" {
|
||||
// Action is in a subdirectory: org/repo/subdir@version
|
||||
return validation.FormatUsesStatement(org, repo+"/"+subdir, version)
|
||||
}
|
||||
|
||||
// Action is at repo root: org/repo@version
|
||||
return validation.FormatUsesStatement(org, repo, version)
|
||||
}
|
||||
|
||||
// getActionVersion returns the action version from template data.
|
||||
func getActionVersion(data any) string {
|
||||
if td, ok := data.(*TemplateData); ok {
|
||||
if td.Config.Version != "" {
|
||||
return td.Config.Version
|
||||
}
|
||||
// extractActionSubdirectory extracts the subdirectory path for an action relative to repo root.
|
||||
// For monorepo actions (e.g., org/repo/subdir/action.yml), returns "subdir".
|
||||
// For repo-root actions (e.g., org/repo/action.yml), returns empty string.
|
||||
// Returns empty string if paths cannot be determined.
|
||||
func extractActionSubdirectory(actionPath, repoRoot string) string {
|
||||
// Validate inputs
|
||||
if actionPath == "" || repoRoot == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Get absolute paths for reliable comparison
|
||||
absActionPath, err := filepath.Abs(actionPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
absRepoRoot, err := filepath.Abs(repoRoot)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Get the directory containing action.yml
|
||||
actionDir := filepath.Dir(absActionPath)
|
||||
|
||||
// Calculate relative path from repo root to action directory
|
||||
relPath, err := filepath.Rel(absRepoRoot, actionDir)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// If relative path is "." or empty, action is at repo root
|
||||
if relPath == "." || relPath == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// If relative path starts with "..", action is outside repo (shouldn't happen)
|
||||
if strings.HasPrefix(relPath, "..") {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Return the subdirectory path (e.g., "actions/csharp-build")
|
||||
return relPath
|
||||
}
|
||||
|
||||
// getActionVersion returns the action version from template data.
|
||||
// Priority: 1) Config.Version (explicit override), 2) Default branch (if enabled), 3) "v1" (fallback).
|
||||
func getActionVersion(data any) string {
|
||||
td, ok := data.(*TemplateData)
|
||||
if !ok {
|
||||
return "v1"
|
||||
}
|
||||
|
||||
// Priority 1: Explicit version override
|
||||
if td.Config.Version != "" {
|
||||
return td.Config.Version
|
||||
}
|
||||
|
||||
// Priority 2: Use default branch if enabled and available
|
||||
if td.Config.UseDefaultBranch && td.Git.DefaultBranch != "" {
|
||||
return td.Git.DefaultBranch
|
||||
}
|
||||
|
||||
// Priority 3: Fallback
|
||||
return "v1"
|
||||
}
|
||||
|
||||
// BuildTemplateData constructs comprehensive template data from action and configuration.
|
||||
func BuildTemplateData(action *ActionYML, config *AppConfig, repoRoot, actionPath string) *TemplateData {
|
||||
data := &TemplateData{
|
||||
ActionYML: action,
|
||||
Config: config,
|
||||
ActionYML: action,
|
||||
Config: config,
|
||||
ActionPath: actionPath,
|
||||
RepoRoot: repoRoot,
|
||||
}
|
||||
|
||||
// Populate Git information
|
||||
@@ -230,23 +291,23 @@ func analyzeDependencies(actionPath string, config *AppConfig, gitInfo git.RepoI
|
||||
|
||||
// RenderReadme renders a README using a Go template and the parsed action.yml data.
|
||||
func RenderReadme(action any, opts TemplateOptions) (string, error) {
|
||||
tmplContent, err := templates_embed.ReadTemplate(opts.TemplatePath)
|
||||
tmplContent, err := templatesembed.ReadTemplate(opts.TemplatePath)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
var head, foot string
|
||||
if opts.HeaderPath != "" {
|
||||
h, _ := templates_embed.ReadTemplate(opts.HeaderPath)
|
||||
h, _ := templatesembed.ReadTemplate(opts.HeaderPath)
|
||||
head = string(h)
|
||||
}
|
||||
if opts.FooterPath != "" {
|
||||
f, _ := templates_embed.ReadTemplate(opts.FooterPath)
|
||||
f, _ := templatesembed.ReadTemplate(opts.FooterPath)
|
||||
foot = string(f)
|
||||
}
|
||||
// Wrap template output in header/footer
|
||||
@@ -260,7 +321,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
|
||||
}
|
||||
|
||||
165
internal/template_helper_test.go
Normal file
165
internal/template_helper_test.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/internal/dependencies"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/git"
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
// TestAssertTemplateData_Helper tests the assertTemplateData helper function.
|
||||
func TestAssertTemplateDataHelper(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func() (*TemplateData, *ActionYML, *AppConfig)
|
||||
wantOrg string
|
||||
wantRepo string
|
||||
}{
|
||||
{
|
||||
name: "valid template data",
|
||||
setup: func() (*TemplateData, *ActionYML, *AppConfig) {
|
||||
action := &ActionYML{
|
||||
Name: "Test Action",
|
||||
Description: "A test action",
|
||||
}
|
||||
config := &AppConfig{
|
||||
Organization: testutil.TestOrgName,
|
||||
Repository: testutil.TestRepoName,
|
||||
}
|
||||
data := &TemplateData{
|
||||
ActionYML: action,
|
||||
Git: git.RepoInfo{
|
||||
Organization: testutil.TestOrgName,
|
||||
Repository: testutil.TestRepoName,
|
||||
},
|
||||
Config: config,
|
||||
}
|
||||
|
||||
return data, action, config
|
||||
},
|
||||
wantOrg: testutil.TestOrgName,
|
||||
wantRepo: testutil.TestRepoName,
|
||||
},
|
||||
{
|
||||
name: "template data with dependencies",
|
||||
setup: func() (*TemplateData, *ActionYML, *AppConfig) {
|
||||
action := &ActionYML{
|
||||
Name: "Action with deps",
|
||||
}
|
||||
config := &AppConfig{
|
||||
Organization: testutil.MyOrgName,
|
||||
Repository: testutil.MyRepoName,
|
||||
AnalyzeDependencies: true,
|
||||
}
|
||||
data := &TemplateData{
|
||||
ActionYML: action,
|
||||
Git: git.RepoInfo{
|
||||
Organization: testutil.MyOrgName,
|
||||
Repository: testutil.MyRepoName,
|
||||
},
|
||||
Config: config,
|
||||
Dependencies: []dependencies.Dependency{}, // Empty slice, not nil
|
||||
}
|
||||
|
||||
return data, action, config
|
||||
},
|
||||
wantOrg: testutil.MyOrgName,
|
||||
wantRepo: testutil.MyRepoName,
|
||||
},
|
||||
{
|
||||
name: "template data with empty organization",
|
||||
setup: func() (*TemplateData, *ActionYML, *AppConfig) {
|
||||
action := &ActionYML{
|
||||
Name: "Test",
|
||||
}
|
||||
config := &AppConfig{
|
||||
Organization: "",
|
||||
Repository: testutil.RepoName,
|
||||
}
|
||||
data := &TemplateData{
|
||||
ActionYML: action,
|
||||
Git: git.RepoInfo{
|
||||
Organization: "",
|
||||
Repository: testutil.RepoName,
|
||||
},
|
||||
Config: config,
|
||||
}
|
||||
|
||||
return data, action, config
|
||||
},
|
||||
wantOrg: "",
|
||||
wantRepo: testutil.RepoName,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
data, action, config := tt.setup()
|
||||
|
||||
// Call the helper - it validates the template data
|
||||
assertTemplateData(t, data, action, config, tt.wantOrg, tt.wantRepo)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrepareTestActionFile_Helper tests the prepareTestActionFile helper function.
|
||||
func TestPrepareTestActionFileHelper(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
actionPath string
|
||||
wantExists bool
|
||||
}{
|
||||
{
|
||||
name: "analyzer fixture composite action",
|
||||
actionPath: testutil.AnalyzerFixturePath + "composite-action.yml",
|
||||
wantExists: true,
|
||||
},
|
||||
{
|
||||
name: "analyzer fixture docker action",
|
||||
actionPath: testutil.AnalyzerFixturePath + "docker-action.yml",
|
||||
wantExists: true,
|
||||
},
|
||||
{
|
||||
name: "analyzer fixture javascript action",
|
||||
actionPath: testutil.AnalyzerFixturePath + "javascript-action.yml",
|
||||
wantExists: true,
|
||||
},
|
||||
{
|
||||
name: "nonexistent file path",
|
||||
actionPath: testutil.AnalyzerFixturePath + "nonexistent.yml",
|
||||
wantExists: true, // Helper creates a path, even if file doesn't exist
|
||||
},
|
||||
{
|
||||
name: "non-analyzer path",
|
||||
actionPath: "some/other/path.yml",
|
||||
wantExists: true, // Returns tmpDir path
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Call the helper - it prepares a test action file
|
||||
result := prepareTestActionFile(t, tt.actionPath)
|
||||
|
||||
// Verify we got a path
|
||||
if result == "" {
|
||||
t.Error("prepareTestActionFile returned empty path")
|
||||
}
|
||||
|
||||
// Verify it's an absolute path or relative path
|
||||
if !filepath.IsAbs(result) && !filepath.IsLocal(result) {
|
||||
t.Logf("Note: path may be relative or absolute: %s", result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
597
internal/template_test.go
Normal file
597
internal/template_test.go
Normal file
@@ -0,0 +1,597 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/git"
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
// templateDataParams holds parameters for creating test TemplateData.
|
||||
type templateDataParams struct {
|
||||
actionName string
|
||||
version string
|
||||
useDefaultBranch bool
|
||||
defaultBranch string
|
||||
org string
|
||||
repo string
|
||||
actionPath string
|
||||
repoRoot string
|
||||
}
|
||||
|
||||
// newTemplateData creates a TemplateData with the provided templateDataParams.
|
||||
// Zero values are preserved as-is; this helper does not apply defaults.
|
||||
// Callers must set defaults themselves or use a separate defaulting helper.
|
||||
func newTemplateData(params templateDataParams) *TemplateData {
|
||||
var actionYML *ActionYML
|
||||
if params.actionName != "" {
|
||||
actionYML = &ActionYML{Name: params.actionName}
|
||||
}
|
||||
|
||||
return &TemplateData{
|
||||
ActionYML: actionYML,
|
||||
Config: &AppConfig{
|
||||
Version: params.version,
|
||||
UseDefaultBranch: params.useDefaultBranch,
|
||||
},
|
||||
Git: git.RepoInfo{
|
||||
Organization: params.org,
|
||||
Repository: params.repo,
|
||||
DefaultBranch: params.defaultBranch,
|
||||
},
|
||||
ActionPath: params.actionPath,
|
||||
RepoRoot: params.repoRoot,
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractActionSubdirectory tests the extractActionSubdirectory function.
|
||||
func TestExtractActionSubdirectory(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
actionPath string
|
||||
repoRoot string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: testutil.TestCaseNameSubdirAction,
|
||||
actionPath: "/repo/actions/csharp-build/action.yml",
|
||||
repoRoot: "/repo",
|
||||
want: "actions/csharp-build",
|
||||
},
|
||||
{
|
||||
name: "single level subdirectory",
|
||||
actionPath: testutil.TestRepoBuildActionPath,
|
||||
repoRoot: "/repo",
|
||||
want: "build",
|
||||
},
|
||||
{
|
||||
name: "deeply nested subdirectory",
|
||||
actionPath: "/repo/a/b/c/d/action.yml",
|
||||
repoRoot: "/repo",
|
||||
want: "a/b/c/d",
|
||||
},
|
||||
{
|
||||
name: testutil.TestCaseNameRootAction,
|
||||
actionPath: testutil.TestRepoActionPath,
|
||||
repoRoot: "/repo",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "empty action path",
|
||||
actionPath: "",
|
||||
repoRoot: "/repo",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "empty repo root",
|
||||
actionPath: testutil.TestRepoActionPath,
|
||||
repoRoot: "",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "both empty",
|
||||
actionPath: "",
|
||||
repoRoot: "",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := extractActionSubdirectory(tt.actionPath, tt.repoRoot)
|
||||
|
||||
// Normalize paths for cross-platform compatibility
|
||||
want := filepath.ToSlash(tt.want)
|
||||
got = filepath.ToSlash(got)
|
||||
|
||||
if got != want {
|
||||
t.Errorf("extractActionSubdirectory() = %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildUsesString tests the buildUsesString function with subdirectory extraction.
|
||||
func TestBuildUsesString(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
td *TemplateData
|
||||
org string
|
||||
repo string
|
||||
version string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "monorepo with subdirectory",
|
||||
td: &TemplateData{
|
||||
ActionPath: "/repo/actions/csharp-build/action.yml",
|
||||
RepoRoot: "/repo",
|
||||
},
|
||||
org: "ivuorinen",
|
||||
repo: "actions",
|
||||
version: "@main",
|
||||
want: "ivuorinen/actions/actions/csharp-build@main",
|
||||
},
|
||||
{
|
||||
name: testutil.TestCaseNameRootAction,
|
||||
td: &TemplateData{
|
||||
ActionPath: testutil.TestRepoActionPath,
|
||||
RepoRoot: "/repo",
|
||||
},
|
||||
org: "ivuorinen",
|
||||
repo: "my-action",
|
||||
version: "@main",
|
||||
want: "ivuorinen/my-action@main",
|
||||
},
|
||||
{
|
||||
name: "empty org",
|
||||
td: &TemplateData{
|
||||
ActionPath: testutil.TestRepoBuildActionPath,
|
||||
RepoRoot: "/repo",
|
||||
},
|
||||
org: "",
|
||||
repo: "actions",
|
||||
version: "@main",
|
||||
want: "your-org/your-action@v1",
|
||||
},
|
||||
{
|
||||
name: "empty repo",
|
||||
td: &TemplateData{
|
||||
ActionPath: testutil.TestRepoBuildActionPath,
|
||||
RepoRoot: "/repo",
|
||||
},
|
||||
org: "ivuorinen",
|
||||
repo: "",
|
||||
version: "@main",
|
||||
want: "your-org/your-action@v1",
|
||||
},
|
||||
{
|
||||
name: "missing paths in template data",
|
||||
td: &TemplateData{
|
||||
ActionPath: "",
|
||||
RepoRoot: "",
|
||||
},
|
||||
org: "ivuorinen",
|
||||
repo: "actions",
|
||||
version: "@v1",
|
||||
want: "ivuorinen/actions@v1",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := buildUsesString(tt.td, tt.org, tt.repo, tt.version)
|
||||
|
||||
// Normalize paths for cross-platform compatibility
|
||||
want := filepath.ToSlash(tt.want)
|
||||
got = filepath.ToSlash(got)
|
||||
|
||||
if got != want {
|
||||
t.Errorf("buildUsesString() = %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetActionVersion tests the getActionVersion function with priority logic.
|
||||
func TestGetActionVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
data any
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "config version override",
|
||||
data: newTemplateData(templateDataParams{version: "v2.0.0", useDefaultBranch: true, defaultBranch: "main"}),
|
||||
want: "v2.0.0",
|
||||
},
|
||||
{
|
||||
name: "use default branch when enabled",
|
||||
data: newTemplateData(templateDataParams{useDefaultBranch: true, defaultBranch: "main"}),
|
||||
want: "main",
|
||||
},
|
||||
{
|
||||
name: "use default branch master",
|
||||
data: newTemplateData(templateDataParams{useDefaultBranch: true, defaultBranch: "master"}),
|
||||
want: "master",
|
||||
},
|
||||
{
|
||||
name: "fallback to v1 when default branch disabled",
|
||||
data: newTemplateData(templateDataParams{useDefaultBranch: false, defaultBranch: "main"}),
|
||||
want: "v1",
|
||||
},
|
||||
{
|
||||
name: "fallback to v1 when default branch not detected",
|
||||
data: newTemplateData(templateDataParams{useDefaultBranch: true}),
|
||||
want: "v1",
|
||||
},
|
||||
{
|
||||
name: "fallback to v1 when data is invalid",
|
||||
data: "invalid",
|
||||
want: "v1",
|
||||
},
|
||||
{
|
||||
name: "fallback to v1 when data is nil",
|
||||
data: nil,
|
||||
want: "v1",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := getActionVersion(tt.data)
|
||||
if got != tt.want {
|
||||
t.Errorf("getActionVersion() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetGitUsesString tests the complete integration of gitUsesString template function.
|
||||
func TestGetGitUsesString(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
data *TemplateData
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "monorepo action with default branch",
|
||||
data: newTemplateData(templateDataParams{
|
||||
actionName: "C# Build",
|
||||
useDefaultBranch: true,
|
||||
defaultBranch: "main",
|
||||
org: "ivuorinen",
|
||||
repo: "actions",
|
||||
actionPath: "/repo/csharp-build/action.yml",
|
||||
repoRoot: "/repo",
|
||||
}),
|
||||
want: "ivuorinen/actions/csharp-build@main",
|
||||
},
|
||||
{
|
||||
name: "monorepo action with explicit version",
|
||||
data: newTemplateData(templateDataParams{
|
||||
actionName: "Build Action",
|
||||
version: "v1.0.0",
|
||||
useDefaultBranch: true,
|
||||
defaultBranch: "main",
|
||||
org: "org",
|
||||
repo: "actions",
|
||||
actionPath: testutil.TestRepoBuildActionPath,
|
||||
repoRoot: "/repo",
|
||||
}),
|
||||
want: "org/actions/build@v1.0.0",
|
||||
},
|
||||
{
|
||||
name: "root level action with default branch",
|
||||
data: newTemplateData(templateDataParams{
|
||||
actionName: testutil.TestMyAction,
|
||||
useDefaultBranch: true,
|
||||
defaultBranch: "develop",
|
||||
org: "user",
|
||||
repo: "my-action",
|
||||
actionPath: testutil.TestRepoActionPath,
|
||||
repoRoot: "/repo",
|
||||
}),
|
||||
want: "user/my-action@develop",
|
||||
},
|
||||
{
|
||||
name: "action with use_default_branch disabled",
|
||||
data: newTemplateData(templateDataParams{
|
||||
actionName: testutil.TestActionName,
|
||||
useDefaultBranch: false,
|
||||
defaultBranch: "main",
|
||||
org: "org",
|
||||
repo: "test",
|
||||
actionPath: testutil.TestRepoActionPath,
|
||||
repoRoot: "/repo",
|
||||
}),
|
||||
want: "org/test@v1",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := getGitUsesString(tt.data)
|
||||
|
||||
// Normalize paths for cross-platform compatibility
|
||||
want := filepath.ToSlash(tt.want)
|
||||
got = filepath.ToSlash(got)
|
||||
|
||||
if got != want {
|
||||
t.Errorf("getGitUsesString() = %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatVersion tests the formatVersion function.
|
||||
func TestFormatVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
version string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "empty version",
|
||||
version: "",
|
||||
want: "@v1",
|
||||
},
|
||||
{
|
||||
name: "whitespace only version",
|
||||
version: " ",
|
||||
want: "@v1",
|
||||
},
|
||||
{
|
||||
name: "version without @",
|
||||
version: "v1.2.3",
|
||||
want: testutil.TestVersionWithAt,
|
||||
},
|
||||
{
|
||||
name: "version with @",
|
||||
version: testutil.TestVersionWithAt,
|
||||
want: testutil.TestVersionWithAt,
|
||||
},
|
||||
{
|
||||
name: "main branch",
|
||||
version: "main",
|
||||
want: "@main",
|
||||
},
|
||||
{
|
||||
name: "version with @ and spaces",
|
||||
version: " @v2.0.0 ",
|
||||
want: "@v2.0.0",
|
||||
},
|
||||
{
|
||||
name: "sha version",
|
||||
version: "abc123",
|
||||
want: "@abc123",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := formatVersion(tt.version)
|
||||
if got != tt.want {
|
||||
t.Errorf("formatVersion(%q) = %q, want %q", tt.version, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildTemplateData tests the BuildTemplateData function.
|
||||
func TestBuildTemplateData(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
action *ActionYML
|
||||
config *AppConfig
|
||||
repoRoot string
|
||||
actionPath string
|
||||
wantOrg string
|
||||
wantRepo string
|
||||
}{
|
||||
{
|
||||
name: "basic action with config overrides",
|
||||
action: &ActionYML{
|
||||
Name: testutil.TestActionName,
|
||||
Description: "Test description",
|
||||
},
|
||||
config: &AppConfig{
|
||||
Organization: "testorg",
|
||||
Repository: "testrepo",
|
||||
},
|
||||
repoRoot: ".",
|
||||
actionPath: appconstants.ActionFileNameYML,
|
||||
wantOrg: "testorg",
|
||||
wantRepo: "testrepo",
|
||||
},
|
||||
{
|
||||
name: "action without config overrides",
|
||||
action: &ActionYML{
|
||||
Name: "Another Action",
|
||||
Description: "Another description",
|
||||
},
|
||||
config: &AppConfig{},
|
||||
repoRoot: ".",
|
||||
actionPath: appconstants.ActionFileNameYML,
|
||||
wantOrg: "",
|
||||
wantRepo: "",
|
||||
},
|
||||
{
|
||||
name: "action with dependency analysis enabled",
|
||||
action: &ActionYML{
|
||||
Name: "Dep Action",
|
||||
Description: "Action with deps",
|
||||
},
|
||||
config: &AppConfig{
|
||||
Organization: "deporg",
|
||||
Repository: "deprepo",
|
||||
AnalyzeDependencies: true,
|
||||
},
|
||||
repoRoot: ".",
|
||||
actionPath: "../testdata/composite-action/action.yml",
|
||||
wantOrg: "deporg",
|
||||
wantRepo: "deprepo",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
data := BuildTemplateData(tt.action, tt.config, tt.repoRoot, tt.actionPath)
|
||||
assertTemplateData(t, data, tt.action, tt.config, tt.wantOrg, tt.wantRepo)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertTemplateData(
|
||||
t *testing.T,
|
||||
data *TemplateData,
|
||||
action *ActionYML,
|
||||
config *AppConfig,
|
||||
wantOrg, wantRepo string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
if data == nil {
|
||||
t.Fatal("BuildTemplateData() returned nil")
|
||||
}
|
||||
|
||||
if data.ActionYML != action {
|
||||
t.Error("BuildTemplateData() did not preserve ActionYML")
|
||||
}
|
||||
|
||||
if data.Config != config {
|
||||
t.Error("BuildTemplateData() did not preserve Config")
|
||||
}
|
||||
|
||||
if config.Organization != "" && data.Git.Organization != wantOrg {
|
||||
t.Errorf("BuildTemplateData() Git.Organization = %q, want %q", data.Git.Organization, wantOrg)
|
||||
}
|
||||
|
||||
if config.Repository != "" && data.Git.Repository != wantRepo {
|
||||
t.Errorf("BuildTemplateData() Git.Repository = %q, want %q", data.Git.Repository, wantRepo)
|
||||
}
|
||||
|
||||
if config.AnalyzeDependencies && data.Dependencies == nil {
|
||||
t.Error("BuildTemplateData() expected Dependencies to be set when AnalyzeDependencies is true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnalyzeDependencies tests the analyzeDependencies function.
|
||||
// prepareTestActionFile prepares a test action file for analyzeDependencies tests.
|
||||
func prepareTestActionFile(t *testing.T, actionPath string) string {
|
||||
t.Helper()
|
||||
|
||||
if strings.HasPrefix(actionPath, "../../testdata/analyzer/") &&
|
||||
actionPath != "../../testdata/analyzer/nonexistent.yml" {
|
||||
filename := filepath.Base(actionPath)
|
||||
yamlContent := testutil.MustReadAnalyzerFixture(filename)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
tmpPath := filepath.Join(tmpDir, appconstants.ActionFileNameYML)
|
||||
tmpPath = testutil.ValidateTestPath(t, tmpPath, tmpDir)
|
||||
testutil.WriteTestFile(t, tmpPath, yamlContent)
|
||||
|
||||
return tmpPath
|
||||
}
|
||||
|
||||
// For nonexistent file test
|
||||
return filepath.Join(t.TempDir(), "nonexistent.yml")
|
||||
}
|
||||
|
||||
func TestAnalyzeDependencies(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
actionPath string
|
||||
config *AppConfig
|
||||
expectNil bool
|
||||
}{
|
||||
{
|
||||
name: "valid composite action without GitHub token",
|
||||
actionPath: "../../testdata/analyzer/composite-action.yml",
|
||||
config: &AppConfig{},
|
||||
expectNil: false,
|
||||
},
|
||||
{
|
||||
name: "nonexistent action file",
|
||||
actionPath: "../../testdata/analyzer/nonexistent.yml",
|
||||
config: &AppConfig{},
|
||||
expectNil: false, // Should return empty slice, not nil
|
||||
},
|
||||
{
|
||||
name: "docker action without token",
|
||||
actionPath: "../../testdata/analyzer/docker-action.yml",
|
||||
config: &AppConfig{},
|
||||
expectNil: false,
|
||||
},
|
||||
{
|
||||
name: "javascript action without token",
|
||||
actionPath: "../../testdata/analyzer/javascript-action.yml",
|
||||
config: &AppConfig{},
|
||||
expectNil: false,
|
||||
},
|
||||
{
|
||||
name: "invalid yaml file",
|
||||
actionPath: "../../testdata/analyzer/invalid.yml",
|
||||
config: &AppConfig{},
|
||||
expectNil: false, // Should gracefully handle errors and return empty slice
|
||||
},
|
||||
{
|
||||
name: testutil.TestCaseNamePathTraversalAttempt,
|
||||
actionPath: "../../etc/passwd",
|
||||
config: &AppConfig{},
|
||||
expectNil: false, // Returns empty slice for invalid paths
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
actionPath := prepareTestActionFile(t, tt.actionPath)
|
||||
|
||||
gitInfo := git.RepoInfo{
|
||||
Organization: "testorg",
|
||||
Repository: "testrepo",
|
||||
}
|
||||
|
||||
result := analyzeDependencies(actionPath, tt.config, gitInfo)
|
||||
|
||||
if tt.expectNil && result != nil {
|
||||
t.Errorf("analyzeDependencies() expected nil, got %v", result)
|
||||
}
|
||||
|
||||
if !tt.expectNil && result == nil {
|
||||
t.Error("analyzeDependencies() returned nil, expected non-nil slice")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -18,7 +19,7 @@ var (
|
||||
_ ErrorReporter = (*NullOutput)(nil)
|
||||
_ ErrorFormatter = (*NullOutput)(nil)
|
||||
_ ProgressReporter = (*NullOutput)(nil)
|
||||
_ OutputConfig = (*NullOutput)(nil)
|
||||
_ QuietChecker = (*NullOutput)(nil)
|
||||
_ CompleteOutput = (*NullOutput)(nil)
|
||||
)
|
||||
|
||||
@@ -33,45 +34,66 @@ func (no *NullOutput) IsQuiet() bool {
|
||||
}
|
||||
|
||||
// Success is a no-op.
|
||||
func (no *NullOutput) Success(_ string, _ ...any) {}
|
||||
func (no *NullOutput) Success(_ string, _ ...any) {
|
||||
// Intentionally empty: NullOutput suppresses all output for testing.
|
||||
}
|
||||
|
||||
// Error is a no-op.
|
||||
func (no *NullOutput) Error(_ string, _ ...any) {}
|
||||
func (no *NullOutput) Error(_ string, _ ...any) {
|
||||
// Intentionally empty: NullOutput suppresses all output for testing.
|
||||
}
|
||||
|
||||
// Warning is a no-op.
|
||||
func (no *NullOutput) Warning(_ string, _ ...any) {}
|
||||
func (no *NullOutput) Warning(_ string, _ ...any) {
|
||||
// Intentionally empty: NullOutput suppresses all output for testing.
|
||||
}
|
||||
|
||||
// Info is a no-op.
|
||||
func (no *NullOutput) Info(_ string, _ ...any) {}
|
||||
func (no *NullOutput) Info(_ string, _ ...any) {
|
||||
// Intentionally empty: NullOutput suppresses all output for testing.
|
||||
}
|
||||
|
||||
// Progress is a no-op.
|
||||
func (no *NullOutput) Progress(_ string, _ ...any) {}
|
||||
func (no *NullOutput) Progress(_ string, _ ...any) {
|
||||
// Intentionally empty: NullOutput suppresses all output for testing.
|
||||
}
|
||||
|
||||
// Bold is a no-op.
|
||||
func (no *NullOutput) Bold(_ string, _ ...any) {}
|
||||
func (no *NullOutput) Bold(_ string, _ ...any) {
|
||||
// Intentionally empty: NullOutput suppresses all output for testing.
|
||||
}
|
||||
|
||||
// Printf is a no-op.
|
||||
func (no *NullOutput) Printf(_ string, _ ...any) {}
|
||||
func (no *NullOutput) Printf(_ string, _ ...any) {
|
||||
// Intentionally empty: NullOutput suppresses all output for testing.
|
||||
}
|
||||
|
||||
// Fprintf is a no-op.
|
||||
func (no *NullOutput) Fprintf(_ *os.File, _ string, _ ...any) {}
|
||||
func (no *NullOutput) Fprintf(_ *os.File, _ string, _ ...any) {
|
||||
// Intentionally empty: NullOutput suppresses all output for testing.
|
||||
}
|
||||
|
||||
// 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,
|
||||
) {
|
||||
// Intentionally empty: NullOutput suppresses all output for testing.
|
||||
}
|
||||
|
||||
// ErrorWithSimpleFix is a no-op.
|
||||
func (no *NullOutput) ErrorWithSimpleFix(_, _ string) {}
|
||||
func (no *NullOutput) ErrorWithSimpleFix(_, _ string) {
|
||||
// Intentionally empty: NullOutput suppresses all output for testing.
|
||||
}
|
||||
|
||||
// FormatContextualError returns empty string.
|
||||
func (no *NullOutput) FormatContextualError(_ *errors.ContextualError) string {
|
||||
func (no *NullOutput) FormatContextualError(_ *apperrors.ContextualError) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -100,13 +122,19 @@ func (npm *NullProgressManager) CreateProgressBarForFiles(
|
||||
}
|
||||
|
||||
// FinishProgressBar is a no-op.
|
||||
func (npm *NullProgressManager) FinishProgressBar(_ *progressbar.ProgressBar) {}
|
||||
func (npm *NullProgressManager) FinishProgressBar(_ *progressbar.ProgressBar) {
|
||||
// Intentionally empty: NullProgressManager suppresses progress output for testing.
|
||||
}
|
||||
|
||||
// FinishProgressBarWithNewline is a no-op.
|
||||
func (npm *NullProgressManager) FinishProgressBarWithNewline(_ *progressbar.ProgressBar) {}
|
||||
func (npm *NullProgressManager) FinishProgressBarWithNewline(_ *progressbar.ProgressBar) {
|
||||
// Intentionally empty: NullProgressManager suppresses progress output for testing.
|
||||
}
|
||||
|
||||
// UpdateProgressBar is a no-op.
|
||||
func (npm *NullProgressManager) UpdateProgressBar(_ *progressbar.ProgressBar) {}
|
||||
func (npm *NullProgressManager) UpdateProgressBar(_ *progressbar.ProgressBar) {
|
||||
// Intentionally empty: NullProgressManager suppresses progress output for testing.
|
||||
}
|
||||
|
||||
// ProcessWithProgressBar executes the function for each item without progress display.
|
||||
func (npm *NullProgressManager) ProcessWithProgressBar(
|
||||
|
||||
220
internal/testoutput_test.go
Normal file
220
internal/testoutput_test.go
Normal file
@@ -0,0 +1,220 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/schollz/progressbar/v3"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/appconstants"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/apperrors"
|
||||
)
|
||||
|
||||
const testFormatString = "test %s %d"
|
||||
|
||||
func TestNullOutput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
no := NewNullOutput()
|
||||
if no == nil {
|
||||
t.Fatal("NewNullOutput() returned nil")
|
||||
}
|
||||
|
||||
// Test IsQuiet
|
||||
if !no.IsQuiet() {
|
||||
t.Error("NullOutput.IsQuiet() should return true")
|
||||
}
|
||||
|
||||
// Test all no-op methods don't panic
|
||||
no.Success("test")
|
||||
no.Error("test")
|
||||
no.Warning("test")
|
||||
no.Info("test")
|
||||
no.Progress("test")
|
||||
no.Bold("test")
|
||||
no.Printf("test")
|
||||
no.Fprintf(os.Stdout, "test")
|
||||
|
||||
// Test error methods
|
||||
err := apperrors.New(appconstants.ErrCodeUnknown, "test error")
|
||||
no.ErrorWithSuggestions(err)
|
||||
no.ErrorWithContext(appconstants.ErrCodeUnknown, "test", map[string]string{})
|
||||
no.ErrorWithSimpleFix("test", "fix")
|
||||
|
||||
// Test FormatContextualError
|
||||
formatted := no.FormatContextualError(err)
|
||||
if formatted != "" {
|
||||
t.Errorf("NullOutput.FormatContextualError() = %q, want empty string", formatted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNullProgressManager(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
npm := NewNullProgressManager()
|
||||
if npm == nil {
|
||||
t.Fatal("NewNullProgressManager() returned nil")
|
||||
}
|
||||
|
||||
// Test CreateProgressBar returns nil
|
||||
bar := npm.CreateProgressBar("test", 10)
|
||||
if bar != nil {
|
||||
t.Error("NullProgressManager.CreateProgressBar() should return nil")
|
||||
}
|
||||
|
||||
// Test CreateProgressBarForFiles returns nil
|
||||
bar = npm.CreateProgressBarForFiles("test", []string{"file1", "file2"})
|
||||
if bar != nil {
|
||||
t.Error("NullProgressManager.CreateProgressBarForFiles() should return nil")
|
||||
}
|
||||
|
||||
// Test no-op methods don't panic
|
||||
npm.FinishProgressBar(nil)
|
||||
npm.FinishProgressBarWithNewline(nil)
|
||||
npm.UpdateProgressBar(nil)
|
||||
|
||||
// Test ProcessWithProgressBar executes function for each item
|
||||
var count int
|
||||
items := []string{"item1", "item2", "item3"}
|
||||
npm.ProcessWithProgressBar("test", items, func(_ string, _ *progressbar.ProgressBar) {
|
||||
count++
|
||||
})
|
||||
|
||||
if count != len(items) {
|
||||
t.Errorf("ProcessWithProgressBar processed %d items, want %d", count, len(items))
|
||||
}
|
||||
}
|
||||
|
||||
// TestNullOutputEdgeCases tests NullOutput methods with edge case inputs.
|
||||
func TestNullOutputEdgeCases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
no := NewNullOutput()
|
||||
|
||||
// Test with empty strings
|
||||
no.Success("")
|
||||
no.Error("")
|
||||
no.Warning("")
|
||||
no.Info("")
|
||||
no.Progress("")
|
||||
no.Bold("")
|
||||
no.Printf("")
|
||||
|
||||
// Test with special characters
|
||||
specialChars := "\n\t\r\x00\a\b\v\f"
|
||||
no.Success(specialChars)
|
||||
no.Error(specialChars)
|
||||
no.Warning(specialChars)
|
||||
no.Info(specialChars)
|
||||
no.Progress(specialChars)
|
||||
no.Bold(specialChars)
|
||||
no.Printf(specialChars)
|
||||
|
||||
// Test with unicode
|
||||
unicode := "🎉 emoji test 你好 мир"
|
||||
no.Success(unicode)
|
||||
no.Error(unicode)
|
||||
no.Warning(unicode)
|
||||
no.Info(unicode)
|
||||
no.Progress(unicode)
|
||||
no.Bold(unicode)
|
||||
no.Printf(unicode)
|
||||
|
||||
// Test with format strings and nil args
|
||||
no.Printf(testFormatString, nil, nil)
|
||||
no.Success(testFormatString, nil, nil)
|
||||
no.Error(testFormatString, nil, nil)
|
||||
|
||||
// Test with multiple args
|
||||
no.Success("test", "arg1", "arg2", "arg3")
|
||||
no.Error("test", 1, 2, 3, 4, 5)
|
||||
no.Printf("test %s %d %v", "str", 42, true)
|
||||
}
|
||||
|
||||
// TestNullOutputErrorMethodsWithNil tests error methods with nil inputs.
|
||||
func TestNullOutputErrorMethodsWithNil(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
no := NewNullOutput()
|
||||
|
||||
// Test with nil error
|
||||
no.ErrorWithSuggestions(nil)
|
||||
no.FormatContextualError(nil)
|
||||
|
||||
// Test with nil context
|
||||
no.ErrorWithContext(appconstants.ErrCodeUnknown, "test", nil)
|
||||
|
||||
// Test with empty context
|
||||
no.ErrorWithContext(appconstants.ErrCodeUnknown, "", map[string]string{})
|
||||
|
||||
// Test with empty simple fix
|
||||
no.ErrorWithSimpleFix("", "")
|
||||
}
|
||||
|
||||
// TestNullProgressManagerEdgeCases tests NullProgressManager with edge cases.
|
||||
func TestNullProgressManagerEdgeCases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
npm := NewNullProgressManager()
|
||||
|
||||
// Test with empty strings
|
||||
bar := npm.CreateProgressBar("", 0)
|
||||
if bar != nil {
|
||||
t.Error("CreateProgressBar with empty string should return nil")
|
||||
}
|
||||
|
||||
// Test with negative count
|
||||
bar = npm.CreateProgressBar("test", -1)
|
||||
if bar != nil {
|
||||
t.Error("CreateProgressBar with negative count should return nil")
|
||||
}
|
||||
|
||||
// Test with empty file list
|
||||
bar = npm.CreateProgressBarForFiles("test", []string{})
|
||||
if bar != nil {
|
||||
t.Error("CreateProgressBarForFiles with empty list should return nil")
|
||||
}
|
||||
|
||||
// Test with nil file list
|
||||
bar = npm.CreateProgressBarForFiles("test", nil)
|
||||
if bar != nil {
|
||||
t.Error("CreateProgressBarForFiles with nil list should return nil")
|
||||
}
|
||||
|
||||
// Test ProcessWithProgressBar with empty items
|
||||
callCount := 0
|
||||
npm.ProcessWithProgressBar("test", []string{}, func(_ string, _ *progressbar.ProgressBar) {
|
||||
callCount++
|
||||
})
|
||||
if callCount != 0 {
|
||||
t.Errorf("ProcessWithProgressBar with empty items called func %d times, want 0", callCount)
|
||||
}
|
||||
|
||||
// Test ProcessWithProgressBar with nil items
|
||||
callCount = 0
|
||||
npm.ProcessWithProgressBar("test", nil, func(_ string, _ *progressbar.ProgressBar) {
|
||||
callCount++
|
||||
})
|
||||
if callCount != 0 {
|
||||
t.Errorf("ProcessWithProgressBar with nil items called func %d times, want 0", callCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNullOutputInterfaceCompliance verifies NullOutput implements CompleteOutput.
|
||||
func TestNullOutputInterfaceCompliance(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var _ CompleteOutput = (*NullOutput)(nil)
|
||||
var _ MessageLogger = (*NullOutput)(nil)
|
||||
var _ ErrorReporter = (*NullOutput)(nil)
|
||||
var _ ErrorFormatter = (*NullOutput)(nil)
|
||||
var _ ProgressReporter = (*NullOutput)(nil)
|
||||
var _ QuietChecker = (*NullOutput)(nil)
|
||||
}
|
||||
|
||||
// TestNullProgressManagerInterfaceCompliance verifies NullProgressManager implements ProgressManager.
|
||||
func TestNullProgressManagerInterfaceCompliance(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var _ ProgressManager = (*NullProgressManager)(nil)
|
||||
}
|
||||
727
internal/validation/strings_mutation_test.go
Normal file
727
internal/validation/strings_mutation_test.go
Normal file
@@ -0,0 +1,727 @@
|
||||
package validation
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
// urlTestCase represents a single URL parsing test case.
|
||||
type urlTestCase struct {
|
||||
name string
|
||||
url string
|
||||
wantOrg string
|
||||
wantRepo string
|
||||
critical bool
|
||||
description string
|
||||
}
|
||||
|
||||
// makeURLTestCase creates a URL test case with fewer lines of code.
|
||||
func makeURLTestCase(name, url, org, repo string, critical bool, desc string) urlTestCase {
|
||||
return urlTestCase{
|
||||
name: name,
|
||||
url: url,
|
||||
wantOrg: org,
|
||||
wantRepo: repo,
|
||||
critical: critical,
|
||||
description: desc,
|
||||
}
|
||||
}
|
||||
|
||||
// sanitizeTestCase represents a string sanitization test case.
|
||||
type sanitizeTestCase struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
critical bool
|
||||
description string
|
||||
}
|
||||
|
||||
// makeSanitizeTestCase creates a sanitize test case with fewer lines of code.
|
||||
func makeSanitizeTestCase(name, input, want string, critical bool, desc string) sanitizeTestCase {
|
||||
return sanitizeTestCase{
|
||||
name: name,
|
||||
input: input,
|
||||
want: want,
|
||||
critical: critical,
|
||||
description: desc,
|
||||
}
|
||||
}
|
||||
|
||||
// formatTestCase represents a uses statement formatting test case.
|
||||
type formatTestCase struct {
|
||||
name string
|
||||
org string
|
||||
repo string
|
||||
version string
|
||||
want string
|
||||
critical bool
|
||||
description string
|
||||
}
|
||||
|
||||
// makeFormatTestCase creates a format test case with fewer lines of code.
|
||||
func makeFormatTestCase(name, org, repo, version, want string, critical bool, desc string) formatTestCase {
|
||||
return formatTestCase{
|
||||
name: name,
|
||||
org: org,
|
||||
repo: repo,
|
||||
version: version,
|
||||
want: want,
|
||||
critical: critical,
|
||||
description: desc,
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseGitHubURLMutationResistance tests URL parsing for regex and boundary mutations.
|
||||
// Critical mutations to catch:
|
||||
// - Pattern order changes (SSH vs HTTPS precedence)
|
||||
// - len(matches) >= 3 changed to > 3, == 3, etc.
|
||||
// - Return statement modifications (returning wrong indices).
|
||||
func TestParseGitHubURLMutationResistance(t *testing.T) {
|
||||
tests := []urlTestCase{
|
||||
// HTTPS URLs
|
||||
makeURLTestCase(
|
||||
"https_standard",
|
||||
testutil.MutationURLHTTPS,
|
||||
testutil.MutationOrgOctocat,
|
||||
testutil.MutationRepoHelloWorld,
|
||||
false,
|
||||
"Standard HTTPS URL",
|
||||
),
|
||||
makeURLTestCase(
|
||||
"https_with_git_extension",
|
||||
testutil.MutationURLHTTPSGit,
|
||||
testutil.MutationOrgOctocat,
|
||||
testutil.MutationRepoHelloWorld,
|
||||
true,
|
||||
".git extension handled by (?:\\.git)? regex",
|
||||
),
|
||||
|
||||
// SSH URLs
|
||||
makeURLTestCase(
|
||||
"ssh_standard",
|
||||
testutil.MutationURLSSH,
|
||||
testutil.MutationOrgOctocat,
|
||||
testutil.MutationRepoHelloWorld,
|
||||
true,
|
||||
"SSH URL with colon separator ([:/] pattern)",
|
||||
),
|
||||
makeURLTestCase(
|
||||
"ssh_with_git_extension",
|
||||
testutil.MutationURLSSHGit,
|
||||
testutil.MutationOrgOctocat,
|
||||
testutil.MutationRepoHelloWorld,
|
||||
true,
|
||||
"SSH with .git",
|
||||
),
|
||||
|
||||
// Simple format
|
||||
makeURLTestCase(
|
||||
"simple_org_repo",
|
||||
testutil.MutationURLSimple,
|
||||
testutil.MutationOrgOctocat,
|
||||
testutil.MutationRepoHelloWorld,
|
||||
true,
|
||||
"Simple org/repo format (second pattern)",
|
||||
),
|
||||
|
||||
// Edge cases with special characters
|
||||
makeURLTestCase(
|
||||
"org_with_dash",
|
||||
testutil.MutationURLSetupNode,
|
||||
testutil.MutationOrgActions,
|
||||
testutil.MutationRepoSetupNode,
|
||||
false,
|
||||
"Hyphen in repo name",
|
||||
),
|
||||
makeURLTestCase("org_with_number", "org123/repo456", "org123", "repo456", false, "Numbers in org/repo"),
|
||||
|
||||
// Boundary: len(matches) >= 3
|
||||
makeURLTestCase(
|
||||
"exactly_3_matches",
|
||||
"a/b",
|
||||
"a",
|
||||
"b",
|
||||
true,
|
||||
"Minimal valid: exactly 3 matches (full, org, repo)",
|
||||
),
|
||||
|
||||
// Invalid URLs (should return empty)
|
||||
makeURLTestCase(
|
||||
"no_slash_invalid",
|
||||
"octocatHello-World",
|
||||
testutil.MutationStrEmpty,
|
||||
testutil.MutationStrEmpty,
|
||||
true,
|
||||
"No slash separator",
|
||||
),
|
||||
makeURLTestCase(
|
||||
"empty_string",
|
||||
testutil.MutationStrEmpty,
|
||||
testutil.MutationStrEmpty,
|
||||
testutil.MutationStrEmpty,
|
||||
true,
|
||||
"Empty string",
|
||||
),
|
||||
makeURLTestCase(
|
||||
"only_org",
|
||||
"octocat/",
|
||||
testutil.MutationStrEmpty,
|
||||
testutil.MutationStrEmpty,
|
||||
true,
|
||||
"Trailing slash, no repo",
|
||||
),
|
||||
makeURLTestCase(
|
||||
"only_repo",
|
||||
"/Hello-World",
|
||||
testutil.MutationStrEmpty,
|
||||
testutil.MutationStrEmpty,
|
||||
true,
|
||||
"Leading slash, no org",
|
||||
),
|
||||
|
||||
// Pattern precedence tests
|
||||
makeURLTestCase(
|
||||
"github_com_in_middle",
|
||||
testutil.MutationURLGitHubReadme,
|
||||
testutil.MutationOrgIvuorinen,
|
||||
testutil.MutationRepoGhActionReadme,
|
||||
false,
|
||||
"First pattern should match",
|
||||
),
|
||||
|
||||
// Regex capture group tests
|
||||
makeURLTestCase(
|
||||
"multiple_slashes",
|
||||
"octocat/Hello-World/extra",
|
||||
testutil.MutationStrEmpty,
|
||||
testutil.MutationStrEmpty,
|
||||
false,
|
||||
"Extra path segments invalid for simple format",
|
||||
),
|
||||
|
||||
// .git extension edge cases
|
||||
makeURLTestCase(
|
||||
"double_git_extension",
|
||||
"octocat/Hello-World.git.git",
|
||||
testutil.MutationStrEmpty,
|
||||
testutil.MutationStrEmpty,
|
||||
true,
|
||||
"Dots not allowed in repo name by [^/.] pattern",
|
||||
),
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotOrg, gotRepo := ParseGitHubURL(tt.url)
|
||||
|
||||
if gotOrg != tt.wantOrg {
|
||||
t.Errorf("ParseGitHubURL(%q) org = %q, want %q (description: %s)",
|
||||
tt.url, gotOrg, tt.wantOrg, tt.description)
|
||||
}
|
||||
if gotRepo != tt.wantRepo {
|
||||
t.Errorf("ParseGitHubURL(%q) repo = %q, want %q (description: %s)",
|
||||
tt.url, gotRepo, tt.wantRepo, tt.description)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSanitizeActionNameMutationResistance tests string transformation order and operations.
|
||||
// Critical mutations to catch:
|
||||
// - Order of operations (TrimSpace, ReplaceAll, ToLower)
|
||||
// - ReplaceAll vs Replace (all occurrences vs first)
|
||||
// - Wrong replacement string.
|
||||
func TestSanitizeActionNameMutationResistance(t *testing.T) {
|
||||
tests := []sanitizeTestCase{
|
||||
// Basic transformations
|
||||
makeSanitizeTestCase("lowercase_conversion", "UPPERCASE", "uppercase", true, "ToLower applied"),
|
||||
makeSanitizeTestCase(
|
||||
"space_to_dash",
|
||||
testutil.ValidationHelloWorld,
|
||||
testutil.MutationStrHelloWorldDash,
|
||||
true,
|
||||
"ReplaceAll spaces with dashes",
|
||||
),
|
||||
makeSanitizeTestCase("trim_spaces", " hello ", "hello", true, "TrimSpace applied"),
|
||||
|
||||
// Multiple spaces (ReplaceAll vs Replace critical)
|
||||
makeSanitizeTestCase(
|
||||
"multiple_spaces_all_replaced",
|
||||
"hello world test",
|
||||
"hello--world--test",
|
||||
true,
|
||||
"All spaces replaced (ReplaceAll, not Replace)",
|
||||
),
|
||||
makeSanitizeTestCase("three_consecutive_spaces", "a b", "a---b", true, "Each space replaced individually"),
|
||||
|
||||
// Operation order tests
|
||||
makeSanitizeTestCase(
|
||||
"uppercase_with_spaces",
|
||||
"HELLO WORLD",
|
||||
testutil.MutationStrHelloWorldDash,
|
||||
true,
|
||||
"Both lowercase and space replacement",
|
||||
),
|
||||
makeSanitizeTestCase(
|
||||
"leading_trailing_spaces_uppercase",
|
||||
" HELLO WORLD ",
|
||||
testutil.MutationStrHelloWorldDash,
|
||||
true,
|
||||
"All transformations: trim, replace, lowercase",
|
||||
),
|
||||
|
||||
// Edge cases
|
||||
makeSanitizeTestCase(
|
||||
"empty_string",
|
||||
testutil.MutationStrEmpty,
|
||||
testutil.MutationStrEmpty,
|
||||
true,
|
||||
testutil.MutationDescEmptyInput,
|
||||
),
|
||||
makeSanitizeTestCase("only_spaces", " ", testutil.MutationStrEmpty, true, "Only spaces (trimmed to empty)"),
|
||||
makeSanitizeTestCase(
|
||||
"no_changes_needed",
|
||||
"already-sanitized",
|
||||
"already-sanitized",
|
||||
false,
|
||||
"Already in correct format",
|
||||
),
|
||||
|
||||
// Special characters
|
||||
makeSanitizeTestCase(
|
||||
"mixed_case_with_hyphens",
|
||||
testutil.MutationStrSetupNode,
|
||||
"setup-node",
|
||||
false,
|
||||
"Existing hyphens preserved",
|
||||
),
|
||||
makeSanitizeTestCase("underscore_preserved", "hello_world", "hello_world", false, "Underscores not replaced"),
|
||||
makeSanitizeTestCase("numbers_preserved", "Action 123", "action-123", false, "Numbers preserved"),
|
||||
|
||||
// Real-world action names
|
||||
makeSanitizeTestCase(
|
||||
"checkout_action",
|
||||
testutil.MutationStrCheckoutCode,
|
||||
testutil.MutationStrCheckoutCodeDash,
|
||||
false,
|
||||
"Realistic action name",
|
||||
),
|
||||
makeSanitizeTestCase(
|
||||
"setup_go_action",
|
||||
testutil.MutationStrSetupGoEnvironment,
|
||||
testutil.MutationStrSetupGoEnvironmentD,
|
||||
false,
|
||||
"Multi-word action name",
|
||||
),
|
||||
|
||||
// Single character
|
||||
makeSanitizeTestCase("single_char", "A", "a", false, "Single character"),
|
||||
makeSanitizeTestCase("single_space", " ", testutil.MutationStrEmpty, true, "Single space (trimmed)"),
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := SanitizeActionName(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("SanitizeActionName(%q) = %q, want %q (description: %s)",
|
||||
tt.input, got, tt.want, tt.description)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTrimAndNormalizeMutationResistance tests whitespace normalization.
|
||||
// Critical mutations to catch:
|
||||
// - Regex quantifier changes (\s+ to \s*, \s, etc.)
|
||||
// - TrimSpace removal or reordering
|
||||
// - ReplaceAllString to different methods.
|
||||
func TestTrimAndNormalizeMutationResistance(t *testing.T) {
|
||||
tests := []sanitizeTestCase{
|
||||
// Leading/trailing whitespace
|
||||
makeSanitizeTestCase("leading_whitespace", " hello", "hello", true, "TrimSpace removes leading"),
|
||||
makeSanitizeTestCase("trailing_whitespace", "hello ", "hello", true, "TrimSpace removes trailing"),
|
||||
makeSanitizeTestCase("both_sides_whitespace", " hello ", "hello", true, "TrimSpace removes both sides"),
|
||||
|
||||
// Internal whitespace normalization
|
||||
makeSanitizeTestCase(
|
||||
"double_space",
|
||||
testutil.ValidationHelloWorld,
|
||||
testutil.ValidationHelloWorld,
|
||||
true,
|
||||
"Double space to single (\\s+ pattern)",
|
||||
),
|
||||
makeSanitizeTestCase(
|
||||
"triple_space",
|
||||
"hello world",
|
||||
testutil.ValidationHelloWorld,
|
||||
true,
|
||||
"Triple space to single",
|
||||
),
|
||||
makeSanitizeTestCase(
|
||||
"many_spaces",
|
||||
"hello world",
|
||||
testutil.ValidationHelloWorld,
|
||||
true,
|
||||
"Many spaces to single (+ quantifier)",
|
||||
),
|
||||
|
||||
// Mixed whitespace types
|
||||
makeSanitizeTestCase(
|
||||
"tab_character",
|
||||
"hello\tworld",
|
||||
testutil.ValidationHelloWorld,
|
||||
true,
|
||||
"Tab normalized to space (\\s includes tabs)",
|
||||
),
|
||||
makeSanitizeTestCase(
|
||||
"newline_character",
|
||||
"hello\nworld",
|
||||
testutil.ValidationHelloWorld,
|
||||
true,
|
||||
"Newline normalized to space (\\s includes newlines)",
|
||||
),
|
||||
makeSanitizeTestCase(
|
||||
"carriage_return",
|
||||
"hello\rworld",
|
||||
testutil.ValidationHelloWorld,
|
||||
true,
|
||||
"CR normalized to space",
|
||||
),
|
||||
makeSanitizeTestCase(
|
||||
"mixed_whitespace",
|
||||
"hello \t\n world",
|
||||
testutil.ValidationHelloWorld,
|
||||
true,
|
||||
"Mixed whitespace types to single space",
|
||||
),
|
||||
|
||||
// Combined leading/trailing and internal
|
||||
makeSanitizeTestCase(
|
||||
"all_whitespace_issues",
|
||||
" hello world ",
|
||||
testutil.ValidationHelloWorld,
|
||||
true,
|
||||
"Trim + normalize internal",
|
||||
),
|
||||
|
||||
// Edge cases
|
||||
makeSanitizeTestCase(
|
||||
"empty_string",
|
||||
testutil.MutationStrEmpty,
|
||||
testutil.MutationStrEmpty,
|
||||
true,
|
||||
testutil.MutationDescEmptyInput,
|
||||
),
|
||||
makeSanitizeTestCase("only_spaces", " ", testutil.MutationStrEmpty, true, "Only spaces (trimmed to empty)"),
|
||||
makeSanitizeTestCase(
|
||||
"only_whitespace_mixed",
|
||||
" \t\n\r ",
|
||||
testutil.MutationStrEmpty,
|
||||
true,
|
||||
"Only various whitespace types",
|
||||
),
|
||||
makeSanitizeTestCase("no_whitespace", "hello", "hello", false, "No whitespace to normalize"),
|
||||
makeSanitizeTestCase(
|
||||
"single_space_valid",
|
||||
testutil.ValidationHelloWorld,
|
||||
testutil.ValidationHelloWorld,
|
||||
false,
|
||||
"Already normalized",
|
||||
),
|
||||
|
||||
// Multiple words
|
||||
makeSanitizeTestCase(
|
||||
"three_words_excess_spaces",
|
||||
"one two three",
|
||||
"one two three",
|
||||
false,
|
||||
"Three words with excess spaces",
|
||||
),
|
||||
|
||||
// Unicode whitespace
|
||||
makeSanitizeTestCase(
|
||||
"regular_space",
|
||||
testutil.ValidationHelloWorld,
|
||||
testutil.ValidationHelloWorld,
|
||||
false,
|
||||
"Regular ASCII space",
|
||||
),
|
||||
|
||||
// Quantifier verification (\s+ means one or more)
|
||||
makeSanitizeTestCase("single_space_between", "a b", "a b", true, "Single space not collapsed (need + for >1)"),
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := TrimAndNormalize(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("TrimAndNormalize(%q) = %q, want %q (description: %s)",
|
||||
tt.input, got, tt.want, tt.description)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatUsesStatementMutationResistance tests uses statement formatting logic.
|
||||
// Critical mutations to catch:
|
||||
// - Empty string checks (org == "" changed to !=, etc.)
|
||||
// - || changed to && in empty check
|
||||
// - HasPrefix negation (! added/removed)
|
||||
// - String concatenation order
|
||||
// - Default version "v1" changed.
|
||||
func TestFormatUsesStatementMutationResistance(t *testing.T) {
|
||||
tests := []formatTestCase{
|
||||
// Basic formatting
|
||||
makeFormatTestCase(
|
||||
"basic_with_version",
|
||||
testutil.MutationOrgActions,
|
||||
testutil.ValidationCheckout,
|
||||
testutil.ValidationCheckoutV3,
|
||||
testutil.MutationUsesActionsCheckout,
|
||||
false,
|
||||
"Standard format",
|
||||
),
|
||||
|
||||
// Empty checks (critical)
|
||||
makeFormatTestCase(
|
||||
"empty_org_returns_empty",
|
||||
testutil.MutationStrEmpty,
|
||||
testutil.ValidationCheckout,
|
||||
testutil.ValidationCheckoutV3,
|
||||
testutil.MutationStrEmpty,
|
||||
true,
|
||||
"org == \"\" check",
|
||||
),
|
||||
makeFormatTestCase(
|
||||
"empty_repo_returns_empty",
|
||||
testutil.MutationOrgActions,
|
||||
testutil.MutationStrEmpty,
|
||||
testutil.ValidationCheckoutV3,
|
||||
testutil.MutationStrEmpty,
|
||||
true,
|
||||
"repo == \"\" check",
|
||||
),
|
||||
makeFormatTestCase(
|
||||
"both_empty_returns_empty",
|
||||
testutil.MutationStrEmpty,
|
||||
testutil.MutationStrEmpty,
|
||||
testutil.ValidationCheckoutV3,
|
||||
testutil.MutationStrEmpty,
|
||||
true,
|
||||
"org == \"\" || repo == \"\" (|| operator critical)",
|
||||
),
|
||||
|
||||
// Default version (critical)
|
||||
makeFormatTestCase(
|
||||
"empty_version_defaults_v1",
|
||||
testutil.MutationOrgActions,
|
||||
testutil.ValidationCheckout,
|
||||
testutil.MutationStrEmpty,
|
||||
testutil.MutationUsesActionsCheckoutV1,
|
||||
true,
|
||||
"version == \"\" defaults to \"v1\"",
|
||||
),
|
||||
|
||||
// @ prefix handling (critical)
|
||||
makeFormatTestCase(
|
||||
"version_without_at",
|
||||
testutil.MutationOrgActions,
|
||||
testutil.ValidationCheckout,
|
||||
testutil.ValidationCheckoutV3,
|
||||
testutil.MutationUsesActionsCheckout,
|
||||
true,
|
||||
"@ added when not present (!HasPrefix check)",
|
||||
),
|
||||
makeFormatTestCase(
|
||||
"version_with_at",
|
||||
testutil.MutationOrgActions,
|
||||
testutil.ValidationCheckout,
|
||||
"@v3",
|
||||
testutil.MutationUsesActionsCheckout,
|
||||
true,
|
||||
"@ not duplicated (HasPrefix check)",
|
||||
),
|
||||
makeFormatTestCase(
|
||||
"double_at_if_hasprefix_fails",
|
||||
testutil.MutationOrgActions,
|
||||
testutil.ValidationCheckout,
|
||||
"@@v3",
|
||||
"actions/checkout@@v3",
|
||||
false,
|
||||
"Malformed input with double @",
|
||||
),
|
||||
|
||||
// String concatenation order
|
||||
makeFormatTestCase(
|
||||
"concatenation_order",
|
||||
"org",
|
||||
"repo",
|
||||
"ver",
|
||||
testutil.MutationUsesOrgRepo,
|
||||
true,
|
||||
"Correct concatenation: org + \"/\" + repo + version",
|
||||
),
|
||||
|
||||
// Edge cases
|
||||
makeFormatTestCase("single_char_org_repo", "a", "b", "c", "a/b@c", false, "Minimal valid input"),
|
||||
makeFormatTestCase(
|
||||
"branch_name_version",
|
||||
testutil.MutationOrgActions,
|
||||
testutil.ValidationCheckout,
|
||||
"main",
|
||||
"actions/checkout@main",
|
||||
false,
|
||||
"Branch name as version",
|
||||
),
|
||||
makeFormatTestCase(
|
||||
"sha_version",
|
||||
testutil.MutationOrgActions,
|
||||
testutil.ValidationCheckout,
|
||||
"abc1234567890def",
|
||||
"actions/checkout@abc1234567890def",
|
||||
false,
|
||||
"SHA as version",
|
||||
),
|
||||
|
||||
// Whitespace in inputs
|
||||
makeFormatTestCase(
|
||||
"org_with_spaces_not_trimmed",
|
||||
" actions ",
|
||||
testutil.ValidationCheckout,
|
||||
testutil.ValidationCheckoutV3,
|
||||
" actions /checkout@v3",
|
||||
false,
|
||||
"Spaces preserved (no TrimSpace in function)",
|
||||
),
|
||||
|
||||
// Special characters
|
||||
makeFormatTestCase(
|
||||
"hyphen_in_repo",
|
||||
testutil.MutationOrgActions,
|
||||
testutil.MutationRepoSetupNode,
|
||||
testutil.ValidationCheckoutV3,
|
||||
"actions/setup-node@v3",
|
||||
false,
|
||||
"Hyphen in repo name",
|
||||
),
|
||||
makeFormatTestCase(
|
||||
"at_in_version_position",
|
||||
testutil.MutationOrgActions,
|
||||
testutil.ValidationCheckout,
|
||||
"@v3",
|
||||
testutil.MutationUsesActionsCheckout,
|
||||
true,
|
||||
"Existing @ not duplicated",
|
||||
),
|
||||
|
||||
// Boolean operator mutation detection
|
||||
makeFormatTestCase(
|
||||
"non_empty_org_empty_repo",
|
||||
testutil.MutationOrgActions,
|
||||
testutil.MutationStrEmpty,
|
||||
testutil.ValidationCheckoutV3,
|
||||
testutil.MutationStrEmpty,
|
||||
true,
|
||||
"|| means either empty returns \"\" (not &&)",
|
||||
),
|
||||
makeFormatTestCase(
|
||||
"empty_org_non_empty_repo",
|
||||
testutil.MutationStrEmpty,
|
||||
testutil.ValidationCheckout,
|
||||
testutil.ValidationCheckoutV3,
|
||||
testutil.MutationStrEmpty,
|
||||
true,
|
||||
"|| means either empty returns \"\" (not &&)",
|
||||
),
|
||||
|
||||
// Default version with @ handling
|
||||
makeFormatTestCase(
|
||||
"empty_version_gets_at_prefix",
|
||||
testutil.MutationOrgActions,
|
||||
testutil.ValidationCheckout,
|
||||
testutil.MutationStrEmpty,
|
||||
testutil.MutationUsesActionsCheckoutV1,
|
||||
true,
|
||||
"Empty version: default \"v1\" then @ added",
|
||||
),
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := FormatUsesStatement(tt.org, tt.repo, tt.version)
|
||||
if got != tt.want {
|
||||
t.Errorf("FormatUsesStatement(%q, %q, %q) = %q, want %q (description: %s)",
|
||||
tt.org, tt.repo, tt.version, got, tt.want, tt.description)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCleanVersionStringMutationResistance tests version cleaning for operation order.
|
||||
// Critical mutations to catch:
|
||||
// - TrimSpace removal
|
||||
// - TrimPrefix removal or wrong prefix
|
||||
// - Operation order (trim then prefix vs prefix then trim).
|
||||
func TestCleanVersionStringMutationResistance(t *testing.T) {
|
||||
tests := []sanitizeTestCase{
|
||||
// v prefix removal
|
||||
makeSanitizeTestCase("v_prefix_removed", "v1.2.3", "1.2.3", true, "TrimPrefix(\"v\") applied"),
|
||||
makeSanitizeTestCase("no_v_prefix_unchanged", "1.2.3", "1.2.3", true, "No v prefix to remove"),
|
||||
|
||||
// Whitespace handling
|
||||
makeSanitizeTestCase("leading_whitespace", " v1.2.3", "1.2.3", true, "TrimSpace before TrimPrefix"),
|
||||
makeSanitizeTestCase("trailing_whitespace", "v1.2.3 ", "1.2.3", true, "TrimSpace applied"),
|
||||
makeSanitizeTestCase("both_whitespace_and_v", " v1.2.3 ", "1.2.3", true, "Both TrimSpace and TrimPrefix"),
|
||||
|
||||
// Operation order critical
|
||||
makeSanitizeTestCase(
|
||||
"whitespace_before_v",
|
||||
" v1.2.3",
|
||||
"1.2.3",
|
||||
true,
|
||||
"TrimSpace must happen before TrimPrefix",
|
||||
),
|
||||
|
||||
// Edge cases
|
||||
makeSanitizeTestCase("only_v", "v", testutil.MutationStrEmpty, true, "Just v becomes empty"),
|
||||
makeSanitizeTestCase(
|
||||
"empty_string",
|
||||
testutil.MutationStrEmpty,
|
||||
testutil.MutationStrEmpty,
|
||||
true,
|
||||
testutil.MutationDescEmptyInput,
|
||||
),
|
||||
makeSanitizeTestCase("only_whitespace", " ", testutil.MutationStrEmpty, true, "Only spaces"),
|
||||
|
||||
// Multiple v's
|
||||
makeSanitizeTestCase(
|
||||
"double_v",
|
||||
"vv1.2.3",
|
||||
"v1.2.3",
|
||||
true,
|
||||
"Only first v removed (TrimPrefix, not ReplaceAll)",
|
||||
),
|
||||
|
||||
// No changes needed
|
||||
makeSanitizeTestCase("already_clean", "1.2.3", "1.2.3", false, "Already clean"),
|
||||
|
||||
// Real-world versions
|
||||
makeSanitizeTestCase("semver_with_v", testutil.MutationVersionV2, "2.5.1", false, "Realistic semver"),
|
||||
makeSanitizeTestCase("semver_no_v", "2.5.1", "2.5.1", false, "Realistic semver without v"),
|
||||
|
||||
// Whitespace variations
|
||||
makeSanitizeTestCase("tab_character", "\tv1.2.3", "1.2.3", true, "Tab handled by TrimSpace"),
|
||||
makeSanitizeTestCase("newline", "v1.2.3\n", "1.2.3", true, "Newline handled by TrimSpace"),
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := CleanVersionString(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("CleanVersionString(%q) = %q, want %q (description: %s)",
|
||||
tt.input, got, tt.want, tt.description)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
491
internal/validation/strings_property_test.go
Normal file
491
internal/validation/strings_property_test.go
Normal file
@@ -0,0 +1,491 @@
|
||||
package validation
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/leanovate/gopter"
|
||||
"github.com/leanovate/gopter/gen"
|
||||
"github.com/leanovate/gopter/prop"
|
||||
)
|
||||
|
||||
// TestFormatUsesStatementProperties verifies properties of uses statement formatting.
|
||||
func TestFormatUsesStatementProperties(t *testing.T) {
|
||||
properties := gopter.NewProperties(nil)
|
||||
registerUsesStatementProperties(properties)
|
||||
properties.TestingRun(t)
|
||||
}
|
||||
|
||||
// registerUsesStatementProperties registers all uses statement property tests.
|
||||
func registerUsesStatementProperties(properties *gopter.Properties) {
|
||||
registerUsesStatementAtSymbolProperty(properties)
|
||||
registerUsesStatementNonEmptyProperty(properties)
|
||||
registerUsesStatementPrefixProperty(properties)
|
||||
registerUsesStatementEmptyInputProperty(properties)
|
||||
registerUsesStatementVersionPrefixProperty(properties)
|
||||
}
|
||||
|
||||
// registerUsesStatementAtSymbolProperty tests that result contains exactly one @ symbol.
|
||||
func registerUsesStatementAtSymbolProperty(properties *gopter.Properties) {
|
||||
properties.Property("uses statement has exactly one @ symbol when non-empty",
|
||||
prop.ForAll(
|
||||
func(org, repo, version string) bool {
|
||||
result := FormatUsesStatement(org, repo, version)
|
||||
if result == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
return strings.Count(result, "@") == 1
|
||||
},
|
||||
gen.AlphaString(),
|
||||
gen.AlphaString(),
|
||||
gen.AlphaString(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// registerUsesStatementNonEmptyProperty tests non-empty inputs produce non-empty result.
|
||||
func registerUsesStatementNonEmptyProperty(properties *gopter.Properties) {
|
||||
properties.Property("non-empty org and repo produce non-empty result",
|
||||
prop.ForAll(
|
||||
func(org, repo, version string) bool {
|
||||
if org == "" || repo == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
return FormatUsesStatement(org, repo, version) != ""
|
||||
},
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return s != "" }),
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return s != "" }),
|
||||
gen.AlphaString(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// registerUsesStatementPrefixProperty tests result starts with org/repo pattern.
|
||||
func registerUsesStatementPrefixProperty(properties *gopter.Properties) {
|
||||
properties.Property("uses statement starts with org/repo when both non-empty",
|
||||
prop.ForAll(
|
||||
func(org, repo, version string) bool {
|
||||
if org == "" || repo == "" {
|
||||
return true
|
||||
}
|
||||
result := FormatUsesStatement(org, repo, version)
|
||||
|
||||
return strings.HasPrefix(result, org+"/"+repo)
|
||||
},
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return s != "" }),
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return s != "" }),
|
||||
gen.AlphaString(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// registerUsesStatementEmptyInputProperty tests empty inputs produce empty result.
|
||||
func registerUsesStatementEmptyInputProperty(properties *gopter.Properties) {
|
||||
properties.Property("empty org or repo produces empty result",
|
||||
prop.ForAll(
|
||||
func(org, repo, version string) bool {
|
||||
if org == "" || repo == "" {
|
||||
return FormatUsesStatement(org, repo, version) == ""
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
gen.AlphaString(),
|
||||
gen.AlphaString(),
|
||||
gen.AlphaString(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// registerUsesStatementVersionPrefixProperty tests version part has @ prefix.
|
||||
func registerUsesStatementVersionPrefixProperty(properties *gopter.Properties) {
|
||||
properties.Property("version part in result always has @ prefix",
|
||||
prop.ForAll(
|
||||
func(org, repo, version string) bool {
|
||||
if org == "" || repo == "" {
|
||||
return true
|
||||
}
|
||||
result := FormatUsesStatement(org, repo, version)
|
||||
atIndex := strings.Index(result, "@")
|
||||
if atIndex == -1 {
|
||||
return false
|
||||
}
|
||||
|
||||
return strings.HasPrefix(result, org+"/"+repo+"@")
|
||||
},
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return s != "" }),
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return s != "" }),
|
||||
gen.AlphaString(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// TestStringNormalizationProperties verifies idempotency and whitespace properties.
|
||||
func TestStringNormalizationProperties(t *testing.T) {
|
||||
properties := gopter.NewProperties(nil)
|
||||
registerStringNormalizationProperties(properties)
|
||||
properties.TestingRun(t)
|
||||
}
|
||||
|
||||
func registerStringNormalizationProperties(properties *gopter.Properties) {
|
||||
// Property 1: Idempotency - normalizing twice produces same result as once
|
||||
properties.Property("normalization is idempotent",
|
||||
prop.ForAll(
|
||||
func(input string) bool {
|
||||
n1 := TrimAndNormalize(input)
|
||||
n2 := TrimAndNormalize(n1)
|
||||
|
||||
return n1 == n2
|
||||
},
|
||||
gen.AnyString(),
|
||||
),
|
||||
)
|
||||
|
||||
// Property 2: No consecutive spaces in output
|
||||
properties.Property("normalized string has no consecutive spaces",
|
||||
prop.ForAll(
|
||||
func(input string) bool {
|
||||
result := TrimAndNormalize(input)
|
||||
|
||||
return !strings.Contains(result, " ")
|
||||
},
|
||||
gen.AnyString(),
|
||||
),
|
||||
)
|
||||
|
||||
// Property 3: No leading whitespace
|
||||
properties.Property("normalized string has no leading whitespace",
|
||||
prop.ForAll(
|
||||
func(input string) bool {
|
||||
result := TrimAndNormalize(input)
|
||||
|
||||
if result == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
return !strings.HasPrefix(result, " ") &&
|
||||
!strings.HasPrefix(result, "\t") &&
|
||||
!strings.HasPrefix(result, "\n")
|
||||
},
|
||||
gen.AnyString(),
|
||||
),
|
||||
)
|
||||
|
||||
// Property 4: No trailing whitespace
|
||||
properties.Property("normalized string has no trailing whitespace",
|
||||
prop.ForAll(
|
||||
func(input string) bool {
|
||||
result := TrimAndNormalize(input)
|
||||
|
||||
if result == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
return !strings.HasSuffix(result, " ") &&
|
||||
!strings.HasSuffix(result, "\t") &&
|
||||
!strings.HasSuffix(result, "\n")
|
||||
},
|
||||
gen.AnyString(),
|
||||
),
|
||||
)
|
||||
|
||||
// Property 5: All-whitespace input becomes empty
|
||||
properties.Property("whitespace-only input becomes empty",
|
||||
prop.ForAll(
|
||||
func() bool {
|
||||
// Generate whitespace-only strings
|
||||
whitespaceOnly := " \t\n\r "
|
||||
result := TrimAndNormalize(whitespaceOnly)
|
||||
|
||||
return result == ""
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// TestVersionCleaningProperties verifies version string cleaning properties.
|
||||
// versionCleaningIdempotentProperty verifies cleaning twice produces same result.
|
||||
func versionCleaningIdempotentProperty(version string) bool {
|
||||
v1 := CleanVersionString(version)
|
||||
v2 := CleanVersionString(v1)
|
||||
|
||||
return v1 == v2
|
||||
}
|
||||
|
||||
// versionRemovesSingleVProperty verifies single 'v' is removed.
|
||||
func versionRemovesSingleVProperty(version string) bool {
|
||||
result := CleanVersionString(version)
|
||||
if result == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
trimmed := strings.TrimSpace(version)
|
||||
if strings.HasPrefix(trimmed, "v") && !strings.HasPrefix(trimmed, "vv") {
|
||||
return !strings.HasPrefix(result, "v")
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// versionHasNoBoundaryWhitespaceProperty verifies no leading/trailing whitespace.
|
||||
func versionHasNoBoundaryWhitespaceProperty(version string) bool {
|
||||
result := CleanVersionString(version)
|
||||
if result == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
return !strings.HasPrefix(result, " ") &&
|
||||
!strings.HasSuffix(result, " ") &&
|
||||
!strings.HasPrefix(result, "\t") &&
|
||||
!strings.HasSuffix(result, "\t")
|
||||
}
|
||||
|
||||
// whitespaceOnlyVersionBecomesEmptyProperty verifies whitespace-only inputs become empty.
|
||||
func whitespaceOnlyVersionBecomesEmptyProperty() bool {
|
||||
whitespaceInputs := []string{" ", "\t\t", "\n", " \t\n "}
|
||||
for _, input := range whitespaceInputs {
|
||||
result := CleanVersionString(input)
|
||||
if result != "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// nonVContentPreservedProperty verifies non-v content is preserved and trimmed.
|
||||
func nonVContentPreservedProperty(content string) bool {
|
||||
trimmed := strings.TrimSpace(content)
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "v") {
|
||||
return true // Skip these cases
|
||||
}
|
||||
|
||||
result := CleanVersionString(content)
|
||||
|
||||
return result == trimmed
|
||||
}
|
||||
|
||||
func TestVersionCleaningProperties(t *testing.T) {
|
||||
properties := gopter.NewProperties(nil)
|
||||
|
||||
// Property 1: Idempotency - cleaning twice produces same result
|
||||
properties.Property("version cleaning is idempotent",
|
||||
prop.ForAll(versionCleaningIdempotentProperty, gen.AnyString()),
|
||||
)
|
||||
|
||||
// Property 2: Result never starts with single 'v' (TrimPrefix removes only one)
|
||||
properties.Property("cleaned version removes single leading v",
|
||||
prop.ForAll(versionRemovesSingleVProperty, gen.AnyString()),
|
||||
)
|
||||
|
||||
// Property 3: No leading/trailing whitespace in result
|
||||
properties.Property("cleaned version has no boundary whitespace",
|
||||
prop.ForAll(versionHasNoBoundaryWhitespaceProperty, gen.AnyString()),
|
||||
)
|
||||
|
||||
// Property 4: Whitespace-only input becomes empty
|
||||
properties.Property("whitespace-only version becomes empty",
|
||||
prop.ForAll(whitespaceOnlyVersionBecomesEmptyProperty),
|
||||
)
|
||||
|
||||
// Property 5: Preserves non-v content and trims whitespace
|
||||
properties.Property("non-v content is preserved",
|
||||
prop.ForAll(
|
||||
nonVContentPreservedProperty,
|
||||
gen.OneGenOf(
|
||||
gen.AlphaString(),
|
||||
gen.AlphaString().Map(func(s string) string { return " " + s }),
|
||||
gen.AlphaString().Map(func(s string) string { return s + " " }),
|
||||
gen.AlphaString().Map(func(s string) string { return " " + s + " " }),
|
||||
gen.AlphaString().Map(func(s string) string { return "\t" + s + "\n" }),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
properties.TestingRun(t)
|
||||
}
|
||||
|
||||
// TestSanitizeActionNameProperties verifies action name sanitization properties.
|
||||
func TestSanitizeActionNameProperties(t *testing.T) {
|
||||
properties := gopter.NewProperties(nil)
|
||||
|
||||
// Property 1: Result is always lowercase
|
||||
properties.Property("sanitized name is always lowercase",
|
||||
prop.ForAll(
|
||||
func(name string) bool {
|
||||
result := SanitizeActionName(name)
|
||||
|
||||
return result == strings.ToLower(result)
|
||||
},
|
||||
gen.AnyString(),
|
||||
),
|
||||
)
|
||||
|
||||
// Property 2: No spaces in result
|
||||
properties.Property("sanitized name has no spaces",
|
||||
prop.ForAll(
|
||||
func(name string) bool {
|
||||
result := SanitizeActionName(name)
|
||||
|
||||
return !strings.Contains(result, " ")
|
||||
},
|
||||
gen.AnyString(),
|
||||
),
|
||||
)
|
||||
|
||||
// Property 3: Idempotency
|
||||
properties.Property("sanitization is idempotent",
|
||||
prop.ForAll(
|
||||
func(name string) bool {
|
||||
s1 := SanitizeActionName(name)
|
||||
s2 := SanitizeActionName(s1)
|
||||
|
||||
return s1 == s2
|
||||
},
|
||||
gen.AnyString(),
|
||||
),
|
||||
)
|
||||
|
||||
// Property 4: Whitespace-only input becomes empty
|
||||
properties.Property("whitespace-only input becomes empty",
|
||||
prop.ForAll(
|
||||
func() bool {
|
||||
whitespaceInputs := []string{" ", "\t\t", " \n "}
|
||||
for _, input := range whitespaceInputs {
|
||||
result := SanitizeActionName(input)
|
||||
if result != "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
// Property 5: Spaces become hyphens
|
||||
properties.Property("spaces are converted to hyphens",
|
||||
prop.ForAll(
|
||||
func(word1 string, word2 string) bool {
|
||||
// Only test when words are non-empty and don't contain spaces
|
||||
if word1 == "" || word2 == "" ||
|
||||
strings.Contains(word1, " ") ||
|
||||
strings.Contains(word2, " ") {
|
||||
return true
|
||||
}
|
||||
|
||||
input := word1 + " " + word2
|
||||
result := SanitizeActionName(input)
|
||||
|
||||
// Result should contain a hyphen where the space was
|
||||
expectedPart1 := strings.ToLower(word1)
|
||||
expectedPart2 := strings.ToLower(word2)
|
||||
expected := expectedPart1 + "-" + expectedPart2
|
||||
|
||||
return result == expected
|
||||
},
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return len(s) > 0 && !strings.Contains(s, " ") }),
|
||||
gen.AlphaString().SuchThat(func(s string) bool { return len(s) > 0 && !strings.Contains(s, " ") }),
|
||||
),
|
||||
)
|
||||
|
||||
properties.TestingRun(t)
|
||||
}
|
||||
|
||||
// TestParseGitHubURLProperties verifies URL parsing properties.
|
||||
func TestParseGitHubURLProperties(t *testing.T) {
|
||||
properties := gopter.NewProperties(nil)
|
||||
registerGitHubURLProperties(properties)
|
||||
properties.TestingRun(t)
|
||||
}
|
||||
|
||||
// registerGitHubURLProperties registers all GitHub URL parsing property tests.
|
||||
func registerGitHubURLProperties(properties *gopter.Properties) {
|
||||
registerGitHubURLEmptyInputProperty(properties)
|
||||
registerGitHubURLSimpleFormatProperty(properties)
|
||||
registerGitHubURLNoSlashesProperty(properties)
|
||||
registerGitHubURLInvalidInputProperty(properties)
|
||||
registerGitHubURLConsistencyProperty(properties)
|
||||
}
|
||||
|
||||
// registerGitHubURLEmptyInputProperty tests empty URL produces empty results.
|
||||
func registerGitHubURLEmptyInputProperty(properties *gopter.Properties) {
|
||||
properties.Property("empty URL produces empty org and repo",
|
||||
prop.ForAll(
|
||||
func() bool {
|
||||
org, repo := ParseGitHubURL("")
|
||||
|
||||
return org == "" && repo == ""
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// registerGitHubURLSimpleFormatProperty tests simple org/repo format parsing.
|
||||
func registerGitHubURLSimpleFormatProperty(properties *gopter.Properties) {
|
||||
properties.Property("simple org/repo format always parses correctly",
|
||||
prop.ForAll(
|
||||
func(org, repo string) bool {
|
||||
if org == "" || repo == "" || strings.Contains(org, "/") || strings.Contains(repo, "/") {
|
||||
return true
|
||||
}
|
||||
gotOrg, gotRepo := ParseGitHubURL(org + "/" + repo)
|
||||
|
||||
return gotOrg == org && gotRepo == repo
|
||||
},
|
||||
gen.AlphaString().SuchThat(func(s string) bool {
|
||||
return len(s) > 0 && !strings.Contains(s, "/") && !strings.Contains(s, ".")
|
||||
}),
|
||||
gen.AlphaString().SuchThat(func(s string) bool {
|
||||
return len(s) > 0 && !strings.Contains(s, "/")
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// registerGitHubURLNoSlashesProperty tests parsed results never contain slashes.
|
||||
func registerGitHubURLNoSlashesProperty(properties *gopter.Properties) {
|
||||
properties.Property("parsed org and repo never contain slashes",
|
||||
prop.ForAll(
|
||||
func(url string) bool {
|
||||
org, repo := ParseGitHubURL(url)
|
||||
|
||||
return !strings.Contains(org, "/") && !strings.Contains(repo, "/")
|
||||
},
|
||||
gen.AnyString(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// registerGitHubURLInvalidInputProperty tests invalid URLs produce empty results.
|
||||
func registerGitHubURLInvalidInputProperty(properties *gopter.Properties) {
|
||||
properties.Property("URLs without slash produce empty result",
|
||||
prop.ForAll(
|
||||
func(url string) bool {
|
||||
if strings.Contains(url, "/") || strings.Contains(url, "github.com") {
|
||||
return true
|
||||
}
|
||||
org, repo := ParseGitHubURL(url)
|
||||
|
||||
return org == "" && repo == ""
|
||||
},
|
||||
gen.AlphaString(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// registerGitHubURLConsistencyProperty tests org and repo are both empty or both non-empty.
|
||||
func registerGitHubURLConsistencyProperty(properties *gopter.Properties) {
|
||||
properties.Property("org and repo are both empty or both non-empty",
|
||||
prop.ForAll(
|
||||
func(url string) bool {
|
||||
org, repo := ParseGitHubURL(url)
|
||||
|
||||
return (org == "" && repo == "") || (org != "" && repo != "")
|
||||
},
|
||||
gen.AnyString(),
|
||||
),
|
||||
)
|
||||
}
|
||||
146
internal/validation/strings_test.go
Normal file
146
internal/validation/strings_test.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package validation
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
// TestTrimAndNormalize tests the TrimAndNormalize function.
|
||||
func TestTrimAndNormalize(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []testutil.StringTestCase{
|
||||
{
|
||||
Name: "no whitespace",
|
||||
Input: "test",
|
||||
Want: "test",
|
||||
},
|
||||
{
|
||||
Name: "leading and trailing whitespace",
|
||||
Input: " test ",
|
||||
Want: "test",
|
||||
},
|
||||
{
|
||||
Name: "multiple internal spaces",
|
||||
Input: "hello world",
|
||||
Want: testutil.ValidationHelloWorld,
|
||||
},
|
||||
{
|
||||
Name: "mixed whitespace",
|
||||
Input: " hello world ",
|
||||
Want: testutil.ValidationHelloWorld,
|
||||
},
|
||||
{
|
||||
Name: "newlines and tabs",
|
||||
Input: "hello\n\t\tworld",
|
||||
Want: testutil.ValidationHelloWorld,
|
||||
},
|
||||
{
|
||||
Name: "empty string",
|
||||
Input: "",
|
||||
Want: "",
|
||||
},
|
||||
{
|
||||
Name: "whitespace only",
|
||||
Input: " \n\t ",
|
||||
Want: "",
|
||||
},
|
||||
{
|
||||
Name: "multiple lines",
|
||||
Input: "line one\n line two\n line three",
|
||||
Want: "line one line two line three",
|
||||
},
|
||||
}
|
||||
|
||||
testutil.RunStringTests(t, tests, TrimAndNormalize)
|
||||
}
|
||||
|
||||
// TestFormatUsesStatement tests the FormatUsesStatement function.
|
||||
func TestFormatUsesStatement(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
org string
|
||||
repo string
|
||||
version string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "full statement with version",
|
||||
org: "actions",
|
||||
repo: "checkout",
|
||||
version: "v3",
|
||||
want: testutil.TestActionCheckoutV3,
|
||||
},
|
||||
{
|
||||
name: "without version defaults to v1",
|
||||
org: "actions",
|
||||
repo: "setup-node",
|
||||
version: "",
|
||||
want: "actions/setup-node@v1",
|
||||
},
|
||||
{
|
||||
name: "version with @ prefix",
|
||||
org: "actions",
|
||||
repo: "cache",
|
||||
version: "@v2",
|
||||
want: "actions/cache@v2",
|
||||
},
|
||||
{
|
||||
name: "version without @ prefix",
|
||||
org: "actions",
|
||||
repo: "upload-artifact",
|
||||
version: "v4",
|
||||
want: "actions/upload-artifact@v4",
|
||||
},
|
||||
{
|
||||
name: "empty org returns empty",
|
||||
org: "",
|
||||
repo: "checkout",
|
||||
version: "v3",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "empty repo returns empty",
|
||||
org: "actions",
|
||||
repo: "",
|
||||
version: "v3",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "both org and repo empty",
|
||||
org: "",
|
||||
repo: "",
|
||||
version: "v3",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "sha as version",
|
||||
org: "actions",
|
||||
repo: "checkout",
|
||||
version: "abc123def456",
|
||||
want: "actions/checkout@abc123def456",
|
||||
},
|
||||
{
|
||||
name: "main branch as version",
|
||||
org: "actions",
|
||||
repo: "checkout",
|
||||
version: "main",
|
||||
want: "actions/checkout@main",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := FormatUsesStatement(tt.org, tt.repo, tt.version)
|
||||
if got != tt.want {
|
||||
t.Errorf("FormatUsesStatement(%q, %q, %q) = %q, want %q",
|
||||
tt.org, tt.repo, tt.version, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user