diff --git a/.checkov.yaml b/.checkov.yaml new file mode 100644 index 0000000..af0c4a4 --- /dev/null +++ b/.checkov.yaml @@ -0,0 +1,22 @@ +block-list-secret-scan: [ ] +branch: master +directory: + - . +download-external-modules: false +evaluate-variables: true +external-modules-download-path: .external_modules +framework: + - secrets + - github_configuration + - github_actions + - json + - yaml + - sca_package + - sca_image +mask: [ ] +secrets-history-timeout: 12h +secrets-scan-file-type: [ ] +skip-path: + - terraform + - venv +summary-position: top diff --git a/.github/stale.yml b/.github/stale.yml deleted file mode 100644 index 4687cbc..0000000 --- a/.github/stale.yml +++ /dev/null @@ -1,18 +0,0 @@ -# Number of days of inactivity before an issue becomes stale -daysUntilStale: 30 -# Number of days of inactivity before a stale issue is closed -daysUntilClose: 7 -# Issues with these labels will never be considered stale -exemptLabels: - - pinned - - security - - enhancement -# Label to use when marking an issue as stale -staleLabel: wontfix -# Comment to post when marking an issue as stale. Set to `false` to disable -markComment: > - This issue has been automatically marked as stale because it has not had - recent activity. It will be closed if no further activity occurs. Thank you - for your contributions. -# Comment to post when closing a stale issue. Set to `false` to disable -closeComment: false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4afa6a6..afd2d38 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: ## this will contain a matrix of all the combinations ## we wish to test again: matrix: - go-version: [ 1.20.x ] + go-version: [ 1.24.x ] platform: [ ubuntu-latest, macos-latest, windows-latest ] ## Defines the platform for each test run @@ -25,13 +25,13 @@ jobs: steps: ## sets up go based on the version - name: Install Go - uses: actions/setup-go@fac708d6674e30b6ba41289acaab6d4b75aa0753 # v4.0.1 + uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.4.0 with: go-version: ${{ matrix.go-version }} ## checks out our code locally, so we can work with the files - name: Checkout code - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 ## runs go test ./... - name: Build @@ -39,4 +39,9 @@ jobs: ## runs go test ./... - name: Test - run: go test ./... + run: go test ./... -coverprofile=./cover.out + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@1f60566a86da84c4b4b64c17662a90de97fbb8d7 # v5.4.2 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index ffd549a..2aaf6b7 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -19,6 +19,7 @@ on: branches: [ "master" ] schedule: - cron: '36 4 * * 0' +permissions: read-all jobs: analyze: @@ -32,17 +33,17 @@ jobs: strategy: fail-fast: false matrix: - language: [ 'go', 'javascript' ] + language: [ 'go' ] # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support steps: - name: Checkout repository - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@1245696032ecf7d39f87d54daa406e22ddf769a8 # codeql-bundle-20230524 + uses: github/codeql-action/init@4c3e5362829f0b0bb62ff5f6c938d7f95574c306 # codeql-bundle-v2.21.1 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -52,7 +53,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@1245696032ecf7d39f87d54daa406e22ddf769a8 # codeql-bundle-20230524 + uses: github/codeql-action/autobuild@4c3e5362829f0b0bb62ff5f6c938d7f95574c306 # codeql-bundle-v2.21.1 # â„šī¸ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -65,4 +66,4 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@1245696032ecf7d39f87d54daa406e22ddf769a8 # codeql-bundle-20230524 + uses: github/codeql-action/analyze@4c3e5362829f0b0bb62ff5f6c938d7f95574c306 # codeql-bundle-v2.21.1 diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index a47957b..027c8f1 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -8,48 +8,53 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Install Go - uses: actions/setup-go@fac708d6674e30b6ba41289acaab6d4b75aa0753 # v4.0.1 + uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.4.0 with: - go-version: 1.20.x + go-version: 1.24.x - name: Restore cache - uses: actions/cache@88522ab9f39a2ea568f7027eddc7d8d8bc9d59c8 # v3.3.1 + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 with: path: ~/go/pkg/mod key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }} restore-keys: | ${{ runner.os }}-go-mod- - - name: Fmt - run: make fmt + - name: gofumpt + uses: jameswoolfenden/auto-gofmt@99a3ed2b78b6c01d70db1740ba16d3dff60003df # v0.0.3 test: strategy: matrix: - go-version: [ 1.20.x ] + go-version: [ 1.24.x ] platform: [ ubuntu-latest, macos-latest, windows-latest ] runs-on: ${{ matrix.platform }} steps: - name: Checkout code - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Install Go - uses: actions/setup-go@fac708d6674e30b6ba41289acaab6d4b75aa0753 # v4.0.1 + uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.4.0 with: go-version: ${{ matrix.go-version }} - name: Restore cache - uses: actions/cache@88522ab9f39a2ea568f7027eddc7d8d8bc9d59c8 # v3.3.1 + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 with: path: ~/go/pkg/mod key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }} restore-keys: | ${{ runner.os }}-go-mod- + ## runs go test ./... + - name: Build + run: go build ./... + + ## runs go test ./... - name: Test - run: make test + run: go test ./... -coverprofile=./cover.out docs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Install Go - uses: actions/setup-go@fac708d6674e30b6ba41289acaab6d4b75aa0753 # v4.0.1 + uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.4.0 with: - go-version: 1.20.x + go-version: 1.24.x diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 097c5c1..2e00b5f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,5 +1,5 @@ --- -name: release +name: Release on: push: tags: @@ -11,21 +11,21 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Unshallow run: git fetch --prune --unshallow - name: Set up Go - uses: actions/setup-go@fac708d6674e30b6ba41289acaab6d4b75aa0753 # v4.0.1 + uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.4.0 with: - go-version: 1.19 + go-version: 1.24 - name: Import GPG key id: import_gpg - uses: crazy-max/ghaction-import-gpg@72b6676b71ab476b77e676928516f6982eef7a41 # v5.3.0 + uses: crazy-max/ghaction-import-gpg@e89d40939c28e39f97cf32126055eeae86ba74ec # v6.3.0 with: gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} passphrase: ${{ secrets.PASSPHRASE }} - name: Run GoReleaser - uses: goreleaser/goreleaser-action@336e29918d653399e599bfca99fadc1d7ffbc9f7 # v4.3.0 + uses: goreleaser/goreleaser-action@9c156ee8a17a598857849441385a2041ef570552 # v6.3.0 with: version: latest args: release --clean @@ -41,16 +41,16 @@ jobs: needs: - goreleaser steps: - - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Publish to Registry - uses: elgohr/Publish-Docker-Github-Action@219613003b08f4d049f34cb56c92e84345e1bb3f # v5 + uses: elgohr/Publish-Docker-Github-Action@82556589c08f584cb95411629a94e6c2b68b9b80 # v5 with: name: jameswoolfenden/ghat username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_PASSWORD }} tags: "latest,${{ github.ref_name }}" - name: Update Docker Hub README - uses: peter-evans/dockerhub-description@579f64ca0abced29dbbc44ab4c6a0b9e33ab3588 # v3.4.1 + uses: peter-evans/dockerhub-description@432a30c9e07499fd01da9f8a49f0faf9e0ca5b77 # v4.0.2 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_PASSWORD }} @@ -64,7 +64,7 @@ jobs: - goreleaser steps: - name: Repository Dispatch - uses: peter-evans/repository-dispatch@26b39ed245ab8f31526069329e112ab2fb224588 # v2.1.1 + uses: peter-evans/repository-dispatch@ff45666b9427631e3450c54a1bcbee4d9ff4d7c0 # v3.0.0 with: token: ${{ secrets.PAT }} repository: jameswoolfenden/scoop diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 0000000..663f980 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,23 @@ +name: 'Stale' +on: + schedule: + - cron: '30 1 * * *' + workflow_dispatch: +permissions: read-all + +jobs: + stale: + permissions: + contents: write + issues: write + pull-requests: write + runs-on: ubuntu-latest + steps: + - uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0 + with: + stale-issue-message: 'This issue is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days.' + days-before-stale: 30 + days-before-close: 5 + enable-statistics: true + exempt-issue-labels: enhancement + exempt-pr-labels: enhancement diff --git a/.gitignore b/.gitignore index e806b1e..11531ff 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,11 @@ __debug_bin.exe .ghat provider.azure.tf provider.azurerm.tf +terraform-provider-* +*.pem +*.csr +.destination +tf.plan +tf.json + +dist/ diff --git a/.goreleaser.yml b/.goreleaser.yml index 1d4ed6e..77cf9fc 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -1,5 +1,11 @@ # .goreleaser.yml +# The lines below are called `modelines`. See `:help modeline` +# Feel free to remove those if you don't want/need to use them. +# yaml-language-server: $schema=https://goreleaser.com/static/schema.json +# vim: set ts=2 sw=2 tw=0 fo=cnqoj + +version: 2 before: hooks: - ./set-version.sh @@ -10,13 +16,13 @@ builds: - linux - windows goarch: - - 386 + - "386" - amd64 - arm64 goarm: - - 7 + - "7" ignore: - - goarch: 386 + - goarch: "386" goos: darwin archives: - format_overrides: @@ -26,7 +32,7 @@ archives: brews: - name: ghat - tap: + repository: owner: JamesWoolfenden name: homebrew-tap token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 01bf506..3f064ce 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,62 +1,93 @@ ---- -# yamllint disable rule:line-length default_language_version: - python: python3.10 + python: python3.11 repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 - hooks: - - id: check-json - - id: check-merge-conflict - - id: trailing-whitespace - - id: end-of-file-fixer - - id: check-yaml - exclude: examples/ - - id: check-added-large-files - - id: pretty-format-json - args: - - --autofix - - id: detect-aws-credentials - - id: detect-private-key - - repo: https://github.com/Lucas-C/pre-commit-hooks - rev: v1.5.1 - hooks: - - id: forbid-tabs - exclude_types: [ python, javascript, dtd, markdown, makefile, xml ] - exclude: binary|\.bin$|rego|\.rego$|go|\.go$ - - repo: https://github.com/jameswoolfenden/pre-commit-shell - rev: 0.0.2 - hooks: - - id: shell-lint - - repo: https://github.com/igorshubovych/markdownlint-cli - rev: v0.34.0 - hooks: - - id: markdownlint - exclude: src/testdata|testdata - - repo: https://github.com/jameswoolfenden/pre-commit - rev: v0.1.50 - hooks: - - id: terraform-fmt - language_version: python3.10 - - repo: https://github.com/gruntwork-io/pre-commit - rev: v0.1.22 - hooks: - - id: gofmt - - id: goimports - - repo: https://github.com/syntaqx/git-hooks - rev: v0.0.18 - hooks: - - id: go-test - args: [ "./..." ] - - id: go-mod-tidy - - id: go-generate - - repo: https://github.com/golangci/golangci-lint - rev: v1.53.3 - hooks: - - id: golangci-lint - - repo: https://github.com/bridgecrewio/checkov - rev: 2.3.294 - hooks: - - id: checkov - language_version: python3.10 - args: ["-d", "."] + - hooks: + - id: check-json + - id: check-merge-conflict + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + exclude: examples/ + - id: check-added-large-files + - id: pretty-format-json + args: + - --autofix + - id: detect-aws-credentials + - id: detect-private-key + repo: https://github.com/pre-commit/pre-commit-hooks + rev: cef0300fd0fc4d2a87a85fa2093c6b283ea36f4b + - hooks: + - id: forbid-tabs + exclude: binary|\.bin$|rego|\.rego$|go|\.go$ + exclude_types: + - python + - javascript + - dtd + - markdown + - makefile + - xml + repo: https://github.com/Lucas-C/pre-commit-hooks + rev: a30f0d816e5062a67d87c8de753cfe499672b959 + - hooks: + - id: shell-lint + repo: https://github.com/jameswoolfenden/pre-commit-shell + rev: 062f0b028ae65827e04f91c1e6738cfcbe9b337f + - hooks: + - id: markdownlint + exclude: src/testdata|testdata + repo: https://github.com/igorshubovych/markdownlint-cli + rev: 586c3ea3f51230da42bab657c6a32e9e66c364f0 + - hooks: + - id: terraform-fmt + language_version: python3.11 + repo: https://github.com/jameswoolfenden/pre-commit + rev: b00d945c0dce54f230a5d1cfb7d24e285396e1f2 + - hooks: + - id: gofmt + - id: goimports + repo: https://github.com/gruntwork-io/pre-commit + rev: 59fd8610ae21aaf8234f1ef17d43c3ccdee84d16 + - hooks: + - id: go-test + args: + - ./... + - id: go-mod-tidy + - id: go-generate + repo: https://github.com/syntaqx/git-hooks + rev: a3b888f92cd5b40b270c9a9752181fdc1717cbe5 + - hooks: + - id: golangci-lint + repo: https://github.com/golangci/golangci-lint + rev: 8c14421d29bd005dee63044d07aa897b7d1bf8b0 + - hooks: + - id: checkov + language_version: python3.11 + args: + - -d + - . + repo: https://github.com/bridgecrewio/checkov + rev: 3.2.408 + - hooks: + - id: ghat-go + name: ghat + entry: ghat swot -d . --continue-on-error true + language: golang + types: + - yaml + always_run: true + description: upgrade action dependencies + - id: ghat-go-sift + name: sift + entry: ghat sift -d . + language: golang + types: + - yaml + always_run: true + description: upgrade action dependencies + repo: local + - hooks: + - id: validate-toml + - id: no-go-testing + - id: go-mod-tidy + repo: https://github.com/dnephin/pre-commit-golang + rev: fb24a639f7c938759fe56eeebbb7713b69d60494 diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index 23bbc05..8c871b9 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -9,6 +9,14 @@ pass_filenames: false types: [ yaml ] +- id: ghat-go-sift + name: sift + description: upgrade pre-commit dependencies + language: golang + entry: ghat sift -d . + pass_filenames: false + types: [ yaml ] + # Build and run `ghat-docs` assuming it was installed manually # or via package manager # REQUIRES: ghat-docs to be installed and on the $PATH @@ -20,6 +28,14 @@ pass_filenames: false types: [ yaml ] +- id: ghat-system-sift + name: sift + description: upgrade pre-commit dependencies + language: system + entry: ghat sift -d . + pass_filenames: false + types: [ yaml ] + # Builds and runs the Docker image from the repo # REQUIRES: Docker installed - id: ghat-docker @@ -29,3 +45,11 @@ entry: ghat swot -d . pass_filenames: false types: [ yaml ] + +- id: ghat-docker-sift + name: sift + description: upgrade pre-commit dependencies (via Docker build) + language: docker + entry: ghat sift -d . + pass_filenames: false + types: [ yaml ] diff --git a/Dockerfile b/Dockerfile index a61a4fa..e7936c3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,11 @@ -FROM alpine +FROM alpine:3.18.2 RUN apk --no-cache add build-base git curl jq bash -RUN curl -s -k https://api.github.com/repos/JamesWoolfenden/ghat/releases/latest | jq '.assets[] | select(.name | contains("linux_386")) | select(.content_type | contains("gzip")) | .browser_download_url' -r | awk '{print "curl -L -k " $0 " -o ./ghat.tar.gz"}' | sh +RUN curl -s https://api.github.com/repos/JamesWoolfenden/ghat/releases/latest | jq '.assets[] | select(.name | contains("linux_386")) | select(.content_type | contains("gzip")) | .browser_download_url' -r | awk '{print "curl -L -k " $0 " -o ./ghat.tar.gz"}' | sh RUN tar -xf ./ghat.tar.gz -C /usr/bin/ && rm ./ghat.tar.gz && chmod +x /usr/bin/ghat && echo 'alias ghat="/usr/bin/ghat"' >> ~/.bashrc COPY entrypoint.sh /entrypoint.sh # Code file to execute when the docker container starts up (`entrypoint.sh`) ENTRYPOINT ["/entrypoint.sh"] + +LABEL layer.0.author="JamesWoolfenden" diff --git a/README.md b/README.md index fe0c34d..2cb0903 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,9 @@ [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)](https://github.com/pre-commit/pre-commit) [![checkov](https://img.shields.io/badge/checkov-verified-brightgreen)](https://www.checkov.io/) [![Github All Releases](https://img.shields.io/github/downloads/jameswoolfenden/ghat/total.svg)](https://github.com/JamesWoolfenden/ghat/releases) +[![codecov](https://codecov.io/gh/JamesWoolfenden/ghat/graph/badge.svg?token=P9V791WMRE)](https://codecov.io/gh/JamesWoolfenden/ghat) -Ghat is a tool (GHAT) for updating dependencies in a GHA - GitHub Action. It replaces insecure mutable tags with immutable commit hashes as well as using the latest released version: +Ghat is a tool (GHAT) for updating dependencies in a GHA - GitHub Action, **managing Terraform Dependencies** and pre-commit configs. It replaces insecure mutable tags with immutable commit hashes as well as using the latest released version: ```yml ## sets up go based on the version @@ -39,7 +40,27 @@ Becomes uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 ``` -Ghat will use your Github creds, if available, from your environment using the environmental variables GITHUB_TOKEN or GITHUB_API, but it can also drop back to anonymous access, the drawback is that this is severely rate limited by gitHub. +Ghat will use your GitHub credentials, if available, from your environment using the environmental variables GITHUB_TOKEN or GITHUB_API, but it can also drop back to anonymous access, the drawback is that this is severely rate limited by gitHub. + +Ghat also manages Terraform modules, to give you the most secure reference, so: + +```terraform +module "ip" { + source = "JamesWoolfenden/ip/http" + version = "0.3.12" + permissions = "pike" +} +``` + +Becomes: + +```terraform +module "ip" { + source = "git::https://github.com/JamesWoolfenden/terraform-http-ip.git?ref=a6cf071d14365133f48ed161812c14b00ad3c692" + permissions = "pike" +} + +``` ## Table of Contents @@ -51,6 +72,13 @@ Ghat will use your Github creds, if available, from your environment using the e - [Windows](#windows) - [Docker](#docker) - [Usage](#usage) + - [swot](#swot) + - [directory](#directory-scan) + - [file](#file-scan) + - [stable](#stable-releases) + - [pre-commit](#pre-commit) + - [swipe](#swipe) + - [sift](#sift) @@ -99,33 +127,99 @@ scoop install ghat ```shell docker pull jameswoolfenden/ghat -docker run --tty --volume /local/path/to/tf:/tf jameswoolfenden/ghat scan -d /tf +docker run --tty --volume /local/path/to/repo:/repo jameswoolfenden/ghat swot -d /repo ``` ## Usage -To authenticate the GitHub Api you should set-up your GitHub Personal Access Token as the environment variable -*GITHUB_API* or *GITHUB_TOKEN*, it will fall back to using anonymous if you dont but RATE LIMITS. +To authenticate the GitHub Api you should set up your GitHub Personal Access Token as the environment variable +*GITHUB_API* or *GITHUB_TOKEN*, it will fall back to using anonymous if you don't but RATE LIMITS. + +### swot + +#### Directory scan -### Directory scan -This will look for the .github/worflow folder and update all the files it find there, and display a diff of the changes made to each file: +This will look for the .github/workflow folder and update all the files it finds there, and display a diff of the changes made to each file: ```bash $ghat swot -d . ``` -### Individual file scan +#### File scan -``` +```bash $ghat swot -f .\.github\workflows\ci.yml ``` +#### Stable releases + +If you're concerned that the very latest release might be too fresh, and would rather have the latest from 2 weeks ago? +I got you covered: + +```bash +$ghat swot -d . --stable 14 +``` + +### Swipe + +Updates Terraform modules to use secure module references, and displays a file diff: + +```bash + ghat swipe -f .\registry\module.git.tf -update + _ _ + __ _ | |_ __ _ | |_ +/ _` || ' \ / _` || _| +\__, ||_||_|\__,_| \__| +|___/ +version: 9.9.9 +1:42PM INF module source is git::https://github.com/terraform-aws-modules/terraform-aws-memory-db.git?depth=1 of type shallow and cannot be updated +module "ip" { + source = "git::https://github.com/JamesWoolfenden/ip/terraform-http" + v-ip.git?rersion f= "aca5d0.4513.1698f2f564913cfcc3534780794c800" + permissions = "pike" +} +``` + +The update flag can be used to update the reference, the default behaviour is just to change the reference to a git bashed hash. + +### sift + +Sift updates pre-commit configs with the latest hooks using hashes. +Commands are similar, but only the directory is needed: + +```shell +ghat sift -d . +``` + +The flag dryrun is also supported. Example outcome display: + +```yaml + - hooks: + - id: forbid-tabs + exclude: binary|\.bin$|rego|\.rego$|go|\.go$ + exclude_types: + - python + - javascript + - dtd + - markdown + - makefile + - xml + repo: https://github.com/Lucas-C/pre-commit-hooks + rev: 762c66ea96843b54b936fc680162ea67f85ec2d7 +``` + ## Help ```bash -./ghat -h + ghat --help + _ _ + __ _ | |_ __ _ | |_ +/ _` || ' \ / _` || _| +\__, ||_||_|\__,_| \__| +|___/ +version: v0.1.1 NAME: ghat - Update GHA dependencies @@ -133,13 +227,15 @@ USAGE: ghat [global options] command [command options] [arguments...] VERSION: - 9.9.9 + v0.1.1 AUTHOR: James Woolfenden COMMANDS: - swot, a updates GHA in a directory + sift, p updates pre-commit version with hashes + swipe, w updates Terraform module versions with versioned hashes + swot, a updates GHA versions for hashes version, v Outputs the application version help, h Shows a list of commands or help for one command @@ -147,11 +243,33 @@ GLOBAL OPTIONS: --help, -h show help --version, -v print the version +COPYRIGHT: + James Woolfenden +``` + +### pre-commit + +I've added a number of pre-commit hooks to this repo that will update your build configs, +update .pre-commit-config.yaml + +```yaml + - repo: https://github.com/JamesWoolfenden/ghat/actions + rev: v0.0.10 + hooks: + - id: ghat-go + name: ghat + description: upgrade action dependencies + language: golang + entry: ghat swot -d . + pass_filenames: false + always_run: true + types: [ yaml ] + ``` ## Building -```go +```shell go build ``` @@ -162,3 +280,5 @@ Make build ``` ## Extending + +Log an issue, a pr or an email to jim.wolf @ duck.com. diff --git a/bump.ps1 b/bump.ps1 index b7eb557..7c9ba9c 100644 --- a/bump.ps1 +++ b/bump.ps1 @@ -1,8 +1,40 @@ -$version = $( git describe --tags --abbrev = 0 ) -$splitter = $version.split(".") -$build = [int]($splitter[2]) + 1 -$newVersion = $splitter[0] + "." + $splitter[1] + "." + $build - -write-host $newVersion -git tag -a $newVersion -m "new release" -git push origin $newVersion +param( + [Parameter(Mandatory = $false)] + [ValidateNotNullOrEmpty()] + [string]$message = "new release" +) + +$versionPattern = '^\d+\.\d+\.\d+$' +$version = $null + +try +{ + $version = $( git describe --tags --abbrev=0 ) -replace "v" + if ($version -notmatch $versionPattern) + { + Write-Error "Invalid version format $version. Expected: x.y.z" + exit 1 + } + + $splitter = $version.split(".") + $build = [int]($splitter[2]) + 1 + [string]$newVersion = $splitter[0] + "." + $splitter[1] + "." + $build.ToString() + + if ([version]$newVersion -le [version]$version) + { + Write-Error "New version must be greater than current version" + exit 1 + } + + Write-Host "Current version: $version" + Write-Host "New version: $newVersion" + Write-Host "Creating new tag..." + + git tag -a v$newVersion -m "$message" + git push origin v$newVersion +} +catch +{ + Write-Error "An error occurred: $_" + exit 1 +} diff --git a/bump.sh b/bump.sh new file mode 100644 index 0000000..38aa268 --- /dev/null +++ b/bump.sh @@ -0,0 +1,35 @@ +#!/bin/bash + +# Parameters +message="${1:-new release}" + +# Version pattern +versionPattern='^[0-9]+\.[0-9]+\.[0-9]+$' +version='' + +# Get the current version +version=$(git describe --tags --abbrev=0 2>/dev/null) +version=${version//v} +if [[ ! $version =~ $versionPattern ]]; then + echo "Invalid version format. Expected: x.y.z" + exit 1 +fi + +# Split the version and increment the build number +IFS='.' read -r major minor build <<< "$version" +newBuild=$((build + 1)) +newVersion="$major.$minor.$newBuild" + +if [[ ! "$newVersion" > "$version" ]]; then + echo "New version must be greater than current version" + exit 1 +fi + +# Output the current and new version +echo "Current version: $version" +echo "New version: $newVersion" +echo "Creating new tag..." + +# Create a new tag and push it +git tag -a "v$newVersion" -m "$message" +git push origin "v$newVersion" diff --git a/go.mod b/go.mod index 081147d..91c4e49 100644 --- a/go.mod +++ b/go.mod @@ -1,18 +1,49 @@ -module ghat +module github.com/jameswoolfenden/ghat -go 1.20 +go 1.24.1 require ( - github.com/rs/zerolog v1.29.1 - github.com/sergi/go-diff v1.3.1 - github.com/urfave/cli/v2 v2.25.6 + github.com/go-git/go-git/v5 v5.16.0 + github.com/hashicorp/hcl/v2 v2.23.0 + github.com/rs/zerolog v1.34.0 + github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 + github.com/urfave/cli/v2 v2.27.6 + github.com/zclconf/go-cty v1.16.2 + golang.org/x/mod v0.24.0 + gopkg.in/yaml.v3 v3.0.1 + moul.io/banner v1.0.1 ) require ( - github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.19 // indirect + dario.cat/mergo v1.0.1 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/ProtonMail/go-crypto v1.2.0 // indirect + github.com/agext/levenshtein v1.2.3 // indirect + github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect + github.com/cloudflare/circl v1.6.1 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect + github.com/cyphar/filepath-securejoin v0.4.1 // indirect + github.com/emirpasic/gods v1.18.1 // indirect + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-git/go-billy/v5 v5.6.2 // indirect + github.com/go-test/deep v1.1.1 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/kevinburke/ssh_config v1.2.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/go-wordwrap v1.0.1 // indirect + github.com/pjbgf/sha1cd v0.3.2 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect - github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect - golang.org/x/sys v0.9.0 // indirect + github.com/skeema/knownhosts v1.3.1 // indirect + github.com/xanzy/ssh-agent v0.3.3 // indirect + github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect + golang.org/x/crypto v0.37.0 // indirect + golang.org/x/net v0.39.0 // indirect + golang.org/x/sync v0.13.0 // indirect + golang.org/x/sys v0.32.0 // indirect + golang.org/x/text v0.24.0 // indirect + golang.org/x/tools v0.32.0 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect ) diff --git a/go.sum b/go.sum index b047071..4ecfce6 100644 --- a/go.sum +++ b/go.sum @@ -1,45 +1,147 @@ +dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= +dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProtonMail/go-crypto v1.2.0 h1:+PhXXn4SPGd+qk76TlEePBfOfivE0zkWFenhGhFLzWs= +github.com/ProtonMail/go-crypto v1.2.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= +github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= +github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= +github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= +github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= -github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= -github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= +github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= +github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= 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/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= +github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= +github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= +github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= +github.com/go-git/go-git/v5 v5.16.0 h1:k3kuOEpkc0DeY7xlL6NaaNg39xdgQbtH5mwCafHO9AQ= +github.com/go-git/go-git/v5 v5.16.0/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +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/hashicorp/hcl/v2 v2.23.0 h1:Fphj1/gCylPxHutVSEOf2fBOh1VE4AuLV7+kbJf3qos= +github.com/hashicorp/hcl/v2 v2.23.0/go.mod h1:62ZYHrXgPoX8xBnzl8QzbWq4dyDsDtfCRgIq1rbJEvA= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= +github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= 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/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +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.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +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/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= +github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= +github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= +github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= -github.com/rs/zerolog v1.29.1 h1:cO+d60CHkknCbvzEWxP0S9K6KqyTjrCNUy1LdQLCGPc= -github.com/rs/zerolog v1.29.1/go.mod h1:Le6ESbR7hc+DP6Lt1THiV8CQSdkkNrd3R0XbEgp3ZBU= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= -github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= +github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/urfave/cli/v2 v2.25.6 h1:yuSkgDSZfH3L1CjF2/5fNNg2KbM47pY2EvjBq4ESQnU= -github.com/urfave/cli/v2 v2.25.6/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ= -github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= -github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= -golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/urfave/cli/v2 v2.27.6 h1:VdRdS98FNhKZ8/Az8B7MTyGQmpIr36O1EHybx/LaZ4g= +github.com/urfave/cli/v2 v2.27.6/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= +github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= +github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= +github.com/zclconf/go-cty v1.16.2 h1:LAJSwc3v81IRBZyUVQDUdZ7hs3SYs9jv0eZJDWHD/70= +github.com/zclconf/go-cty v1.16.2/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= +github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= +github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= +golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= +golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s= -golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= +golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU= +golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +moul.io/banner v1.0.1 h1:+WsemGLhj2pOajw2eR5VYjLhOIqs0XhIRYchzTyMLk0= +moul.io/banner v1.0.1/go.mod h1:XwvIGKkhKRKyN1vIdmR5oaKQLIkMhkMqrsHpS94QzAU= diff --git a/main.go b/main.go index 0ee5bb3..aff7bf1 100644 --- a/main.go +++ b/main.go @@ -2,30 +2,29 @@ package main import ( "fmt" - "ghat/src/core" - "ghat/src/version" "os" "sort" "time" + "github.com/jameswoolfenden/ghat/src/core" + "github.com/jameswoolfenden/ghat/src/version" "github.com/rs/zerolog" "github.com/rs/zerolog/log" "github.com/urfave/cli/v2" + "moul.io/banner" ) func main() { - log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}) - - var file string + fmt.Println(banner.Inline("ghat")) + fmt.Println("version:", version.Version) - var directory string - - var gitHubToken string + log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}) - var days int + var myFlags core.Flags app := &cli.App{ EnableBashCompletion: true, + Copyright: "James Woolfenden", Flags: []cli.Flag{}, Commands: []*cli.Command{ { @@ -42,30 +41,17 @@ func main() { { Name: "swot", Aliases: []string{"a"}, - Usage: "updates GHA in a directory", + Usage: "updates GHA versions for hashes", UsageText: "ghat swot", Action: func(*cli.Context) error { - - if file != "" { - err := core.UpdateFile(&file, gitHubToken, &days) - if err != nil { - return err - } - } else { - _, err := core.Files(&directory, gitHubToken, &days) - if err != nil { - return err - } - } - - return nil + return myFlags.Action("swot") }, Flags: []cli.Flag{ &cli.StringFlag{ Name: "file", Aliases: []string{"f"}, Usage: "GHA file to parse", - Destination: &file, + Destination: &myFlags.File, Category: "files", }, &cli.StringFlag{ @@ -73,15 +59,15 @@ func main() { Aliases: []string{"d"}, Usage: "Destination to update GHAs", Value: ".", - Destination: &directory, + Destination: &myFlags.Directory, Category: "files", }, - &cli.IntFlag{ + &cli.UintFlag{ Name: "stable", Aliases: []string{"s"}, Usage: "days to wait for stabilisation of release", Value: 0, - Destination: &days, + Destination: &myFlags.Days, DefaultText: "0", Category: "delay", }, @@ -89,7 +75,97 @@ func main() { Name: "token", Aliases: []string{"t"}, Usage: "Github PAT token", - Destination: &gitHubToken, + Destination: &myFlags.GitHubToken, + Category: "authentication", + EnvVars: []string{"GITHUB_TOKEN", "GITHUB_API"}, + }, + &cli.BoolFlag{ + Name: "dry-run", + Usage: "show but don't write changes", + Destination: &myFlags.DryRun, + Value: false, + }, + &cli.BoolFlag{ + Name: "continue-on-error", + Usage: "just keep going", + Destination: &myFlags.ContinueOnError, + Value: false, + }, + }, + }, + { + Name: "swipe", + Aliases: []string{"w"}, + Usage: "updates Terraform module versions with versioned hashes", + UsageText: "ghat swipe", + Action: func(*cli.Context) error { + return myFlags.Action("swipe") + }, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "file", + Aliases: []string{"f"}, + Usage: "module file to parse", + Destination: &myFlags.File, + Category: "files", + }, + &cli.StringFlag{ + Name: "directory", + Aliases: []string{"d"}, + Usage: "Destination to update modules", + Value: ".", + Destination: &myFlags.Directory, + Category: "files", + }, + &cli.BoolFlag{ + Name: "update", + Usage: "update to latest module available", + Destination: &myFlags.Update, + Value: false, + }, + &cli.BoolFlag{ + Name: "dry-run", + Usage: "show but don't write changes", + Destination: &myFlags.DryRun, + Value: false, + }, + &cli.StringFlag{ + Name: "token", + Aliases: []string{"t"}, + Usage: "Github PAT token", + Destination: &myFlags.GitHubToken, + Category: "authentication", + EnvVars: []string{"GITHUB_TOKEN", "GITHUB_API"}, + }, + }, + }, + { + Name: "sift", + Aliases: []string{"p"}, + Usage: "updates pre-commit version with hashes", + UsageText: "ghat sift", + Action: func(*cli.Context) error { + return myFlags.Action("sift") + }, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "directory", + Aliases: []string{"d"}, + Usage: "Destination to update modules", + Destination: &myFlags.Directory, + Category: "files", + }, + &cli.BoolFlag{ + Name: "dry-run", + Usage: "show but don't write changes", + Destination: &myFlags.DryRun, + Value: false, + }, + &cli.StringFlag{ + Name: "token", + Aliases: []string{"t"}, + Usage: "Github PAT token", + Destination: &myFlags.GitHubToken, Category: "authentication", EnvVars: []string{"GITHUB_TOKEN", "GITHUB_API"}, }, diff --git a/src/core/action.go b/src/core/action.go new file mode 100644 index 0000000..72c9f11 --- /dev/null +++ b/src/core/action.go @@ -0,0 +1,78 @@ +package core + +import ( + "os" + "path/filepath" +) + +const ( + ActionSwipe = "swipe" + ActionSwot = "swot" + ActionSift = "sift" +) + +func (myFlags *Flags) Action(action string) error { + var err error + + if action == "" { + return &actionIsEmptyError{} + } + + if myFlags.File != "" { + if _, err := os.Stat(myFlags.File); err != nil { + pwd, err := os.Getwd() + if err != nil { + return &workingDirectoryError{pwd} + } + myFlags.File = filepath.Join(pwd, myFlags.File) + } + + myFlags.Entries = append(myFlags.Entries, myFlags.File) + } else { + myFlags.Entries, err = GetFiles(myFlags.Directory) + + if err != nil { + return &directoryReadError{myFlags.Directory} + } + } + + err = executeAction(action, myFlags) + if err != nil { + return &executeActionError{action} + } + + return nil +} + +func executeAction(action string, myFlags *Flags) error { + if myFlags == nil { + return &actionIsEmptyError{} + } + + if myFlags.File == "" && myFlags.Directory == "" { + return &dirAndFileEmptyError{} + } + + switch action { + case ActionSwipe: + if myFlags.File != "" { + return myFlags.UpdateModule(myFlags.File) + } else { + return myFlags.UpdateModules() + } + case ActionSwot: + { + if myFlags.File != "" { + return myFlags.UpdateGHA(myFlags.File) + } else { + return myFlags.UpdateGHAS() + } + } + case ActionSift: + { + return myFlags.UpdateHooks() + } + } + + return nil +} diff --git a/src/core/action_test.go b/src/core/action_test.go new file mode 100644 index 0000000..1125064 --- /dev/null +++ b/src/core/action_test.go @@ -0,0 +1,152 @@ +package core + +import ( + "os" + "testing" +) + +func TestFlags_Action(t *testing.T) { + t.Parallel() + type fields struct { + File string + Directory string + GitHubToken string + Days uint + DryRun bool + Entries []string + Update bool + } + + type args struct { + Action string + } + + dir := fields{"", "testdata/files/", gitHubToken, 0, false, nil, true} + bogus := fields{"", "testdata/bogus/", gitHubToken, 0, false, nil, true} + empty := fields{"", "testdata/empty", gitHubToken, 0, false, nil, true} + dirDry := fields{"", "testdata/files/", gitHubToken, 0, true, nil, true} + fileGHA := fields{"testdata/files/ci.yml", "testdata/files/", gitHubToken, 0, true, nil, true} + file := fields{"testdata/files/module.tf", "testdata/files/", gitHubToken, 0, true, nil, true} + noFile := fields{"testdata/files/guff.tf", "testdata/files/", gitHubToken, 0, true, nil, true} + + _ = os.Remove("testdata/empty") + + tests := []struct { + name string + fields fields + args args + wantErr bool + }{ + {"Pass", dir, args{}, true}, + {"Bogus", bogus, args{}, true}, + {"Empty swot", empty, args{"swot"}, true}, + {"Empty swipe", empty, args{"swipe"}, true}, + {"dirDry", dirDry, args{}, true}, + {"file swipe", file, args{"swipe"}, false}, + {"file swot", fileGHA, args{"swot"}, false}, + {"file swot empty", dirDry, args{"swot"}, false}, + {"file swipe empty", dirDry, args{"swipe"}, false}, + {"no file", noFile, args{"swipe"}, true}, + {"sift", fields{Directory: "../../"}, args{"sift"}, false}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + myFlags := &Flags{ + File: tt.fields.File, + Directory: tt.fields.Directory, + GitHubToken: tt.fields.GitHubToken, + Days: tt.fields.Days, + DryRun: tt.fields.DryRun, + Entries: tt.fields.Entries, + Update: tt.fields.Update, + } + if err := myFlags.Action(tt.args.Action); (err != nil) != tt.wantErr { + t.Errorf("Action() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestExecuteAction(t *testing.T) { + t.Parallel() + type args struct { + action string + myFlags *Flags + } + + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "Unknown action type", + args: args{ + action: "unknown", + myFlags: &Flags{ + File: "", + Directory: "testdata/files/", + GitHubToken: gitHubToken, + }, + }, + wantErr: false, + }, + { + name: "Swipe with nil flags", + args: args{ + action: ActionSwipe, + myFlags: nil, + }, + wantErr: true, + }, + { + name: "Swot with empty file and directory", + args: args{ + action: ActionSwot, + myFlags: &Flags{ + File: "", + Directory: "", + GitHubToken: "", + }, + }, + wantErr: true, + }, + { + name: "Sift with missing GitHub token", + args: args{ + action: ActionSift, + myFlags: &Flags{ + File: "", + Directory: "testdata/files/", + GitHubToken: "", + }, + }, + wantErr: true, + }, + { + name: "Swipe with invalid file path format", + args: args{ + action: ActionSwipe, + myFlags: &Flags{ + File: "///", + Directory: "testdata/files/", + GitHubToken: gitHubToken, + }, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if err := executeAction(tt.args.action, tt.args.myFlags); (err != nil) != tt.wantErr { + t.Errorf("executeAction() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/src/core/error.go b/src/core/error.go new file mode 100644 index 0000000..7f8f510 --- /dev/null +++ b/src/core/error.go @@ -0,0 +1,196 @@ +package core + +import "fmt" + +type actionIsEmptyError struct { +} + +func (m *actionIsEmptyError) Error() string { + return "action is empty" +} + +type directoryReadError struct { + directory string +} + +func (m *directoryReadError) Error() string { + return fmt.Sprintf("action failed to read %s", m.directory) +} + +type workingDirectoryError struct { + directory string +} + +func (m *workingDirectoryError) Error() string { + return fmt.Sprintf("failed to get working directory: %s", m.directory) +} + +type executeActionError struct { + action string +} + +func (m *executeActionError) Error() string { + return fmt.Sprintf("failed to execute action: %s", m.action) +} + +type dirAndFileEmptyError struct { +} + +func (m *dirAndFileEmptyError) Error() string { + return "file and directory are empty" +} + +type ghaUpdateError struct { + gha string +} + +func (m *ghaUpdateError) Error() string { + return fmt.Sprintf("GHA update error %s", m.gha) +} + +type ghaFileError struct { + file string +} + +func (m *ghaFileError) Error() string { + return fmt.Sprintf("GHA file error %s", m.file) +} + +type castToMapError struct { + object string +} + +func (m *castToMapError) Error() string { + return fmt.Sprintf("failed to cast %s to map[string]interface{}", m.object) +} + +type writeGHAError struct { + gha string +} + +func (m *writeGHAError) Error() string { + return fmt.Sprintf("failed to write GHA %s", m.gha) +} + +type readConfigError struct { + config *string + err error +} + +func (m *readConfigError) Error() string { + return fmt.Sprintf("failed to read %s: %v", *m.config, m.err) +} + +type marshalJSONError struct { + err error +} + +func (m *marshalJSONError) Error() string { + return fmt.Sprintf("failed to marshal JSON: %v", m.err) +} + +type getHookError struct { + err error +} + +func (m *getHookError) Error() string { + return fmt.Sprintf("failed to get hook: %v", m.err) +} + +type castToStringError struct { + object string +} + +func (m *castToStringError) Error() string { + return fmt.Sprintf("failed to cast %s to string", m.object) +} + +type requestFailedError struct { + err error +} + +func (m *requestFailedError) Error() string { + return fmt.Sprintf("request failed: %v", m.err) +} + +type httpClientError struct { + err error +} + +func (m *httpClientError) Error() string { + return fmt.Sprintf("http client error: %v", m.err) +} + +type emptyURL struct { +} + +func (m *emptyURL) Error() string { + return "URL is empty" +} + +type registryModuleError struct { + module string + err error +} + +func (m *registryModuleError) Error() string { + return fmt.Sprintf("failed to get module %s: %v", m.module, m.err) +} + +type httpGetError struct { + err error +} + +func (m *httpGetError) Error() string { + return fmt.Sprintf("http get error: %v", m.err) +} + +type unmarshalJSONError struct { + err error +} + +func (m *unmarshalJSONError) Error() string { + return fmt.Sprintf("failed to unmarshal: %v", m.err) +} + +type moduleEmptyError struct { +} + +func (m *moduleEmptyError) Error() string { + return "module name cannot be empty" +} + +type responseReadError struct { + err error +} + +func (m *responseReadError) Error() string { + return fmt.Sprintf("failed to read response: %v", m.err) +} + +type responseNilError struct { +} + +func (m *responseNilError) Error() string { + return "api response is nil" +} + +type githubTokenIsEmptyError struct{} + +func (e githubTokenIsEmptyError) Error() string { + return "github token is empty" +} + +type timeParsingError struct { + err error +} + +func (e timeParsingError) Error() string { + return fmt.Sprintf("failed to parse time %v", e.err) +} + +type daysParameterError struct{} + +func (e daysParameterError) Error() string { + return "days parameter must be positive" +} diff --git a/src/core/error_test.go b/src/core/error_test.go new file mode 100644 index 0000000..2935cf1 --- /dev/null +++ b/src/core/error_test.go @@ -0,0 +1,359 @@ +package core + +import ( + "errors" + "fmt" + "testing" +) + +func TestActionIsEmptyError(t *testing.T) { + t.Parallel() + err := &actionIsEmptyError{} + expected := "action is empty" + if err.Error() != expected { + t.Errorf("Expected error message '%s', got '%s'", expected, err.Error()) + } +} + +func TestDirectoryReadError(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + directory string + expected string + }{ + {"Empty directory", "", "action failed to read "}, + {"Valid directory", "/test/dir", "action failed to read /test/dir"}, + {"Relative path", "./relative", "action failed to read ./relative"}, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + err := &directoryReadError{directory: tc.directory} + if err.Error() != tc.expected { + t.Errorf("Expected error message '%s', got '%s'", tc.expected, err.Error()) + } + }) + } +} + +func TestWorkingDirectoryError(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + directory string + expected string + }{ + {"Empty directory", "", "failed to get working directory: "}, + {"Valid directory", "/home/user", "failed to get working directory: /home/user"}, + {"Windows path", "C:\\Users\\test", "failed to get working directory: C:\\Users\\test"}, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + err := &workingDirectoryError{directory: tc.directory} + if err.Error() != tc.expected { + t.Errorf("Expected error message '%s', got '%s'", tc.expected, err.Error()) + } + }) + } +} + +func TestExecuteActionError(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + action string + expected string + }{ + {"Empty action", "", "failed to execute action: "}, + {"Simple action", "build", "failed to execute action: build"}, + {"Complex action", "deploy --force --env=prod", "failed to execute action: deploy --force --env=prod"}, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + err := &executeActionError{action: tc.action} + if err.Error() != tc.expected { + t.Errorf("Expected error message '%s', got '%s'", tc.expected, err.Error()) + } + }) + } +} + +func TestDirAndFileEmptyError(t *testing.T) { + t.Parallel() + err := &dirAndFileEmptyError{} + expected := "file and directory are empty" + if err.Error() != expected { + t.Errorf("Expected error message '%s', got '%s'", expected, err.Error()) + } +} + +func TestGHAUpdateError(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + gha string + expected string + }{ + {"Empty GHA", "", "GHA update error "}, + {"Valid GHA", "workflow.yml", "GHA update error workflow.yml"}, + {"Path GHA", ".github/workflows/test.yml", "GHA update error .github/workflows/test.yml"}, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + err := &ghaUpdateError{gha: tc.gha} + if err.Error() != tc.expected { + t.Errorf("Expected error message '%s', got '%s'", tc.expected, err.Error()) + } + }) + } +} + +func TestGHAFileError(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + file string + expected string + }{ + {"Empty file", "", "GHA file error "}, + {"Simple file", "main.yml", "GHA file error main.yml"}, + {"Nested file", "workflows/deploy.yml", "GHA file error workflows/deploy.yml"}, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + err := &ghaFileError{file: tc.file} + if err.Error() != tc.expected { + t.Errorf("Expected error message '%s', got '%s'", tc.expected, err.Error()) + } + }) + } +} + +func TestCastToMapError(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + object string + expected string + }{ + {"Empty object", "", "failed to cast to map[string]interface{}"}, + {"Simple object", "config", "failed to cast config to map[string]interface{}"}, + {"Complex object", "workflow.settings", "failed to cast workflow.settings to map[string]interface{}"}, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + err := &castToMapError{object: tc.object} + if err.Error() != tc.expected { + t.Errorf("Expected error message '%s', got '%s'", tc.expected, err.Error()) + } + }) + } +} + +func TestWriteGHAError(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + gha string + expected string + }{ + {"Empty GHA", "", "failed to write GHA "}, + {"Simple GHA", "ci.yml", "failed to write GHA ci.yml"}, + {"Full path GHA", ".github/workflows/release.yml", "failed to write GHA .github/workflows/release.yml"}, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + err := &writeGHAError{gha: tc.gha} + if err.Error() != tc.expected { + t.Errorf("Expected error message '%s', got '%s'", tc.expected, err.Error()) + } + }) + } +} + +func TestReadConfigError(t *testing.T) { + t.Parallel() + config := "config.yaml" + testErr := fmt.Errorf("test error") + err := &readConfigError{config: &config, err: testErr} + expected := "failed to read config.yaml: test error" + if err.Error() != expected { + t.Errorf("Expected error message '%s', got '%s'", expected, err.Error()) + } +} + +func TestMarshalJSONError(t *testing.T) { + t.Parallel() + testErr := fmt.Errorf("marshal error") + err := &marshalJSONError{err: testErr} + expected := "failed to marshal JSON: marshal error" + if err.Error() != expected { + t.Errorf("Expected error message '%s', got '%s'", expected, err.Error()) + } +} + +func TestGetHookError(t *testing.T) { + t.Parallel() + testErr := fmt.Errorf("hook error") + err := &getHookError{err: testErr} + expected := "failed to get hook: hook error" + if err.Error() != expected { + t.Errorf("Expected error message '%s', got '%s'", expected, err.Error()) + } +} + +func TestCastToStringError(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + object string + expected string + }{ + {"Empty object", "", "failed to cast to string"}, + {"Valid object", "testObject", "failed to cast testObject to string"}, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + err := &castToStringError{object: tc.object} + if err.Error() != tc.expected { + t.Errorf("Expected error message '%s', got '%s'", tc.expected, err.Error()) + } + }) + } +} + +func TestRequestFailedError(t *testing.T) { + t.Parallel() + testErr := fmt.Errorf("request error") + err := &requestFailedError{err: testErr} + expected := "request failed: request error" + if err.Error() != expected { + t.Errorf("Expected error message '%s', got '%s'", expected, err.Error()) + } +} + +func TestHTTPClientError(t *testing.T) { + t.Parallel() + testErr := fmt.Errorf("client error") + err := &httpClientError{err: testErr} + expected := "http client error: client error" + if err.Error() != expected { + t.Errorf("Expected error message '%s', got '%s'", expected, err.Error()) + } +} + +func TestEmptyURL(t *testing.T) { + t.Parallel() + err := &emptyURL{} + expected := "URL is empty" + if err.Error() != expected { + t.Errorf("Expected error message '%s', got '%s'", expected, err.Error()) + } +} + +func TestRegistryModuleError(t *testing.T) { + t.Parallel() + testErr := fmt.Errorf("module error") + err := ®istryModuleError{module: "test-module", err: testErr} + expected := "failed to get module test-module: module error" + if err.Error() != expected { + t.Errorf("Expected error message '%s', got '%s'", expected, err.Error()) + } +} + +func TestHTTPGetError(t *testing.T) { + t.Parallel() + testErr := fmt.Errorf("get error") + err := &httpGetError{err: testErr} + expected := "http get error: get error" + if err.Error() != expected { + t.Errorf("Expected error message '%s', got '%s'", expected, err.Error()) + } +} + +func TestUnmarshalJSONError(t *testing.T) { + t.Parallel() + testErr := fmt.Errorf("unmarshal error") + err := &unmarshalJSONError{err: testErr} + expected := "failed to unmarshal: unmarshal error" + if err.Error() != expected { + t.Errorf("Expected error message '%s', got '%s'", expected, err.Error()) + } +} + +func TestModuleEmptyError(t *testing.T) { + t.Parallel() + err := &moduleEmptyError{} + expected := "module name cannot be empty" + if err.Error() != expected { + t.Errorf("Expected error message '%s', got '%s'", expected, err.Error()) + } +} + +func TestResponseReadError(t *testing.T) { + t.Parallel() + testErr := fmt.Errorf("read error") + err := &responseReadError{err: testErr} + expected := "failed to read response: read error" + if err.Error() != expected { + t.Errorf("Expected error message '%s', got '%s'", expected, err.Error()) + } +} + +func TestResponseNilError(t *testing.T) { + t.Parallel() + err := &responseNilError{} + expected := "api response is nil" + if err.Error() != expected { + t.Errorf("Expected error message '%s', got '%s'", expected, err.Error()) + } +} + +func TestTimeParsingError(t *testing.T) { + testErr := errors.New("test error") + err := timeParsingError{err: testErr} + + expected := "failed to parse time test error" + if got := err.Error(); got != expected { + t.Errorf("timeParsingError.Error() = %v, want %v", got, expected) + } +} + +func TestDaysParameterError(t *testing.T) { + err := daysParameterError{} + + expected := "days parameter must be positive" + if got := err.Error(); got != expected { + t.Errorf("daysParameterError.Error() = %v, want %v", got, expected) + } +} + +func TestErrorInterfaces(t *testing.T) { + // Verify types implement error interface + var _ error = timeParsingError{} + var _ error = daysParameterError{} +} diff --git a/src/core/files.go b/src/core/files.go deleted file mode 100644 index 41fb842..0000000 --- a/src/core/files.go +++ /dev/null @@ -1,230 +0,0 @@ -package core - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "os" - "path/filepath" - "regexp" - "strings" - - "github.com/rs/zerolog/log" - "github.com/sergi/go-diff/diffmatchpatch" -) - -// Files updates all actions in a directory. -func Files(directory *string, gitHubToken string, days *int) ([]os.DirEntry, error) { - - matches, err := os.ReadDir(*directory) - - if err != nil { - log.Error().Msgf("failed to read %s", *directory) - } - - var ghat []os.DirEntry - - entries, directory, err2 := GetGHA(directory, matches, ghat) - if err2 != nil { - return entries, err2 - } - - for _, gha := range entries { - file := filepath.Join(*directory, gha.Name()) - err = UpdateFile(&file, gitHubToken, days) - - if err != nil { - log.Warn().Msgf("failed to update %s", gha.Name()) - } - } - - return nil, nil -} - -// GetGHA gets all the actions in a directory -func GetGHA(directory *string, matches []os.DirEntry, ghat []os.DirEntry) ([]os.DirEntry, *string, error) { - for _, match := range matches { - if match.IsDir() { - if strings.Contains(match.Name(), ".github") { - log.Print(match.Name()) - AbsDir, _ := filepath.Abs(*directory) - newDirectory := filepath.Join(AbsDir, match.Name(), "workflows") - if _, err := os.Stat(newDirectory); err == nil { - ghat, err = os.ReadDir(newDirectory) - - if err != nil { - return nil, &newDirectory, fmt.Errorf("no files found %w", err) - } - - return ghat, &newDirectory, nil - } - } - } else { - if strings.Contains(match.Name(), ".yml") || (strings.Contains(match.Name(), ".yaml")) { - ghat = append(ghat, match) - } - } - } - - return ghat, directory, nil -} - -// UpdateFile updates am action with latest dependencies -func UpdateFile(file *string, gitHubToken string, days *int) error { - buffer, err := os.ReadFile(*file) - replacement := string(buffer) - - var newUrl string - - if err != nil { - return fmt.Errorf("failed to open file %w", err) - } - - r := regexp.MustCompile(`uses:(.*)`) - matches := r.FindAllStringSubmatch(string(buffer), -1) - for _, match := range matches { - action := strings.Split(match[1], "@") - - action[0] = strings.TrimSpace(action[0]) - body, err2 := getPayload(action[0], gitHubToken, days) - - if err2 != nil { - splitter := strings.SplitN(action[0], "/", 3) - newUrl = splitter[0] + "/" + splitter[1] - body, err2 = getPayload(newUrl, gitHubToken, days) - if err2 != nil { - log.Warn().Msgf("failed to retrieve back %s", err2) - - continue - } - } - - msg := body.(map[string]interface{}) - - if msg["tag_name"] != nil { - tag := msg["tag_name"].(string) - - url := action[0] - - if newUrl != "" { - url = newUrl - } - - payload, err := getHash(url, tag, gitHubToken) - body := payload.(map[string]interface{}) - - if err != nil { - log.Warn().Msgf("failed to retrieve commit hash %s for %s", err, action[0]) - continue - } - - object, ok := body["object"].(map[string]interface{}) - if !ok { - log.Warn().Msgf("failed to assert map of string %s", err) - continue - } - - sha := object["sha"].(string) - if !ok { - log.Warn().Msgf("failed to assert string %s", err) - continue - } - - oldAction := action[0] + "@" + action[1] - newAction := action[0] + "@" + sha + " # " + tag //GET /repos/{owner}/{repo}/git/ref/tags/{tag_name} - - replacement = strings.ReplaceAll(replacement, oldAction, newAction) - } else { - log.Warn().Msgf("tag field empty skipping %s", action[0]) - } - } - - dmp := diffmatchpatch.New() - diffs := dmp.DiffMain(string(buffer), replacement, false) - - fmt.Println(dmp.DiffPrettyText(diffs)) - - newBuffer := []byte(replacement) - - err = os.WriteFile(*file, newBuffer, 0644) - - if err != nil { - return fmt.Errorf("failed to write err %w", err) - } - - return nil -} - -func getPayload(action string, gitHubToken string, days *int) (interface{}, error) { - if *days == 0 { - url := "https://api.github.com/repos/" + action + "/releases/latest" - return GetBody(gitHubToken, url) - } - - return GetReleases(action, gitHubToken, days) -} - -func getHash(action string, tag string, gitHubToken string) (interface{}, error) { - url := "https://api.github.com/repos/" + action + "/git/ref/tags/" + tag - return GetBody(gitHubToken, url) -} - -// GetBody requests a URL using gitHub PAT for auth -func GetBody(gitHubToken string, url string) (interface{}, error) { - var body []byte - - if gitHubToken != "" { - req, err := http.NewRequest("GET", url, nil) - if err != nil { - return nil, fmt.Errorf("request failed %w", err) - } - - req.Header.Add("Authorization", "Bearer "+gitHubToken) - client := &http.Client{} - resp, err := client.Do(req) - - if resp == nil { - return nil, fmt.Errorf("api failed to respond") - } - - if resp.StatusCode != 200 { - return nil, fmt.Errorf("api failed with %d", resp.StatusCode) - } - - defer func(Body io.ReadCloser) { - _ = Body.Close() - }(resp.Body) - - if err != nil { - return nil, fmt.Errorf("client failed %w", err) - } - - body, err = io.ReadAll(resp.Body) - - if err != nil { - return nil, fmt.Errorf("failed to read body %w", err) - } - - } else { - log.Warn().Msgf("failing back to anonymous auth") - resp, err := http.Get(url) - if err != nil { - return nil, fmt.Errorf("failed to get url %w", err) - } - - body, err = io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read body %w", err) - } - } - - var msg interface{} - - err := json.Unmarshal(body, &msg) - if err != nil { - return nil, fmt.Errorf("failed to unmarshal %w", err) - } - - return msg, nil -} diff --git a/src/core/files_test.go b/src/core/files_test.go deleted file mode 100644 index 38f2845..0000000 --- a/src/core/files_test.go +++ /dev/null @@ -1,209 +0,0 @@ -package core - -import ( - "os" - "reflect" - "testing" -) - -var gitHubToken = os.Getenv("GITHUB_TOKEN") - -func TestFiles(t *testing.T) { - t.Parallel() - - type args struct { - directory *string - days *int - } - - dir := "testdata/files/" - bogus := "testdata/bogus/" - empty := "testdata/empty" - - var zero = 0 - - tests := []struct { - name string - args args - want []os.DirEntry - wantErr bool - }{ - {"Pass", args{&dir, &zero}, nil, false}, - {"Bogus", args{&bogus, &zero}, nil, false}, - {"Empty", args{&empty, &zero}, nil, false}, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - got, err := Files(tt.args.directory, gitHubToken, tt.args.days) - if (err != nil) != tt.wantErr { - t.Errorf("Files() error = %v, wantErr %v", err, tt.wantErr) - return - } - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("Files() got = %v, want %v", got, tt.want) - } - }) - } -} - -func TestGetGHA(t *testing.T) { - t.Parallel() - - type args struct { - directory *string - matches []os.DirEntry - ghat []os.DirEntry - } - - duffDir := "nothere" - nomatches, _ := os.ReadDir(duffDir) - - noworkflowsdir := "./testdata/noworkflows" - noworkflows, _ := os.ReadDir(noworkflowsdir) - - noworkflowswithdir := "./testdata/noworkflowswithdir" - noworkflowswithdircontents, _ := os.ReadDir(noworkflowswithdir) - - tests := []struct { - name string - args args - want []os.DirEntry - want1 *string - wantErr bool - }{ - {"no matches", args{&duffDir, nomatches, nil}, nil, &duffDir, false}, - {"no workflows", args{&noworkflowsdir, noworkflows, nil}, nil, &noworkflowsdir, false}, - {"no workflows with dir", args{&noworkflowswithdir, noworkflowswithdircontents, nil}, nil, &noworkflowswithdir, false}, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - got, got1, err := GetGHA(tt.args.directory, tt.args.matches, tt.args.ghat) - t.Parallel() - if (err != nil) != tt.wantErr { - t.Errorf("GetGHA() error = %v, wantErr %v", err, tt.wantErr) - return - } - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("GetGHA() got = %v, want %v", got, tt.want) - } - if !reflect.DeepEqual(got1, tt.want1) { - t.Errorf("GetGHA() got1 = %v, want %v", got1, tt.want1) - } - }) - } -} - -func TestGetBody(t *testing.T) { - type args struct { - gitHubToken string - url string - } - tests := []struct { - name string - args args - want interface{} - wantErr bool - }{ - // TODO: Add test cases. - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := GetBody(tt.args.gitHubToken, tt.args.url) - if (err != nil) != tt.wantErr { - t.Errorf("GetBody() error = %v, wantErr %v", err, tt.wantErr) - return - } - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("GetBody() got = %v, want %v", got, tt.want) - } - }) - } -} - -func TestUpdateFile(t *testing.T) { - type args struct { - file *string - gitHubToken string - days *int - } - tests := []struct { - name string - args args - wantErr bool - }{ - // TODO: Add test cases. - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if err := UpdateFile(tt.args.file, tt.args.gitHubToken, tt.args.days); (err != nil) != tt.wantErr { - t.Errorf("UpdateFile() error = %v, wantErr %v", err, tt.wantErr) - } - }) - } -} - -func Test_getHash(t *testing.T) { - type args struct { - action string - tag string - gitHubToken string - } - tests := []struct { - name string - args args - want interface{} - wantErr bool - }{ - // TODO: Add test cases. - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := getHash(tt.args.action, tt.args.tag, tt.args.gitHubToken) - if (err != nil) != tt.wantErr { - t.Errorf("getHash() error = %v, wantErr %v", err, tt.wantErr) - return - } - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("getHash() got = %v, want %v", got, tt.want) - } - }) - } -} - -func Test_getPayload(t *testing.T) { - t.Parallel() - - type args struct { - action string - gitHubToken string - days *int - } - - tests := []struct { - name string - args args - want interface{} - wantErr bool - }{ - // TODO: Add test cases. - } - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - got, err := getPayload(tt.args.action, tt.args.gitHubToken, tt.args.days) - if (err != nil) != tt.wantErr { - t.Errorf("getPayload() error = %v, wantErr %v", err, tt.wantErr) - return - } - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("getPayload() got = %v, want %v", got, tt.want) - } - }) - } -} diff --git a/src/core/filter.go b/src/core/filter.go index d93d9dd..a449619 100644 --- a/src/core/filter.go +++ b/src/core/filter.go @@ -5,13 +5,30 @@ import ( "time" ) -func GetReleases(action string, gitHubToken string, days *int) (map[string]interface{}, error) { +const ( + dayInNanos int64 = 24 * 60 * 60 * 1000 * 1000 * 1000 + apiBaseURL = "https://api.github.com/repos/" +) + +func GetReleases(action string, gitHubToken string, days *uint) (map[string]interface{}, error) { + if days == nil { + return nil, &daysParameterError{} + } + + if gitHubToken == "" { + return nil, &githubTokenIsEmptyError{} + } + + if action == "" { + return nil, &actionIsEmptyError{} + } + now := time.Now() - interval := time.Duration(*days * 24 * 60 * 60 * 1000 * 1000 * 1000) + interval := time.Duration(int64(*days) * dayInNanos) limit := now.Add(-interval) - url := "https://api.github.com/repos/" + action + "/releases" - temp, err := GetBody(gitHubToken, url) + url := apiBaseURL + action + "/releases" + temp, err := GetGithubBody(gitHubToken, url) if err != nil { return nil, fmt.Errorf("failed to request list of releases %w", err) @@ -24,14 +41,27 @@ func GetReleases(action string, gitHubToken string, days *int) (map[string]inter } for _, body := range bodies { - release := body.(map[string]interface{}) - temp := release["published_at"].(string) + release, ok := body.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("invalid release format in response") + } + + temp, ok := release["published_at"].(string) + + if !ok { + return nil, &castToStringError{"published_at"} + } + + released, err := time.Parse(time.RFC3339, temp) + + if err != nil { + return nil, &timeParsingError{err: err} + } - released, _ := time.Parse(time.RFC3339, temp) if released.Before(limit) { return release, nil } } - return nil, err + return nil, nil } diff --git a/src/core/filter_test.go b/src/core/filter_test.go index ff7d01b..58afa0a 100644 --- a/src/core/filter_test.go +++ b/src/core/filter_test.go @@ -10,13 +10,51 @@ func TestGetReleases(t *testing.T) { type args struct { action string gitHubToken string - delay *int + delay *uint } - delay := 14 - zero := 0 + var delay uint = 14 + var zero uint = 0 var empty map[string]interface{} - var want map[string]interface{} + want := map[string]interface{}{ + "tarball_url": "https: //api.github.com/repos/JamesWoolfenden/test-data-action/tarball/v0.0.1", + "zipball_url": "https: //api.github.com/repos/JamesWoolfenden/test-data-action/zipball/v0.0.1", + "assets_url": "https: //api.github.com/repos/JamesWoolfenden/test-data-action/releases/109328421/assets", + "id": 109328421, + "draft": false, + "created_at": "2023-06-21T06:59:22Z", + "published_at": "2023-06-21T06:59:51Z", + "assets": []interface{}{}, + "html_url": "https: //github.com/JamesWoolfenden/test-data-action/releases/tag/v0.0.1", + "author": map[string]interface{}{ + "avatar_url": "https://avatars.githubusercontent.com/u/1456880?v=4", + "url": "https://api.github.com/users/JamesWoolfenden", + "type": "User", + "followers_url": "https://api.github.com/users/JamesWoolfenden/followers", + "organizations_url": "https://api.github.com/users/JamesWoolfenden/orgs", + "starred_url": "https://api.github.com/users/JamesWoolfenden/starred{/owner}{/repo}", + "events_url": "https://api.github.com/users/JamesWoolfenden/events{/privacy}", + "login": "JamesWoolfenden", + "id": 1456880, + "node_id": "MDQ6VXNlcjE0NTY4ODA=", + "gravatar_id": "", + "html_url": "https://github.com/JamesWoolfenden", + "following_url": "https://api.github.com/users/JamesWoolfenden/following{/other_user}", + "gists_url": "https://api.github.com/users/JamesWoolfenden/gists{/gist_id}", + "subscriptions_url": "https://api.github.com/users/JamesWoolfenden/subscriptions", + "repos_url": "https://api.github.com/users/JamesWoolfenden/repos", + "received_events_url": "https://api.github.com/users/JamesWoolfenden/received_events", + "site_admin": false, + }, + "node_id": "RE_kwDOJyIXLs4GhDgl", + "tag_name": "v0.0.1", + "name": "Test", + "prerelease": false, + "body": "", + "url": "https://api.github.com/repos/JamesWoolfenden/test-data-action/releases/109328421", + "upload_url": "https: //uploads.github.com/repos/JamesWoolfenden/test-data-action/releases/109328421/assets{?name,label}", + "target_commitish": "main", + } result := map[string]interface{}{ "tarball_url": "https: //api.github.com/repos/JamesWoolfenden/test-data-action/tarball/v0.0.1", @@ -68,6 +106,7 @@ func TestGetReleases(t *testing.T) { {"Has release", args{"jameswoolfenden/test-data-action", gitHubToken, &delay}, want, false}, {"Has released", args{"jameswoolfenden/test-data-action", gitHubToken, &zero}, result, false}, {"Fake", args{"jameswoolfenden/god", gitHubToken, &zero}, nil, true}, + {"no token", args{"actions/checkout", "", &zero}, nil, true}, } for _, tt := range tests { @@ -85,3 +124,70 @@ func TestGetReleases(t *testing.T) { }) } } + +func TestGetReleasesEdgeCases(t *testing.T) { + t.Parallel() + + var days uint = 14 + var zero uint = 0 + + tests := []struct { + name string + action string + gitHubToken string + days *uint + wantErr bool + errMsg string + }{ + { + name: "Empty GitHub token", + action: "JamesWoolfenden/test-data-action", + gitHubToken: "", + days: &days, + wantErr: true, + errMsg: "github token is empty", + }, + { + name: "Empty action name", + action: "", + gitHubToken: "dummy-token", + days: &days, + wantErr: true, + errMsg: "action is empty", + }, + { + name: "Zero days filter", + action: "JamesWoolfenden/test-data-action", + gitHubToken: "dummy-token", + days: &zero, + wantErr: true, + errMsg: "failed to request list of releases api failed with 401", + }, + { + name: "Valid days filter", + action: "JamesWoolfenden/test-data-action", + gitHubToken: "dummy-token", + days: &days, + wantErr: true, + errMsg: "failed to request list of releases api failed with 401", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := GetReleases(tt.action, tt.gitHubToken, tt.days) + if (err != nil) != tt.wantErr { + t.Errorf("GetReleases() error = %v, wantErr %v", err, tt.wantErr) + return + } + if err != nil && err.Error() != tt.errMsg { + t.Errorf("GetReleases() error message = %v, want %v", err.Error(), tt.errMsg) + } + if !tt.wantErr && got == nil { + t.Error("GetReleases() returned nil result when error not expected") + } + }) + } +} diff --git a/src/core/gha.go b/src/core/gha.go new file mode 100644 index 0000000..577e6ea --- /dev/null +++ b/src/core/gha.go @@ -0,0 +1,311 @@ +package core + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/rs/zerolog/log" + "github.com/sergi/go-diff/diffmatchpatch" +) + +const ( + githubWorkflowPath = ".github/workflows" + terraformDir = ".terraform" + yamlExtension = ".yml" + yamlAltExtension = ".yaml" +) + +type readFilesError struct { + err error +} + +func (m *readFilesError) Error() string { + return fmt.Sprintf("failed to read files: %s", m.err) +} + +type absolutePathError struct { + directory string + err error +} + +func (m *absolutePathError) Error() string { + return fmt.Sprintf("failed to get absolute path: %v %s ", m.err, m.directory) +} + +func GetFiles(dir string) ([]string, error) { + Entries, err := os.ReadDir(dir) + if err != nil { + return nil, &readFilesError{err} + } + + var ParsedEntries []string + + for _, entry := range Entries { + AbsDir, err := filepath.Abs(dir) + if err != nil { + return nil, &absolutePathError{dir, err} + } + gitDir := filepath.Join(AbsDir, ".git") + + if entry.IsDir() { + + newDir := filepath.Join(AbsDir, entry.Name()) + + if !(strings.Contains(newDir, terraformDir)) && newDir != gitDir { + newEntries, err := GetFiles(newDir) + + if err != nil { + return nil, err + } + + ParsedEntries = append(ParsedEntries, newEntries...) + } + } else { + myFile := filepath.Join(dir, entry.Name()) + if !(strings.Contains(myFile, terraformDir)) { + ParsedEntries = append(ParsedEntries, myFile) + } + } + } + + return ParsedEntries, nil +} + +func (myFlags *Flags) UpdateGHAS() error { + var err error + myFlags.Entries = myFlags.GetGHA() + + for _, gha := range myFlags.Entries { + err = myFlags.UpdateGHA(gha) + + if err != nil { + return &ghaUpdateError{gha} + } + } + + return nil +} + +// GetGHA gets all the actions in a directory +func (myFlags *Flags) GetGHA() []string { + var ghat []string + + for _, match := range myFlags.Entries { + match, _ = filepath.Abs(match) + entry, _ := os.Stat(match) + if strings.Contains(match, githubWorkflowPath) && !entry.IsDir() { + if strings.Contains(match, yamlExtension) || (strings.Contains(match, yamlAltExtension)) { + ghat = append(ghat, match) + } + } + } + + return ghat +} + +// UpdateGHA updates am action with latest dependencies +func (myFlags *Flags) UpdateGHA(file string) error { + buffer, err := os.ReadFile(file) + if err != nil { + return &ghaFileError{file} + } + + replacement := string(buffer) + + var newUrl string + + r := regexp.MustCompile(`uses:(.*)`) + matches := r.FindAllStringSubmatch(string(buffer), -1) + for _, match := range matches { + + //is path + if strings.Contains(match[1], ".github") { + continue + } + + action := strings.Split(match[1], "@") + + action[0] = strings.TrimSpace(action[0]) + body, err := getPayload(action[0], myFlags.GitHubToken, &myFlags.Days) + + if err != nil { + splitter := strings.SplitN(action[0], "/", 3) + newUrl = splitter[0] + "/" + splitter[1] + body, err = getPayload(newUrl, myFlags.GitHubToken, &myFlags.Days) + if err != nil { + if myFlags.ContinueOnError { + log.Info().Err(err) + continue + } + return fmt.Errorf("failed to retrieve data for action %s with %s", action[0], err) + } + } + + msg, ok := body.(map[string]interface{}) + + if !ok { + return &castToMapError{"body"} + } + + if msg["tag_name"] != nil { + tag := msg["tag_name"].(string) + + url := action[0] + + if newUrl != "" { + url = newUrl + } + + payload, err := getHash(url, tag, myFlags.GitHubToken) + if err != nil { + log.Warn().Msgf("failed to retrieve commit hash %s for %s", err, action[0]) + continue + } + + body, ok := payload.(map[string]interface{}) + if !ok { + log.Warn().Msgf("Payload is not expected map %s", body) + continue + } + + object, ok := body["object"].(map[string]interface{}) + if !ok { + log.Warn().Msgf("failed to assert map of string %s", err) + continue + } + + sha, ok := object["sha"].(string) + if !ok { + log.Warn().Msgf("failed to assert string %s", err) + continue + } + + oldAction := action[0] + "@" + action[1] + newAction := action[0] + "@" + sha + " # " + tag //GET /repos/{owner}/{repo}/git/ref/tags/{tag_name} + + replacement = strings.ReplaceAll(replacement, oldAction, newAction) + } else { + log.Warn().Msgf("tag field empty skipping %s", action[0]) + } + } + + dmp := diffmatchpatch.New() + diffs := dmp.DiffMain(string(buffer), replacement, false) + + fmt.Println(dmp.DiffPrettyText(diffs)) + + if !myFlags.DryRun { + newBuffer := []byte(replacement) + + err = os.WriteFile(file, newBuffer, 0644) + + if err != nil { + return &writeGHAError{file} + } + } + + return nil +} + +func getPayload(action string, gitHubToken string, days *uint) (interface{}, error) { + + if days == nil { + return nil, &daysParameterError{} + } + + if *days == 0 { + return GetLatestRelease(action, gitHubToken) + } + + return GetReleases(action, gitHubToken, days) +} + +func GetLatestRelease(action string, gitHubToken string) (interface{}, error) { + url := "https://api.github.com/repos/" + action + "/releases/latest" + return GetGithubBody(gitHubToken, url) +} + +func GetLatestTag(action string, gitHubToken string) (interface{}, error) { + url := "https://api.github.com/repos/" + action + "/tags" + tags, err := GetGithubBody(gitHubToken, url) + tagged, ok := tags.([]interface{}) + + if !ok { + return nil, fmt.Errorf("failed to assert slice %s", tags) + } + + return tagged[0].(map[string]interface{}), err +} + +func getHash(action string, tag string, gitHubToken string) (interface{}, error) { + url := "https://api.github.com/repos/" + action + "/git/ref/tags/" + tag + return GetGithubBody(gitHubToken, url) +} + +// GetGithubBody requests a URL using gitHub PAT for auth +func GetGithubBody(gitHubToken string, url string) (interface{}, error) { + var body []byte + + if gitHubToken != "" { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("request failed %w", err) + } + + req.Header.Add("Authorization", "Bearer "+gitHubToken) + client := &http.Client{ + Timeout: time.Second * 30} + + resp, err := client.Do(req) + + if resp == nil { + return nil, fmt.Errorf("api failed to respond") + } + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("api failed with %d", resp.StatusCode) + } + + defer func(Body io.ReadCloser) { + _ = Body.Close() + }(resp.Body) + + if err != nil { + return nil, fmt.Errorf("client failed %w", err) + } + + body, err = io.ReadAll(resp.Body) + + if err != nil { + return nil, fmt.Errorf("failed to read body %w", err) + } + + } else { + log.Warn().Msgf("failing back to anonymous auth") + resp, err := http.Get(url) + if err != nil { + return nil, fmt.Errorf("failed to get url %w", err) + } + + body, err = io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read body %w", err) + } + } + + var msg interface{} + + err := json.Unmarshal(body, &msg) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal %w", err) + } + + return msg, nil +} diff --git a/src/core/gha_test.go b/src/core/gha_test.go new file mode 100644 index 0000000..949bb29 --- /dev/null +++ b/src/core/gha_test.go @@ -0,0 +1,571 @@ +package core + +import ( + "fmt" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/rs/zerolog/log" +) + +var gitHubToken = os.Getenv("GITHUB_TOKEN") + +func TestGetBody(t *testing.T) { + t.Parallel() + + garbage := "guff-inhere" + failUrl := "https://api.github.com/users/JamesWoolfenden2/orgs" + url := "https://api.github.com/users/JamesWoolfenden/orgs" + + result := map[string]interface{}{ + "login": "teamvulkan", + "id": 46164047, + "node_id": "MDEyOk9yZ2FuaXphdGlvbjQ2MTY0MDQ3", + "url": "https://api.github.com/orgs/teamvulkan", + "repos_url": "https://api.github.com/orgs/teamvulkan/repos", + "events_url": "https://api.github.com/orgs/teamvulkan/events", + "hooks_url": "https://api.github.com/orgs/teamvulkan/hooks", + "issues_url": "https://api.github.com/orgs/teamvulkan/issues", + "members_url": "https://api.github.com/orgs/teamvulkan/members{/member}", + "public_members_url": "https://api.github.com/orgs/teamvulkan/public_members{/member}", + "avatar_url": "https://avatars.githubusercontent.com/u/46164047?v=4", + "description": "", + } + + type args struct { + gitHubToken string + url string + } + + tests := []struct { + name string + args args + want interface{} + wantErr bool + }{ + {"Pass", args{gitHubToken: gitHubToken, url: url}, result, false}, + {"Pass no token", args{url: url}, result, false}, + {"Fail 404", args{gitHubToken: gitHubToken, url: failUrl}, nil, true}, + {"Garbage", args{gitHubToken: gitHubToken, url: garbage}, nil, true}, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := GetGithubBody(tt.args.gitHubToken, tt.args.url) + if (err != nil) != tt.wantErr { + t.Errorf("GetGithubBody() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.want != nil { + _, ok := got.([]interface{}) + if !ok { + log.Info().Msgf("assertion error %s", err) + return + } + + gotMap := got.([]interface{})[0].(map[string]interface{}) + wanted := tt.want.(map[string]interface{}) + + if !reflect.DeepEqual(gotMap["node_id"], wanted["node_id"]) { + t.Errorf("GetGithubBody() got = %v, want %v", got, tt.want) + } + return + } + if got != nil { + t.Errorf("GetGithubBody() nillness got = %v, want %v", got, tt.want) + } + + }) + } +} + +func Test_getHash(t *testing.T) { + t.Parallel() + + type args struct { + action string + tag string + gitHubToken string + } + + want := map[string]interface{}{ + "node_id": "MDM6UmVmMTk3ODE0NjI5OnJlZnMvdGFncy92NC4wLjA=", + "object": map[string]interface{}{ + "sha": "1e31de5234b9f8995739874a8ce0492dc87873e2", + "type": "commit", + "url": "https://api.github.com/repos/actions/checkout/git/commits/1e31de5234b9f8995739874a8ce0492dc87873e2", + }, + "ref": "refs/tags/v4.0.0", + "url": "https://api.github.com/repos/actions/checkout/git/refs/tags/v4.0.0", + } + + tests := []struct { + name string + args args + want interface{} + wantErr bool + }{ + {"pass", args{"actions/checkout", "v4.0.0", gitHubToken}, want, false}, + {"pass", args{"actions/checkout", "v4.0.999", gitHubToken}, nil, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := getHash(tt.args.action, tt.args.tag, tt.args.gitHubToken) + if (err != nil) != tt.wantErr { + t.Errorf("getHash() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("getHash() got = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_getPayload(t *testing.T) { + t.Parallel() + + type args struct { + action string + gitHubToken string + days *uint + } + + var days uint = 0 + var ninety uint = 90 + + daysMap := map[string]interface{}{ + "html_url": "https://github.com/JamesWoolfenden/action-pike/releases/tag/v0.1.3", + "id": 81460196, + "created_at": "2022-10-29T11:25:25Z", + "url": "https://api.github.com/repos/JamesWoolfenden/action-pike/releases/81460196", + "node_id": "RE_kwDOIVF07c4E2vvk", + "prerelease": "false", + "tarball_url": "https://api.github.com/repos/JamesWoolfenden/action-pike/tarball/v0.1.3", + "target_commitish": "master", + "name": "Initial Release", + "zipball_url": "https://api.github.com/repos/JamesWoolfenden/action-pike/zipball/v0.1.3", + "assets_url": "https://api.github.com/repos/JamesWoolfenden/action-pike/releases/81460196/assets", + "upload_url": "https://uploads.github.com/repos/JamesWoolfenden/action-pike/releases/81460196/assets{?name,label}", + "tag_name": "v0.1.3", + "draft": "false", + "published_at": "2022-10-29T15:17:57Z", + } + + tests := []struct { + name string + args args + want interface{} + wantErr bool + }{ + {"pass", args{"JamesWoolfenden/action-pike", gitHubToken, &days}, daysMap, false}, + {"pass", args{"JamesWoolfenden/action-pike", gitHubToken, &ninety}, daysMap, false}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := getPayload(tt.args.action, tt.args.gitHubToken, tt.args.days) + if (err != nil) != tt.wantErr { + t.Errorf("getPayload() error = %v, wantErr %v", err, tt.wantErr) + return + } + + gotMap := got.(map[string]interface{}) + wantMap := tt.want.(map[string]interface{}) + + if !reflect.DeepEqual(gotMap["created_at"], wantMap["created_at"]) { + t.Errorf("getPayload() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestFlags_GetGHA(t *testing.T) { + type fields struct { + File string + Directory string + GitHubToken string + Days uint + DryRun bool + } + + type args struct { + matches []os.DirEntry + ghat []os.DirEntry + } + + duffDir := fields{"", "nothere", gitHubToken, 0, false} + noMatches, _ := os.ReadDir(duffDir.Directory) + + noWorkflowsDir := fields{"", "./testdata/noworkflows", gitHubToken, 0, false} + noWorkflows, _ := os.ReadDir(noWorkflowsDir.Directory) + + noWorkflowsWithDir := fields{"", "./testdata/noworkflowswithdir", gitHubToken, 0, false} + noWorkflowsWithDirContents, _ := os.ReadDir(noWorkflowsWithDir.Directory) + + var nothing []string + tests := []struct { + name string + fields fields + args args + want []string + }{ + {"no matches", duffDir, args{noMatches, nil}, nothing}, + {"no workflows", noWorkflowsDir, args{noWorkflows, nil}, nil}, + {"no workflows with dir", noWorkflowsWithDir, args{noWorkflowsWithDirContents, nil}, nil}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + myFlags := &Flags{ + File: tt.fields.File, + Directory: tt.fields.Directory, + GitHubToken: tt.fields.GitHubToken, + Days: tt.fields.Days, + DryRun: tt.fields.DryRun, + } + got := myFlags.GetGHA() + + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("GetGHA() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestGetLatestTag(t *testing.T) { + t.Parallel() + type args struct { + action string + gitHubToken string + } + + latest := "34bf44973c4f415bd3e791728b630e5d110a2244" + + tests := []struct { + name string + args args + want string + wantErr bool + }{ + {"Pass", args{"jameswoolfenden/terraform-azurerm-diskencryptionset", gitHubToken}, latest, false}, + {"Fail", args{"jameswoolfenden/terraform-azurerm-guff", gitHubToken}, "", true}, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := GetLatestTag(tt.args.action, tt.args.gitHubToken) + if (err != nil) != tt.wantErr { + t.Errorf("GetLatestTag() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if got == nil && tt.want != "" { + t.Errorf("GetLatestTag() got = nil, want %v", tt.want) + return + } + + if (got == nil) == (tt.want == "") { + return + } + + returned := got.(map[string]interface{}) + commit := returned["commit"].(map[string]interface{}) + hash := commit["sha"].(string) + if hash != tt.want { + t.Errorf("GetLatestTag() got = %v, want %v", hash, tt.want) + } + }) + } +} + +func TestFlags_UpdateGHAS(t *testing.T) { + t.Parallel() + + type fields struct { + File string + Directory string + GitHubToken string + Days uint + DryRun bool + Entries []string + Update bool + } + + tests := []struct { + name string + fields fields + wantErr bool + }{ + {"Pass file", + fields{"./testdata/gha/.github/workflows/test.yml", "", gitHubToken, 0, true, []string{"./testdata/gha/.github/workflows/test.yml"}, true}, false}, + {"Pass file not dry", + fields{"./testdata/gha/.github/workflows/test.yml", "", gitHubToken, 0, false, []string{"./testdata/gha/.github/workflows/test.yml"}, true}, false}, + {"Pass dir", + fields{"", "./testdata/gha/.github/workflows", gitHubToken, 0, true, []string{"./testdata/gha/.github/workflows/test.yml"}, true}, false}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + myFlags := &Flags{ + File: tt.fields.File, + Directory: tt.fields.Directory, + GitHubToken: tt.fields.GitHubToken, + Days: tt.fields.Days, + DryRun: tt.fields.DryRun, + Entries: tt.fields.Entries, + Update: tt.fields.Update, + } + if err := myFlags.UpdateGHAS(); (err != nil) != tt.wantErr { + t.Errorf("UpdateGHAS() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestFlags_UpdateGHA(t *testing.T) { + t.Parallel() + type fields struct { + File string + Directory string + GitHubToken string + Days uint + DryRun bool + Entries []string + Update bool + ContinueOnError bool + } + + type args struct { + file string + } + + tests := []struct { + name string + fields fields + args args + wantErr bool + }{ + {name: "Pass file", + fields: fields{File: "./testdata/gha/.github/workflows/test.yml", GitHubToken: gitHubToken, DryRun: true, Entries: []string{"./testdata/gha/.github/workflows/test.yml"}, Update: true}, + args: args{"./testdata/gha/.github/workflows/test.yml"}}, + {name: "No such file", + fields: fields{File: "./testdata/gha/.github/workflows/guff.yml", GitHubToken: gitHubToken, DryRun: true, Entries: []string{"./testdata/gha/.github/workflows/test.yml"}, Update: true}, + args: args{"./testdata/gha/.github/workflows/guff.yml"}, + wantErr: true}, + {name: "Faulty GHA", + fields: fields{File: "./testdata/faulty/.github/workflows/test.yml", GitHubToken: gitHubToken, DryRun: true, Entries: []string{"./testdata/faulty/.github/workflows/test.yml"}, Update: true}, + args: args{file: "./testdata/faulty/.github/workflows/test.yml"}, + wantErr: true}, + {name: "Faulty GHA continue", + fields: fields{File: "./testdata/faulty/.github/workflows/test.yml", GitHubToken: gitHubToken, DryRun: true, Entries: []string{"./testdata/faulty/.github/workflows/test.yml"}, Update: true, ContinueOnError: true}, + args: args{file: "./testdata/faulty/.github/workflows/test.yml"}}, + { + name: "Empty entries", + fields: fields{ + Entries: []string{}, + GitHubToken: gitHubToken, + }, + wantErr: true, + }, + { + name: "Invalid file path", + fields: fields{ + Entries: []string{"./testdata/nonexistent/workflow.yml"}, + GitHubToken: gitHubToken, + }, + wantErr: true, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + myFlags := &Flags{ + File: tt.fields.File, + Directory: tt.fields.Directory, + GitHubToken: tt.fields.GitHubToken, + Days: tt.fields.Days, + DryRun: tt.fields.DryRun, + Entries: tt.fields.Entries, + Update: tt.fields.Update, + ContinueOnError: tt.fields.ContinueOnError, + } + if err := myFlags.UpdateGHA(tt.args.file); (err != nil) != tt.wantErr { + t.Errorf("UpdateGHA() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func setupSuite(tb testing.TB) func(tb testing.TB) { + log.Info().Msgf("setup suite %s", tb.Name()) + testPath, _ := filepath.Abs("./testdata/empty") + _ = os.Mkdir(testPath, os.ModePerm) + _ = os.Mkdir("./testdata/.terraform/", os.ModePerm) + _ = os.Mkdir("./testdata/.git/", os.ModePerm) + + return func(tb testing.TB) { + log.Info().Msg("teardown suite") + _ = os.RemoveAll(testPath) + _ = os.RemoveAll("./testdata/.terraform/") + _ = os.RemoveAll("./testdata/.git/") + + } +} + +func TestGetFiles(t *testing.T) { + t.Parallel() + + //teardownSuite := setupSuite(t) + //defer teardownSuite(t) + + tests := []struct { + name string + dir string + want int + wantErr bool + }{ + {"Valid directory", "./testdata/gha", 1, false}, + {"Empty directory", "./testdata/empty", 0, false}, + {"Non-existent directory", "./testdata/nonexistent", 0, true}, + {"Directory with .terraform", "./testdata/.terraform", 0, false}, + {"Directory with .git", "./testdata/.git", 0, false}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + teardownSuite := setupSuite(t) + defer teardownSuite(t) + got, err := GetFiles(tt.dir) + if (err != nil) != tt.wantErr { + t.Errorf("GetFiles() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && len(got) != tt.want { + t.Errorf("GetFiles() got = %v files, want %v", len(got), tt.want) + } + }) + } +} + +func TestReadFilesError(t *testing.T) { + t.Parallel() + + testErr := fmt.Errorf("test error") + err := &readFilesError{err: testErr} + expected := "failed to read files: test error" + + if err.Error() != expected { + t.Errorf("readFilesError.Error() = %v, want %v", err.Error(), expected) + } +} + +func TestAbsolutePathError(t *testing.T) { + t.Parallel() + + testErr := fmt.Errorf("test error") + testDir := "/test/dir" + err := &absolutePathError{directory: testDir, err: testErr} + expected := "failed to get absolute path: test error /test/dir " + + if err.Error() != expected { + t.Errorf("absolutePathError.Error() = %v, want %v", err.Error(), expected) + } +} + +func TestGetGithubBody_EdgeCases(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + gitHubToken string + url string + wantErr bool + }{ + { + name: "Invalid URL format", + gitHubToken: gitHubToken, + url: "not-a-url", + wantErr: true, + }, + { + name: "Empty URL", + gitHubToken: gitHubToken, + url: "", + wantErr: true, + }, + { + name: "Invalid JSON response", + gitHubToken: gitHubToken, + url: "https://api.github.com/invalid-endpoint", + wantErr: true, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + _, err := GetGithubBody(tt.gitHubToken, tt.url) + if (err != nil) != tt.wantErr { + t.Errorf("GetGithubBody() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestGetPayload_ErrorCases(t *testing.T) { + t.Parallel() + + var days uint = 30 + tests := []struct { + name string + action string + gitHubToken string + days *uint + wantErr bool + }{ + { + name: "Empty action", + action: "", + gitHubToken: gitHubToken, + days: &days, + wantErr: true, + }, + { + name: "Invalid action format", + action: "invalid-format", + gitHubToken: gitHubToken, + days: &days, + wantErr: true, + }, + { + name: "Nil days pointer", + action: "actions/checkout", + gitHubToken: gitHubToken, + days: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + _, err := getPayload(tt.action, tt.gitHubToken, tt.days) + if (err != nil) != tt.wantErr { + t.Errorf("getPayload() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/src/core/modules.go b/src/core/modules.go new file mode 100644 index 0000000..3237ae6 --- /dev/null +++ b/src/core/modules.go @@ -0,0 +1,479 @@ +package core + +import ( + "fmt" + "os" + "path" + "path/filepath" + "strings" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/storage/memory" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclwrite" + "github.com/rs/zerolog/log" + "github.com/sergi/go-diff/diffmatchpatch" + "github.com/zclconf/go-cty/cty" + "golang.org/x/mod/semver" +) + +func (myFlags *Flags) UpdateModule(file string) error { + + var version string + var newValue string + + src, err := os.ReadFile(file) + if err != nil { + return fmt.Errorf("failed to read %s", file) + } + + inFile, _ := hclwrite.ParseConfig(src, "", hcl.Pos{Line: 1, Column: 1}) + outFile := hclwrite.NewEmptyFile() + + newBody := outFile.Body() + root := inFile.Body() + + for _, block := range root.Blocks() { + if block.Type() == "module" { + version = GetVersion(block) + + source := GetStringValue(block, "source") + + block.Body().RemoveAttribute("version") + + myType, err := myFlags.GetType(source) + + if err != nil { + log.Info().Msgf("source type failure %s", source) + } else { + newValue, version, err = myFlags.UpdateSource(source, myType, version) + if err != nil { + log.Info().Msgf("failed to update module source %s", err) + } + block.Body().SetAttributeValue("source", cty.StringVal(newValue)) + } + } + + newBody.AppendBlock(block) + } + + var differ bool + + temp := string(outFile.Bytes()) + + if version != "" { + find := "\"" + newValue + "\"" + replacement := " source = " + find + " #" + version + + lines := strings.Split(temp, "\n") + + for i, line := range lines { + if strings.Contains(line, find) { + lines[i] = replacement + break + } + } + + temp = strings.Join(lines, "\n") + } + + if string(src) != temp { + differ = true + } + + dmp := diffmatchpatch.New() + diffs := dmp.DiffMain(string(src), temp, false) + + if differ { + fmt.Println(dmp.DiffPrettyText(diffs)) + } + + if differ && !myFlags.DryRun { + err := os.WriteFile(file, []byte(temp), 0666) + if err != nil { + log.Info().Msgf("failed to write %s", file) + } + } + + return nil +} + +func GetVersion(block *hclwrite.Block) string { + version := GetStringValue(block, "version") + if version == "" { + return "" + } + + constraints := []string{"=", "!", ">", ">", "~"} + + for _, constraint := range constraints { + if strings.Contains(version, constraint) { + version = "" + log.Info().Msg("constraints not valid, using latest") + continue + } + } + + if !strings.Contains(version, "v") && version != "" { + version = "v" + version + } + + return version +} + +func GetStringValue(block *hclwrite.Block, attribute string) string { + var Value string + version := block.Body().GetAttribute(attribute) + + if (version != nil) && (len(version.Expr().BuildTokens(nil)) == 3) { + Value = string(version.Expr().BuildTokens(nil)[1].Bytes) + } + return Value +} + +func (myFlags *Flags) UpdateModules() error { + + terraform, err := myFlags.GetTF() + + if err != nil { + return err + } + + // contains a module? + for _, file := range terraform { + err = myFlags.UpdateModule(file) + if err != nil { + return err + } + } + + return nil +} + +func (myFlags *Flags) GetTF() ([]string, error) { + var terraform []string + + for _, match := range myFlags.Entries { + //for each file that is a terraform file + if path.Ext(match) == ".tf" { + terraform = append(terraform, match) + } + } + + return terraform, nil +} + +func (myFlags *Flags) GetType(module string) (string, error) { + var moduleType string + + // handle local path + absPath, _ := filepath.Abs(module) + _, err := os.Stat(absPath) + + if err == nil { + return "local", nil + } + + if strings.Contains(module, "bitbucket.org") { + return "bitbucket", nil + } + + if strings.Contains(module, "s3::") { + return "s3", nil + } + + if strings.Contains(module, "gcs::") { + return "gcs", nil + } + + if strings.Contains(module, ".zip") || strings.Contains(module, "archive=") { + return "archive", nil + } + + // gitHub registry format and sub dirs + splitter := strings.Split(module, "/") + + if len(splitter) == 3 && !(strings.Contains(module, "git::") || strings.Contains(module, "https:")) { + if strings.Contains(module, "github.com") { + return "github", nil + } + + return "registry", nil + } + + if strings.Contains(module, "depth=") { + return "shallow", nil + } + + if strings.Contains(module, "git::") { + return "git", nil + } + + if strings.Contains(module, "hg::") { + return "mercurial", nil + } + + if strings.Contains(module, "//") { + temp := strings.Split(module, "//")[0] + return myFlags.GetType(temp) + } + + if _, err := os.Stat(module); os.IsNotExist(err) { + return "local", fmt.Errorf("localpath not found %s", module) + } + + return moduleType, err +} + +func (myFlags *Flags) UpdateSource(module string, moduleType string, version string) (string, string, error) { + + var newModule string + + var hash string + + var err error + + switch moduleType { + case "git": + { + newModule := strings.TrimPrefix(module, "git::") + + splitter := strings.Split(newModule, "?ref=") + + root := splitter[0] + + if len(splitter) > 1 { + version = splitter[1] + } + + if myFlags.Update { + if strings.Contains(newModule, "github.com") { + hash, version, err := myFlags.GetGithubLatestHash(newModule) + if err != nil { + return "", "", err + } + + return "git::" + root + "?ref=" + hash, version, nil + } else { + repo, err := git.Clone(memory.NewStorage(), nil, &git.CloneOptions{ + URL: strings.TrimRight(module, ".git"), + }) + + if err != nil { + return "", "", fmt.Errorf("failed to clone %s", newModule) + } + + ref, err := repo.Head() + if err != nil { + return "", "", err + } + log.Print(ref) + } + + // get latest hash for root + log.Print(root) + } else { + if strings.Contains(newModule, "github.com") { + if version != "" { + hash, err = myFlags.GetGithubHash( + strings.TrimPrefix(newModule, "https://"), + version, + ) + if err != nil { + return "", "", err + } + } else { + hash, version, err = myFlags.GetGithubLatestHash(newModule) + if err != nil { + return "", "", err + } + } + return "git::" + root + "?ref=" + hash, version, nil + } else { + log.Info().Msgf("git != github") + } + } + } + + case "registry": + { + var subDir string + + subDirs := strings.Split(module, "//") + + if len(subDirs) == 2 { + subDir = subDirs[1] + module = subDirs[0] + } + + splits := strings.Split(module, "/") + + if len(splits) != 3 { + return "", "", fmt.Errorf("registry format should split 3 ways") + } + + //e.g. jameswoolfenden/terraform-http-ip + newModule := "github.com" + "/" + splits[0] + "/" + "terraform" + "-" + splits[2] + "-" + splits[1] + ".git" + + if subDir == "" { + return myFlags.UpdateGithubSource(version, newModule) + } else { + return myFlags.WithSubDir(version, newModule, subDir) + } + } + + case "github": + { + subDirs := strings.Split(module, "//") + if len(subDirs) == 2 { + subDir := subDirs[1] + root := subDirs[0] + + // e.g. jameswoolfenden/terraform-http-ip + newModule := root + ".git" + + return myFlags.WithSubDir(version, newModule, subDir) + } + + newModule = module + ".git" + return myFlags.UpdateGithubSource(version, newModule) + } + + case "local", "shallow", "archive", "s3", "gcs", "mercurial": + { + log.Info().Msgf("module source is %s of type %s and cannot be updated", module, moduleType) + return module, version, nil + } + + default: + { + log.Info().Msgf("unknown module type encountered %s", moduleType) + } + } + + return newModule, version, nil +} + +func (myFlags *Flags) WithSubDir(version string, newModule string, subdir string) (string, string, error) { + url, version, err := myFlags.UpdateGithubSource(version, newModule) + + urlsplit := strings.Split(url, ".git") + newUrl := urlsplit[0] + ".git" + "//" + subdir + urlsplit[1] + + return newUrl, version, err +} + +func (myFlags *Flags) UpdateGithubSource(version string, newModule string) (string, string, error) { + var hash string + + var err error + + if myFlags.Update { + hash, version, err = myFlags.GetGithubLatestHash(newModule) + if err != nil { + return "", "", err + } + } else { + if version != "" { + hash, err = myFlags.GetGithubHash(newModule, version) + if err != nil { + return "", "", err + } + } else { + hash, version, err = myFlags.GetGithubLatestHash(newModule) + if err != nil { + return "", "", err + } + } + } + + return "git::https://" + newModule + "?ref=" + hash, version, nil +} + +func (myFlags *Flags) GetGithubLatestHash(newModule string) (string, string, error) { + name := strings.Split(newModule, "github.com/") + + if len(name) < 2 { + return "", "", fmt.Errorf("modules string doesnt contain github.com") + } + + action := strings.Split(name[1], ".git") + if len(action) < 2 { + return "", "", fmt.Errorf("modules string doesnt end in .git") + } + + payload, err := GetLatestTag(action[0], myFlags.GitHubToken) + + if err != nil { + return "", "", err + } + + assertedPayload, ok := payload.(map[string]interface{}) + + if !ok { + return "", "", fmt.Errorf("type assertion failed") + } + + version, ok := assertedPayload["name"].(string) + + if !ok { + return "", "", fmt.Errorf("type assertion failed") + } + + commit := assertedPayload["commit"].(map[string]interface{}) + + hash := commit["sha"].(string) + + return hash, version, nil +} + +func (myFlags *Flags) GetGithubHash(newModule string, tag string) (string, error) { + var err error + + var hash string + + var url string + + var payload interface{} + + name := strings.Split(newModule, "github.com/") + action := strings.Split(name[1], ".git") + + valid := semver.IsValid(tag) + + if valid { + url = "https://api.github.com/repos/" + action[0] + "/git/ref/tags/" + tag + payload, err = GetGithubBody(myFlags.GitHubToken, url) + + if err != nil { + // retry as version is truncated + if strings.Count(tag, ".") == 1 { + tag = tag + ".0" + url = "https://api.github.com/repos/" + action[0] + "/git/ref/tags/" + tag + payload, err = GetGithubBody(myFlags.GitHubToken, url) + if err != nil { + log.Info().Msgf("failed to find tag %s", tag) + return "", err + } + } else { + return "", err + } + } else { + log.Info().Msgf("failed to understand %s", tag) + } + + assertedPayload := payload.(map[string]interface{}) + + object := assertedPayload["object"].(map[string]interface{}) + + hash = object["sha"].(string) + } else { + if len(tag) == 40 || len(tag) == 7 { + hash = tag + } else { + return "", fmt.Errorf("supplied hash is not a short or a long hash") + } + } + + return hash, err +} diff --git a/src/core/modules_test.go b/src/core/modules_test.go new file mode 100644 index 0000000..c63788e --- /dev/null +++ b/src/core/modules_test.go @@ -0,0 +1,436 @@ +package core + +import ( + "fmt" + "reflect" + "testing" +) + +func TestFlags_GetType(t *testing.T) { + t.Parallel() + type fields struct { + File string + Directory string + GitHubToken string + Days uint + DryRun bool + Entries []string + Update bool + } + + type args struct { + module string + } + + //goland:noinspection HttpUrlsUsage + tests := []struct { + name string + fields fields + args args + wantType string + wantErr bool + }{ + {"Local paths", fields{}, args{"./testdata"}, "local", false}, + {"Local paths not found", fields{}, args{"./somewhere"}, "local", true}, + + {"Terraform Registry", fields{}, args{"jameswoolfenden/http/ip"}, "registry", false}, + {"Terraform Registry fail", fields{}, args{"jameswoolfenden/http/ip/duff"}, "local", true}, + {"github", fields{}, args{"github.com/jameswoolfenden/terraform-http-ip"}, "github", false}, + + {"git", fields{}, args{"git::https://github.com/terraform-aws-modules/terraform-aws-memory-db.git"}, "git", false}, + {"git query string", fields{}, args{"git::https://github.com/terraform-aws-modules/terraform-aws-memory-db.git"}, "git", false}, + {"git query string", fields{}, args{"git::ssh://github.com/terraform-aws-modules/terraform-aws-memory-db"}, "git", false}, + + // I dearly wanted to use that name + {"Bitbucket", fields{}, args{"bitbucket.org/hashicorp/terraform-consul-aws"}, "bitbucket", false}, + + {"Shallow", fields{}, args{"git::https://github.com/terraform-aws-modules/terraform-aws-memory-db.git?depth=1"}, "shallow", false}, // + + {"Mercurial repositories", fields{}, args{"hg::http://example.com/vpc.hg"}, "mercurial", false}, + // + {"archive", fields{}, args{"https://example.com/vpc-module.zip"}, "archive", false}, + {"archive", fields{}, args{"https://example.com/vpc-module?archive=zip"}, "archive", false}, + + {"S3 buckets", fields{}, args{"s3::https://s3-eu-west-1.amazonaws.com/examplecorp-terraform-modules/vpc.zip"}, "s3", false}, + {"GCS buckets", fields{}, args{"gcs::https://www.googleapis.com/storage/v1/modules/foomodule.zip"}, "gcs", false}, + + {"Modules in Package Sub-directories", fields{}, args{"hashicorp/consul/aws//modules/consul-cluster"}, "registry", false}, + {"Modules 2", fields{}, args{"git::https://example.com/network.git//modules/vpc"}, "git", false}, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + myFlags := &Flags{ + File: tt.fields.File, + Directory: tt.fields.Directory, + GitHubToken: tt.fields.GitHubToken, + Days: tt.fields.Days, + DryRun: tt.fields.DryRun, + Entries: tt.fields.Entries, + Update: tt.fields.Update, + } + got, err := myFlags.GetType(tt.args.module) + if (err != nil) != tt.wantErr { + t.Errorf("GetType() error = %v, wantErr %v", err, tt.wantErr) + + return + } + + if got != tt.wantType { + t.Errorf("GetType() got = %v, want %v", got, tt.wantType) + } + }) + } +} + +func TestFlags_UpdateSource(t *testing.T) { + type fields struct { + File string + Directory string + GitHubToken string + Days uint + DryRun bool + Entries []string + Update bool + } + type args struct { + module string + moduleType string + version string + } + //goland:noinspection HttpUrlsUsage + tests := []struct { + name string + fields fields + args args + want string + want1 string + wantErr bool + }{ + {"Local paths", fields{}, args{"./testdata", "local", ""}, "./testdata", "", false}, + {"Local paths not found", fields{}, args{"./somewhere", "local", ""}, "./somewhere", "", false}, + + {"github", + fields{"", "", gitHubToken, 0, false, nil, true}, + args{"github.com/hashicorp/terraform-aws-consul", "github", ""}, + "git::https://github.com/hashicorp/terraform-aws-consul.git?ref=e9ceb573687c3d28516c9e3714caca84db64a766", + "v0.11.0", + false}, + {"Terraform Registry fail", + fields{}, + args{"jameswoolfenden/http/ip/duff", "registry", ""}, + "", + "", + true}, + {"git", + fields{"", "", gitHubToken, 0, false, nil, false}, + args{"git::https://github.com/terraform-aws-modules/terraform-aws-memory-db.git", "git", ""}, + "git::https://github.com/terraform-aws-modules/terraform-aws-memory-db.git?ref=2c24bd2b005d804cddaa4a09aa39a5a82d0ee9fb", + "v2.3.0", false}, + {"git update", + fields{"", "", gitHubToken, 0, false, nil, true}, + args{"git::https://github.com/terraform-aws-modules/terraform-aws-memory-db.git", "git", ""}, + "git::https://github.com/terraform-aws-modules/terraform-aws-memory-db.git?ref=2c24bd2b005d804cddaa4a09aa39a5a82d0ee9fb", + "v2.3.0", false}, + {"git version", + fields{"", "", gitHubToken, 0, false, nil, false}, + args{"git::https://github.com/terraform-aws-modules/terraform-aws-memory-db.git?ref=v1.0.0", "git", ""}, + "git::https://github.com/terraform-aws-modules/terraform-aws-memory-db.git?ref=c1a0698ae1ae4ced03399809ef3e0253b07c44a9", + "v1.0.0", false}, + {"git version update", + fields{"", "", gitHubToken, 0, false, nil, true}, + args{"git::https://github.com/terraform-aws-modules/terraform-aws-memory-db.git?ref=v1.0.0", "git", ""}, + "git::https://github.com/terraform-aws-modules/terraform-aws-memory-db.git?ref=2c24bd2b005d804cddaa4a09aa39a5a82d0ee9fb", + "v2.3.0", false}, + {"git version missing", + fields{"", "", gitHubToken, 0, false, nil, false}, + args{"git::https://github.com/terraform-aws-modules/terraform-aws-memory-db.git?ref=v1.2.0", "git", ""}, + "", "", true}, + {"git hash", + fields{"", "", gitHubToken, 0, false, nil, false}, + args{"git::https://github.com/terraform-aws-modules/terraform-aws-memory-db.git?ref=c6d56c1", "git", ""}, + "git::https://github.com/terraform-aws-modules/terraform-aws-memory-db.git?ref=c6d56c1", "c6d56c1", false}, + {name: "git hash update", + fields: fields{"", "", gitHubToken, 0, false, nil, true}, + args: args{"git::https://github.com/terraform-aws-modules/terraform-aws-memory-db.git?ref=93facd14e9e3a66704d84a0236a8a3b813f047be", "git", ""}, + want: "git::https://github.com/terraform-aws-modules/terraform-aws-memory-db.git?ref=2c24bd2b005d804cddaa4a09aa39a5a82d0ee9fb", + want1: "v2.3.0", + wantErr: false}, + + //{"git query string", fields{}, args{"git::https://github.com/terraform-aws-modules/terraform-aws-memory-db.git"}, "git", false}, + //{"git query string", fields{}, args{"git::ssh://github.com/terraform-aws-modules/terraform-aws-memory-db.git"}, "git", false}, + // + // I dearly wanted to use that name + {"Bitbucket", fields{}, args{"bitbucket.org/hashicorp/terraform-consul-aws", "bitbucket", ""}, + "", + "", + false}, + + {"Shallow", fields{}, args{"git::https://github.com/terraform-aws-modules/terraform-aws-memory-db.git?depth=1", "shallow", ""}, + "git::https://github.com/terraform-aws-modules/terraform-aws-memory-db.git?depth=1", + "", + false}, // + + {"Mercurial repositories", fields{}, args{"hg::http://example.com/vpc.hg", "mercurial", ""}, + "hg::http://example.com/vpc.hg", + "", + false}, + + {"archive", fields{}, args{"https://example.com/vpc-module.zip", "archive", ""}, + "https://example.com/vpc-module.zip", + "", + false}, + {"archive", fields{}, args{"https://example.com/vpc-module?archive=zip", "archive", ""}, + "https://example.com/vpc-module?archive=zip", + "", + false}, + + {"S3 buckets", fields{}, args{"s3::https://s3-eu-west-1.amazonaws.com/examplecorp-terraform-modules/vpc.zip", "s3", ""}, + "s3::https://s3-eu-west-1.amazonaws.com/examplecorp-terraform-modules/vpc.zip", + "", + false}, + {"GCS buckets", fields{}, args{"gcs::https://www.googleapis.com/storage/v1/modules/foomodule.zip", "gcs", ""}, + "gcs::https://www.googleapis.com/storage/v1/modules/foomodule.zip", + "", + false}, + {"subdir registry", + fields{"", "", gitHubToken, 0, false, nil, true}, + args{"hashicorp/consul/aws//modules/consul-cluster", "registry", ""}, + "git::https://github.com/hashicorp/terraform-aws-consul.git//modules/consul-cluster?ref=e9ceb573687c3d28516c9e3714caca84db64a766", + "v0.11.0", + false}, + {"subdir github", + fields{"", "", gitHubToken, 0, false, nil, true}, + args{"github.com/hashicorp/terraform-aws-consul//modules/consul-cluster", "github", ""}, + "git::https://github.com/hashicorp/terraform-aws-consul.git//modules/consul-cluster?ref=e9ceb573687c3d28516c9e3714caca84db64a766", + "v0.11.0", + false}, + //{"Modules 2", fields{}, args{"git::https://example.com/network.git//modules/vpc", "git", ""}, + // "git::https://example.com/network.git//modules/vpc", + // "", + // false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + myFlags := &Flags{ + File: tt.fields.File, + Directory: tt.fields.Directory, + GitHubToken: tt.fields.GitHubToken, + Days: tt.fields.Days, + DryRun: tt.fields.DryRun, + Entries: tt.fields.Entries, + Update: tt.fields.Update, + } + got, got1, err := myFlags.UpdateSource(tt.args.module, tt.args.moduleType, tt.args.version) + if (err != nil) != tt.wantErr { + t.Errorf("UpdateSource() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("UpdateSource() got = %v, want %v", got, tt.want) + } + if got1 != tt.want1 { + t.Errorf("UpdateSource() got1 = %v, want %v", got1, tt.want1) + } + }) + } +} + +func TestFlags_UpdateGithubSource(t *testing.T) { + t.Parallel() + type fields struct { + File string + Directory string + GitHubToken string + Days uint + DryRun bool + Entries []string + Update bool + ContinueOnError bool + } + + type args struct { + version string + newModule string + } + + tests := []struct { + name string + fields fields + args args + want string + want1 string + wantErr bool + }{ + {"Pass update", fields{Update: true, GitHubToken: gitHubToken}, args{newModule: "github.com/jameswoolfenden/terraform-http-ip.git"}, + "git::https://github.com/jameswoolfenden/terraform-http-ip.git?ref=2f3cef24e667fb840a3d3481f5a1aaa5a1ac7d28", + "v0.3.14", false}, + {"Not action", fields{Update: true}, args{newModule: "github.com/jameswoolfenden/ip.git"}, "", "", true}, + {"Fail no .git", fields{Update: true}, args{newModule: "jameswoolfenden/ip"}, "", "", true}, + {"Fail too short", fields{Update: true}, args{newModule: "jameswoolfenden/ip"}, "", "", true}, + {"Pass", fields{Update: false, GitHubToken: gitHubToken}, args{newModule: "github.com/jameswoolfenden/terraform-http-ip.git"}, + "git::https://github.com/jameswoolfenden/terraform-http-ip.git?ref=2f3cef24e667fb840a3d3481f5a1aaa5a1ac7d28", + "v0.3.14", false}, + {"Pass with version", + fields{Update: false, GitHubToken: gitHubToken}, args{version: "81a0a7c", newModule: "github.com/jameswoolfenden/terraform-http-ip.git"}, + "git::https://github.com/jameswoolfenden/terraform-http-ip.git?ref=81a0a7c", + "81a0a7c", false}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + myFlags := &Flags{ + File: tt.fields.File, + Directory: tt.fields.Directory, + GitHubToken: tt.fields.GitHubToken, + Days: tt.fields.Days, + DryRun: tt.fields.DryRun, + Entries: tt.fields.Entries, + Update: tt.fields.Update, + ContinueOnError: tt.fields.ContinueOnError, + } + got, got1, err := myFlags.UpdateGithubSource(tt.args.version, tt.args.newModule) + if (err != nil) != tt.wantErr { + t.Errorf("UpdateGithubSource() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("UpdateGithubSource() got = %v, want %v", got, tt.want) + } + if got1 != tt.want1 { + t.Errorf("UpdateGithubSource() got1 = %v, want %v", got1, tt.want1) + } + }) + } +} + +func TestFlags_UpdateModule(t *testing.T) { + type fields struct { + File string + Directory string + GitHubToken string + Days uint + DryRun bool + Entries []string + Update bool + ContinueOnError bool + } + type args struct { + file string + } + tests := []struct { + name string + fields fields + args args + wantErr bool + }{ + {"add version", fields{Update: true}, args{"testdata/modules/github-git/module.tf"}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + myFlags := &Flags{ + File: tt.fields.File, + Directory: tt.fields.Directory, + GitHubToken: tt.fields.GitHubToken, + Days: tt.fields.Days, + DryRun: tt.fields.DryRun, + Entries: tt.fields.Entries, + Update: tt.fields.Update, + ContinueOnError: tt.fields.ContinueOnError, + } + if err := myFlags.UpdateModule(tt.args.file); (err != nil) != tt.wantErr { + t.Errorf("UpdateModule() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestCustomErrors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + err error + expected string + }{ + { + name: "URL Join Error", + err: &urlJoinError{fmt.Errorf("invalid path")}, + expected: "failed to join url: invalid path", + }, + { + name: "Empty Module Error", + err: &moduleEmptyError{}, + expected: "module name cannot be empty", + }, + { + name: "Empty URL Error", + err: &emptyURL{}, + expected: "URL is empty", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if tt.err.Error() != tt.expected { + t.Errorf("Error() = %v, want %v", tt.err.Error(), tt.expected) + } + }) + } +} + +func TestRegistry_GetLatest_EdgeCases(t *testing.T) { + t.Parallel() + + type fields struct { + Registry bool + LatestVersion string + } + + tests := []struct { + name string + fields fields + module string + want *string + wantErr bool + }{ + { + name: "Empty Module", + fields: fields{false, ""}, + module: "", + want: nil, + wantErr: true, + }, + { + name: "Module With Special Characters", + fields: fields{false, ""}, + module: "test/module/with spaces/and#special@chars", + want: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + myRegistry := &Registry{ + Registry: tt.fields.Registry, + LatestVersion: tt.fields.LatestVersion, + } + got, err := myRegistry.GetLatest(tt.module) + if (err != nil) != tt.wantErr { + t.Errorf("GetLatest() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("GetLatest() got = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/src/core/pre-commit.go b/src/core/pre-commit.go new file mode 100644 index 0000000..6d4da4a --- /dev/null +++ b/src/core/pre-commit.go @@ -0,0 +1,147 @@ +package core + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/rs/zerolog/log" + "github.com/sergi/go-diff/diffmatchpatch" + "gopkg.in/yaml.v3" +) + +type Hook struct { + ID string `yaml:"id"` + Name string `yaml:"name,omitempty"` + Entry string `yaml:"entry,omitempty"` + Language string `yaml:"language,omitempty"` + Files string `yaml:"files,omitempty"` + Exclude string `yaml:"exclude,omitempty"` + Types []string `yaml:"types,omitempty"` + TypesOr []string `yaml:"types_or,omitempty"` + ExcludeTypes []string `yaml:"exclude_types,omitempty"` + AlwaysRun *bool `yaml:"always_run,omitempty"` + FailFast *bool `yaml:"fail_fast,omitempty"` + Verbose *bool `yaml:"verbose,omitempty"` + PassFilenames *bool `yaml:"pass_filenames,omitempty"` + RequireSerial *bool `yaml:"require_serial,omitempty"` + Description string `yaml:"description,omitempty"` + LanguageVersion string `yaml:"language_version,omitempty"` + MinimumPrecommitVersion string `yaml:"minimum_pre_commit_version,omitempty"` + Args []string `yaml:"args,omitempty"` + Stages []string `yaml:"stages,omitempty"` +} + +type Repo struct { + Hooks []Hook `yaml:"hooks"` + Repo string `yaml:"repo"` + Rev string `yaml:"rev,omitempty"` +} + +type ConfigFile struct { + DefaultLanguageVersion struct { + Python string `yaml:"python"` + } `yaml:"default_language_version"` + Repos []Repo `yaml:"repos"` +} + +// Add constants for repeated values +const ( + PreCommitConfigFile = ".pre-commit-config.yaml" + GitHubPrefix = "https://github.com/" + FilePermissions = 0666 +) + +func (myFlags *Flags) UpdateHooks() error { + var config *string + var err error + + if config, err = myFlags.GetHook(); err != nil { + return &getHookError{err: err} + } + + data, err := os.ReadFile(*config) + if err != nil { + return &readConfigError{config, err} + } + + var m ConfigFile + + err = yaml.Unmarshal(data, &m) + + if err != nil { + return &unmarshalJSONError{err} + } + + var newRepos []Repo + + for _, item := range m.Repos { + action := strings.Replace(item.Repo, GitHubPrefix, "", 1) + tag, err := GetLatestTag(action, myFlags.GitHubToken) + + if err != nil { + log.Info().Msgf("failed to find %s", item.Repo) + // i dont want to delete hook + newRepos = append(newRepos, item) + continue + } + + myTag := tag.(map[string]interface{}) + + commit := myTag["commit"].(map[string]interface{}) + + item.Rev = commit["sha"].(string) // myTag["name"].(string) + + newRepos = append(newRepos, item) + } + + newConfigFile := m + newConfigFile.Repos = newRepos + + newData, err := yaml.Marshal(&newConfigFile) + if err != nil { + return &marshalJSONError{err: err} + } + + dmp := diffmatchpatch.New() + diffs := dmp.DiffMain(string(data), string(newData), false) + + fmt.Println(dmp.DiffPrettyText(diffs)) + + if !myFlags.DryRun { + err = os.WriteFile(*config, newData, FilePermissions) + if err != nil { + log.Info().Msgf("failed to write %s", *config) + + return err + } + } + + return nil +} + +func (myFlags *Flags) GetHook() (*string, error) { + var err error + myFlags.Directory, err = filepath.Abs(myFlags.Directory) + + if err != nil { + return nil, fmt.Errorf("failed to make sense of directory %s", myFlags.Directory) + } + + fileInfo, err := os.Stat(myFlags.Directory) + if err != nil { + return nil, fmt.Errorf("please specify a valid directory: %s", myFlags.Directory) + } + + if !fileInfo.IsDir() { + return nil, fmt.Errorf("please specify a directory") + } + + config := filepath.Join(myFlags.Directory, PreCommitConfigFile) + if _, err = os.Stat(config); err != nil { + return nil, fmt.Errorf("pre-commit config not found %s", config) + } + + return &config, nil +} diff --git a/src/core/pre-commit_test.go b/src/core/pre-commit_test.go new file mode 100644 index 0000000..0ea19bb --- /dev/null +++ b/src/core/pre-commit_test.go @@ -0,0 +1,48 @@ +package core + +import "testing" + +func TestFlags_UpdateHooks(t *testing.T) { + t.Parallel() + type fields struct { + File string + Directory string + GitHubToken string + Days uint + DryRun bool + Entries []string + Update bool + ContinueOnError bool + } + + tests := []struct { + name string + fields fields + wantErr bool + }{ + {name: "Empty", fields: fields{GitHubToken: gitHubToken}, wantErr: true}, + {name: "guff", fields: fields{Directory: "guff", GitHubToken: gitHubToken}, wantErr: true}, + {name: "Pass relative", fields: fields{Directory: "../../", GitHubToken: gitHubToken}, wantErr: false}, + //{name: "Pass absolute", fields: fields{Directory: "E:/Code/pike", GitHubToken: gitHubToken}, wantErr: false}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + myFlags := &Flags{ + File: tt.fields.File, + Directory: tt.fields.Directory, + GitHubToken: tt.fields.GitHubToken, + Days: tt.fields.Days, + DryRun: tt.fields.DryRun, + Entries: tt.fields.Entries, + Update: tt.fields.Update, + ContinueOnError: tt.fields.ContinueOnError, + } + if err := myFlags.UpdateHooks(); (err != nil) != tt.wantErr { + t.Errorf("UpdateHooks() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/src/core/registry.go b/src/core/registry.go new file mode 100644 index 0000000..5dfbae4 --- /dev/null +++ b/src/core/registry.go @@ -0,0 +1,152 @@ +package core + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +type Registry struct { + Registry bool + LatestVersion string +} + +const ( + registryBaseURL = "https://registry.terraform.io/v1/modules/" + successStatus = 200 + defaultTimeout = 30 * time.Second +) + +func (myRegistry *Registry) IsRegistryModule(module string) (bool, error) { + module = url.PathEscape(module) + urlBuilt := registryBaseURL + module + "/versions" + result, err := IsOK(urlBuilt) + + myRegistry.Registry = result + + return result, err +} + +type URLFormatError struct { + err error +} + +func (e URLFormatError) Error() string { + return fmt.Sprintf("failed to format url: %v", e.err) +} + +func IsOK(rawURL string) (bool, error) { + + if rawURL == "" { + return false, &emptyURL{} + } + + // Add URL format validation + if _, err := url.Parse(rawURL); err != nil { + return false, &URLFormatError{err: err} + } + + ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, "GET", rawURL, nil) + + if err != nil { + return false, &requestFailedError{err: err} + } + + resp, err := http.DefaultClient.Do(req) + + if err != nil { + return false, &httpClientError{err: err} + } + + // Add resp.Body.Close() to prevent resource leaks + defer resp.Body.Close() + + if resp.StatusCode == successStatus { + return true, nil + } + + return false, fmt.Errorf("received %s for %s", resp.Status, rawURL) +} + +type urlJoinError struct { + err error +} + +func (m *urlJoinError) Error() string { + return fmt.Sprintf("failed to join url: %v", m.err) +} + +func (myRegistry *Registry) GetLatest(module string) (*string, error) { + // Add module name validation + if module == "" { + return nil, &moduleEmptyError{} + } + + found, err := myRegistry.IsRegistryModule(module) + + if err != nil { + return nil, ®istryModuleError{module, err} + } + + if found { + // Add URL sanitization + urlBuilt, err := url.JoinPath(registryBaseURL, url.PathEscape(module)) + + if err != nil { + return nil, &urlJoinError{err: err} + } + + // Add timeout to prevent hanging requests + client := &http.Client{ + Timeout: defaultTimeout, + } + + resp, err := client.Get(urlBuilt) + + if err != nil { + return nil, &httpGetError{err: err} + } + + if resp == nil { + return nil, &responseNilError{} + } + + if resp.StatusCode != successStatus { + return nil, fmt.Errorf("api failed with %d", resp.StatusCode) + } + + defer func(Body io.ReadCloser) { + _ = Body.Close() + }(resp.Body) + + body, err := io.ReadAll(resp.Body) + + if err != nil { + return nil, &responseReadError{err: err} + } + + var msg map[string]interface{} + + err = json.Unmarshal(body, &msg) + + if err != nil { + return nil, &unmarshalJSONError{err: err} + } + + var ok bool + + myRegistry.LatestVersion, ok = msg["version"].(string) + + if !ok { + return nil, &castToStringError{"version"} + } + } + + return &myRegistry.LatestVersion, nil +} diff --git a/src/core/registry_test.go b/src/core/registry_test.go new file mode 100644 index 0000000..3b66e83 --- /dev/null +++ b/src/core/registry_test.go @@ -0,0 +1,129 @@ +package core + +import ( + "reflect" + "testing" +) + +func TestIsOK(t *testing.T) { + t.Parallel() + + type args struct { + url string + } + + tests := []struct { + name string + args args + want interface{} + wantErr bool + }{ + {"Pass", args{"https://registry.terraform.io/v1/modules/jameswoolfenden/ip/http/versions"}, true, false}, + {"Fail", args{"https://registry.terraform.io/v1/modules/jameswoolfenden/ip/https/versions"}, false, true}, + {"NotUrl", args{"jameswoolfenden/ip/https"}, false, true}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := IsOK(tt.args.url) + if (err != nil) != tt.wantErr { + t.Errorf("IsOK() error = %v, wantErr %v", err, tt.wantErr) + + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("IsOK() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestRegistry_IsRegistryModule(t *testing.T) { + t.Parallel() + + type fields struct { + Registry bool + } + + type args struct { + module string + } + + tests := []struct { + name string + fields fields + args args + want bool + wantErr bool + }{ + {"Pass", fields{false}, args{"jameswoolfenden/ip/http"}, true, false}, + {"Fail", fields{false}, args{"jameswoolfenden/ip/https"}, false, true}, + {"NotUrl", fields{false}, args{"https://jameswoolfenden/ip/https"}, false, true}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + myRegistry := &Registry{ + Registry: tt.fields.Registry, + } + got, err := myRegistry.IsRegistryModule(tt.args.module) + if (err != nil) != tt.wantErr { + t.Errorf("IsRegistryModule() error = %v, wantErr %v", err, tt.wantErr) + + return + } + if got != tt.want { + t.Errorf("IsRegistryModule() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestRegistry_GetLatest(t *testing.T) { + t.Parallel() + + type fields struct { + Registry bool + LatestVersion string + } + + type args struct { + module string + } + + want := "0.3.14" + + tests := []struct { + name string + fields fields + args args + want *string + wantErr bool + }{ + {"Pass", fields{false, ""}, args{"jameswoolfenden/ip/http"}, &want, false}, + {"Fail", fields{false, ""}, args{"jameswoolfenden/ip/guff"}, nil, true}, + {"No Repo", fields{false, ""}, args{"jameswoolfenden/ip/guff"}, nil, true}, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + myRegistry := &Registry{ + Registry: tt.fields.Registry, + LatestVersion: tt.fields.LatestVersion, + } + got, err := myRegistry.GetLatest(tt.args.module) + if (err != nil) != tt.wantErr { + t.Errorf("GetLatestRelease() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("GetLatestRelease() got = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/src/core/testdata/faulty/.github/workflows/test.yml b/src/core/testdata/faulty/.github/workflows/test.yml new file mode 100644 index 0000000..ed0df1a --- /dev/null +++ b/src/core/testdata/faulty/.github/workflows/test.yml @@ -0,0 +1,42 @@ +on: + push: + branches: + - master + +name: CI +permissions: read-all +env: + GITHUB_TOKEN: ${{ github.token }} +jobs: + test: + ## We want to define a strategy for our job + strategy: + ## this will contain a matrix of all the combinations + ## we wish to test again: + matrix: + go-version: [ 1.24.x ] + platform: [ ubuntu-latest, macos-latest, windows-latest ] + + ## Defines the platform for each test run + runs-on: ${{ matrix.platform }} + + ## the steps that will be run through for each version and platform + ## combination + steps: + ## sets up go based on the version + - name: Install Go + uses: notactions/setup-go@v1.0 + with: + go-version: ${{ matrix.go-version }} + + ## checks out our code locally, so we can work with the files + - name: Checkout code + uses: actions/checkout@v1.0 + + ## runs go test ./... + - name: Build + run: go build ./... + + ## runs go test ./... + - name: Test + run: go test ./... diff --git a/src/core/testdata/files/ci.yml b/src/core/testdata/files/ci.yml index 67b99be..bf5bb85 100644 --- a/src/core/testdata/files/ci.yml +++ b/src/core/testdata/files/ci.yml @@ -13,7 +13,7 @@ jobs: ## this will contain a matrix of all the combinations ## we wish to test again: matrix: - go-version: [ 1.20.x ] + go-version: [ 1.24.x ] platform: [ ubuntu-latest, macos-latest, windows-latest ] ## Defines the platform for each test run diff --git a/src/core/testdata/files/module.tf b/src/core/testdata/files/module.tf new file mode 100644 index 0000000..fc4b53c --- /dev/null +++ b/src/core/testdata/files/module.tf @@ -0,0 +1,5 @@ +module "ip" { + source = "JamesWoolfenden/ip/http" + version = "0.3.12" + permissions = "pike" +} diff --git a/src/core/testdata/gha/.github/workflows/test.yml b/src/core/testdata/gha/.github/workflows/test.yml new file mode 100644 index 0000000..a983b58 --- /dev/null +++ b/src/core/testdata/gha/.github/workflows/test.yml @@ -0,0 +1,42 @@ +on: + push: + branches: + - master + +name: CI +permissions: read-all +env: + GITHUB_TOKEN: ${{ github.token }} +jobs: + test: + ## We want to define a strategy for our job + strategy: + ## this will contain a matrix of all the combinations + ## we wish to test again: + matrix: + go-version: [ 1.24.x ] + platform: [ ubuntu-latest, macos-latest, windows-latest ] + + ## Defines the platform for each test run + runs-on: ${{ matrix.platform }} + + ## the steps that will be run through for each version and platform + ## combination + steps: + ## sets up go based on the version + - name: Install Go + uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.4.0 + with: + go-version: ${{ matrix.go-version }} + + ## checks out our code locally, so we can work with the files + - name: Checkout code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + ## runs go test ./... + - name: Build + run: go build ./... + + ## runs go test ./... + - name: Test + run: go test ./... diff --git a/src/core/testdata/modules/depth/module.tf b/src/core/testdata/modules/depth/module.tf new file mode 100644 index 0000000..b30a0dc --- /dev/null +++ b/src/core/testdata/modules/depth/module.tf @@ -0,0 +1,3 @@ +module "memory" { + source = "git::https://github.com/terraform-aws-modules/terraform-aws-memory-db.git?depth=1" +} diff --git a/src/core/testdata/modules/github-git/module.tf b/src/core/testdata/modules/github-git/module.tf new file mode 100644 index 0000000..0942ab6 --- /dev/null +++ b/src/core/testdata/modules/github-git/module.tf @@ -0,0 +1,6 @@ +module "disk_encryption_set" { + source = "" #v0.0.7 + common_tags = var.common_tags + location = var.location + rg_name = var.resource_group_name +} diff --git a/src/core/testdata/modules/registry/module.git.tf b/src/core/testdata/modules/registry/module.git.tf new file mode 100644 index 0000000..fb68348 --- /dev/null +++ b/src/core/testdata/modules/registry/module.git.tf @@ -0,0 +1,4 @@ +module "git" { + source = "git::https://github.com/JamesWoolfenden/terraform-http-ip.git?ref=aca5d04513698f2f564913cfcc3534780794c800" + permissions = "pike" +} diff --git a/src/core/testdata/modules/registry/module.tf b/src/core/testdata/modules/registry/module.tf new file mode 100644 index 0000000..fc4b53c --- /dev/null +++ b/src/core/testdata/modules/registry/module.tf @@ -0,0 +1,5 @@ +module "ip" { + source = "JamesWoolfenden/ip/http" + version = "0.3.12" + permissions = "pike" +} diff --git a/src/core/testdata/modules/registry/nomoduleshere.tfvars b/src/core/testdata/modules/registry/nomoduleshere.tfvars new file mode 100644 index 0000000..e69de29 diff --git a/src/core/testdata/modules/registry/subdir/catch.tf b/src/core/testdata/modules/registry/subdir/catch.tf new file mode 100644 index 0000000..2fb0780 --- /dev/null +++ b/src/core/testdata/modules/registry/subdir/catch.tf @@ -0,0 +1,3 @@ +resource "aws_s3_bucket" "ignore" { + bucket = "fake" +} diff --git a/src/core/testdata/modules/subdir/module.tf b/src/core/testdata/modules/subdir/module.tf new file mode 100644 index 0000000..d4b4023 --- /dev/null +++ b/src/core/testdata/modules/subdir/module.tf @@ -0,0 +1,3 @@ +module "subdir" { + source = "git::https://github.com/hashicorp/terraform-aws-consul.git//modules/consul-cluster?ref=e9ceb573687c3d28516c9e3714caca84db64a766" +} diff --git a/src/core/testdata/modules/version/gt/module.tf b/src/core/testdata/modules/version/gt/module.tf new file mode 100644 index 0000000..4db64f7 --- /dev/null +++ b/src/core/testdata/modules/version/gt/module.tf @@ -0,0 +1,5 @@ +module "ip" { + source = "JamesWoolfenden/ip/http" + version = ">=0.3.12" + permissions = "pike" +} diff --git a/src/core/testdata/modules/version/range/module.range.tf b/src/core/testdata/modules/version/range/module.range.tf new file mode 100644 index 0000000..4db64f7 --- /dev/null +++ b/src/core/testdata/modules/version/range/module.range.tf @@ -0,0 +1,5 @@ +module "ip" { + source = "JamesWoolfenden/ip/http" + version = ">=0.3.12" + permissions = "pike" +} diff --git a/src/core/types.go b/src/core/types.go new file mode 100644 index 0000000..bd1f97e --- /dev/null +++ b/src/core/types.go @@ -0,0 +1,12 @@ +package core + +type Flags struct { + File string + Directory string + GitHubToken string + Days uint + DryRun bool + Entries []string + Update bool + ContinueOnError bool +}