diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..4eba4a5 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,51 @@ +{ + "name": "Terraform Provider GitHubx", + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + + "features": { + "ghcr.io/devcontainers/features/git:1": {}, + "ghcr.io/devcontainers/features/github-cli:1": {}, + "ghcr.io/devcontainers/features/go:1": {}, + "ghcr.io/devcontainers/features/terraform:1": {} + }, + + "customizations": { + "vscode": { + "extensions": [ + "davidanson.vscode-markdownlint", + "golang.go", + "hashicorp.terraform", + "Gruntfuggly.todo-tree", + "ms-azuretools.vscode-docker", + "vscode-icons-team.vscode-icons", + "ms-vscode.makefile-tools" + ], + "settings": { + "workbench.iconTheme": "vscode-icons", + "go.useLanguageServer": true, + "go.formatTool": "goimports", + "go.lintTool": "golangci-lint", + "go.lintOnSave": "package", + "[go]": { + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit" + } + }, + "[terraform]": { + "editor.formatOnSave": true + }, + "terraform.format.enable": true, + "terraform.languageServer.enable": true + } + } + }, + + "postCreateCommand": ".devcontainer/scripts/postCreate.sh", + + "remoteUser": "root", + + "mounts": [ + "source=${localEnv:HOME}/.config/gh,target=/root/.config/gh,type=bind,consistency=cached" + ] +} diff --git a/.devcontainer/scripts/postCreate.sh b/.devcontainer/scripts/postCreate.sh new file mode 100755 index 0000000..53031a6 --- /dev/null +++ b/.devcontainer/scripts/postCreate.sh @@ -0,0 +1,207 @@ +#!/bin/bash +# Don't use set -e to allow script to continue even if some steps fail +# set -e + +export DEBIAN_FRONTEND=noninteractive +export GIT_TERMINAL_PROMPT=0 + +# Ensure we're running as root (no sudo needed) +if [ "$(id -u)" -ne 0 ]; then + echo "⚠️ Warning: Not running as root. Some operations may fail." +fi + +echo "🚀 Setting up Terraform Provider GitHubx development environment..." + +# Install bash-completion if not already installed +if ! command -v bash-completion &> /dev/null && [ ! -f /usr/share/bash-completion/bash_completion ]; then + echo "📦 Installing bash-completion..." + apt-get update -y && apt-get install -y bash-completion && rm -rf /var/lib/apt/lists/* +fi + +# Setup bashrc with aliases and history settings +echo "⚙️ Configuring bash environment..." +cat >> /root/.bashrc << 'BASHRC_EOF' + +# Devcontainer bashrc configuration +# History and completion settings + +# Enable arrow key history navigation +set -o emacs +bind "\e[A": history-search-backward +bind "\e[B": history-search-forward + +# History settings +HISTCONTROL=ignoredups:erasedups +HISTSIZE=10000 +HISTFILESIZE=20000 +shopt -s histappend + +# Save and reload history after each command +PROMPT_COMMAND="history -a; history -c; history -r; $PROMPT_COMMAND" + +# Enable bash completion +if [ -f /usr/share/bash-completion/bash_completion ]; then + . /usr/share/bash-completion/bash_completion +elif [ -f /etc/bash_completion ]; then + . /etc/bash_completion +fi + +# Additional useful aliases +alias ll='ls -alF' +alias la='ls -A' +alias l='ls -CF' +alias ..='cd ..' +alias ...='cd ../..' + +# Git aliases +alias gs='git status' +alias ga='git add' +alias gc='git commit' +alias gp='git push' +alias gl='git log --oneline --graph --decorate' + +# Terraform aliases +alias tf='terraform' +alias tfi='terraform init' +alias tfa='terraform apply' +alias tfaa='terraform apply -auto-approve' +alias tfp='terraform plan' +alias tfd='terraform destroy' +alias tfda='terraform destroy -auto-approve' + +alias rmtl='rm -rf .terraform.lock.hcl' + +BASHRC_EOF +echo "✅ Bash environment configured" + +# Display system information +echo "📋 System Information:" +uname -a +if command -v go &> /dev/null; then + echo "Go version: $(go version)" + echo "Go path: $(go env GOPATH)" +else + echo "⚠️ Go not found - will be installed by devcontainer feature" +fi + +# Verify Terraform installation +echo "🔧 Verifying Terraform..." +if command -v terraform &> /dev/null; then + echo "✅ Terraform installed: $(terraform version)" +else + echo "⚠️ Terraform not found - will be installed by devcontainer feature" +fi + +# Setup GitHub CLI authentication from host OS +echo "🔧 Setting up GitHub CLI authentication..." +if command -v gh &> /dev/null; then + echo "✅ GitHub CLI installed: $(gh --version | head -n 1)" + + # Ensure the config directory exists + GH_CONFIG_DIR="/root/.config/gh" + mkdir -p "${GH_CONFIG_DIR}" + + # Check if host config is mounted (from devcontainer mount) + # The devcontainer.json should mount ${localEnv:HOME}/.config/gh to /root/.config/gh + if [ -d "${GH_CONFIG_DIR}" ] && [ -n "$(ls -A ${GH_CONFIG_DIR} 2>/dev/null)" ]; then + echo "📁 Found mounted GitHub CLI config from host OS" + # Ensure proper permissions (config files should be readable) + chmod -R u+rw "${GH_CONFIG_DIR}" 2>/dev/null || true + find "${GH_CONFIG_DIR}" -type f -name "*.yaml" -exec chmod 600 {} \; 2>/dev/null || true + + # Verify authentication + if gh auth status &> /dev/null; then + echo "✅ GitHub CLI is authenticated (using host OS auth)" + gh auth status 2>&1 | head -n 3 || true + else + echo "⚠️ GitHub CLI config found but not authenticated." + echo " Please run 'gh auth login' on your host OS to authenticate." + fi + else + echo "⚠️ GitHub CLI config not found at ${GH_CONFIG_DIR}" + echo " The devcontainer should mount your host's ~/.config/gh directory." + echo " If the mount failed, ensure you have authenticated with 'gh auth login' on your host OS." + echo " You can also run 'gh auth login' inside the container, but it won't persist across rebuilds." + + # Check if auth works anyway (might be using a different method) + if gh auth status &> /dev/null; then + echo "✅ GitHub CLI is authenticated (using alternative method)" + gh auth status 2>&1 | head -n 3 || true + fi + fi +else + echo "⚠️ GitHub CLI not found" +fi + +# Install Terraform Plugin Framework docs generator +echo "📚 Installing Terraform Plugin Framework documentation generator..." +go install github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs@latest || echo "⚠️ Failed to install tfplugindocs (may retry later)" + +# Verify Go tools (non-blocking, may not be installed yet) +echo "🔧 Verifying Go tools..." +command -v golangci-lint >/dev/null && echo "golangci-lint: $(golangci-lint version)" || echo "⚠️ golangci-lint not found" +command -v goimports >/dev/null && echo "goimports: $(which goimports)" || echo "⚠️ goimports not found" +command -v gopls >/dev/null && echo "gopls: $(which gopls)" || echo "⚠️ gopls not found" + +# Download Go dependencies +echo "📥 Downloading Go dependencies..." +cd /workspaces/terraform-provider-githubx || cd /workspace +go mod download || echo "⚠️ Go mod download failed (may retry later)" +go mod verify || echo "⚠️ Go mod verify failed" + +# Build the provider to verify everything works +echo "🔨 Building provider..." +go build -buildvcs=false -o terraform-provider-githubx || echo "⚠️ Build failed (may retry later)" + +# Install provider locally for Terraform to use (only if build succeeded) +echo "📦 Installing provider locally for Terraform..." +if [ -f terraform-provider-githubx ]; then +VERSION="0.1.0" +PLATFORM="linux_amd64" +PLUGIN_DIR="${HOME}/.terraform.d/plugins/registry.terraform.io/tfstack/githubx/${VERSION}/${PLATFORM}" +mkdir -p "${PLUGIN_DIR}" + cp terraform-provider-githubx "${PLUGIN_DIR}/" && echo "✅ Provider installed to ${PLUGIN_DIR}" || echo "⚠️ Failed to install provider" +else + echo "⚠️ Provider binary not found, skipping installation" +fi + +# Initialize Terraform in examples (non-blocking, may fail if variables needed) +echo "🔧 Initializing Terraform examples..." +for dir in examples/data-sources/*/ examples/resources/*/ examples/provider/; do + if [ -f "${dir}data-source.tf" ] || [ -f "${dir}resource.tf" ] || [ -f "${dir}provider.tf" ] || [ -f "${dir}main.tf" ] || [ -f "${dir}"*.tf ]; then + echo " Initializing ${dir}..." + (cd "${dir}" && terraform init -upgrade -input=false > /dev/null 2>&1 && echo " ✅ ${dir} initialized" || echo " ⚠️ ${dir} skipped (may need variables)") + fi + done + +# Load .env file if it exists +echo "🔐 Loading environment variables from .env file..." +if [ -f /workspaces/terraform-provider-githubx/.env ]; then + set -a + source /workspaces/terraform-provider-githubx/.env + set +a + echo "✅ Environment variables loaded from .env" +elif [ -f /workspace/.env ]; then + set -a + source /workspace/.env + set +a + echo "✅ Environment variables loaded from .env" +else + echo "⚠️ No .env file found. Create one from .env.example if needed." +fi + +echo "" +echo "✅ Development environment setup complete!" +echo "" +echo "Available commands:" +echo " make build - Build the provider" +echo " make install - Install the provider" +echo " make install-local - Install provider locally for Terraform testing" +echo " make init-examples - Initialize Terraform in all examples" +echo " make init-example - Initialize a specific example (EXAMPLE=path)" +echo " make test - Run tests" +echo " make fmt - Format code" +echo " make docs - Generate documentation" +echo "" +echo "💡 The provider is already installed locally and examples are initialized!" +echo " Navigate to any example directory and run 'terraform plan' or 'terraform apply'." diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e69de29 diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..0c8b092 --- /dev/null +++ b/.github/CODE_OF_CONDUCT.md @@ -0,0 +1,5 @@ +# Code of Conduct + +HashiCorp Community Guidelines apply to you when interacting with the community here on GitHub and contributing code. + +Please read the full text at https://www.hashicorp.com/community-guidelines diff --git a/.github/workflows/issue-comment-triage.yml b/.github/workflows/issue-comment-triage.yml new file mode 100644 index 0000000..00017cd --- /dev/null +++ b/.github/workflows/issue-comment-triage.yml @@ -0,0 +1,21 @@ +# DO NOT EDIT - This GitHub Workflow is managed by automation +# https://github.com/hashicorp/terraform-devex-repos +name: Issue Comment Triage + +on: + issue_comment: + types: [created] + +jobs: + issue_comment_triage: + runs-on: ubuntu-latest + env: + # issue_comment events are triggered by comments on issues and pull requests. Checking the + # value of github.event.issue.pull_request tells us whether the issue is an issue or is + # actually a pull request, allowing us to dynamically set the gh subcommand: + # https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#issue_comment-on-issues-only-or-pull-requests-only + COMMAND: ${{ github.event.issue.pull_request && 'pr' || 'issue' }} + GH_TOKEN: ${{ github.token }} + steps: + - name: 'Remove waiting-response on comment' + run: gh ${{ env.COMMAND }} edit ${{ github.event.issue.html_url }} --remove-label waiting-response diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml new file mode 100644 index 0000000..358aed1 --- /dev/null +++ b/.github/workflows/lock.yml @@ -0,0 +1,21 @@ +# DO NOT EDIT - This GitHub Workflow is managed by automation +# https://github.com/hashicorp/terraform-devex-repos +name: 'Lock Threads' + +on: + schedule: + - cron: '43 20 * * *' + +jobs: + lock: + runs-on: ubuntu-latest + steps: + # NOTE: When TSCCR updates the GitHub action version, update the template workflow file to avoid drift: + # https://github.com/hashicorp/terraform-devex-repos/blob/main/modules/repo/workflows/lock.tftpl + - uses: dessant/lock-threads@1bf7ec25051fe7c00bdd17e6a7cf3d7bfb7dc771 # v5.0.1 + with: + github-token: ${{ github.token }} + issue-inactive-days: '30' + issue-lock-reason: resolved + pr-inactive-days: '30' + pr-lock-reason: resolved diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..68f13e3 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,45 @@ +# Terraform Provider release workflow. +name: Release + +on: + workflow_run: + workflows: ["Terraform Tag"] + types: + - completed + branches: + - main + +# Releases need permissions to read and write the repository contents. +# GitHub considers creating releases and uploading assets as writing contents. +permissions: + contents: write + +jobs: + goreleaser: + if: ${{ github.event.workflow_run.conclusion == 'success' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + with: + # Allow goreleaser to access older tag information. + fetch-depth: 0 + # Checkout the commit from the triggering workflow run + ref: ${{ github.event.workflow_run.head_sha }} + - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 + with: + go-version-file: 'go.mod' + cache: true + - name: Import GPG key + uses: crazy-max/ghaction-import-gpg@01dd5d3ca463c7f10f7f4f7b4f177225ac661ee4 # v6.1.0 + id: import_gpg + with: + gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} + passphrase: ${{ secrets.PASSPHRASE }} + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@286f3b13b1b49da4ac219696163fb8c1c93e1200 # v6.0.0 + with: + args: release --clean + env: + # GitHub sets the GITHUB_TOKEN secret automatically. + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }} diff --git a/.github/workflows/tag.yml b/.github/workflows/tag.yml new file mode 100644 index 0000000..cc03272 --- /dev/null +++ b/.github/workflows/tag.yml @@ -0,0 +1,15 @@ +name: Terraform Tag +on: + workflow_run: + workflows: ["Tests"] + types: + - completed + branches: + - main + +permissions: + contents: write +jobs: + terraform-tag: + if: ${{ github.event.workflow_run.conclusion == 'success' }} + uses: actionsforge/actions/.github/workflows/terraform-tag.yml@main diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..b19d203 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,90 @@ +# Terraform Provider testing workflow. +name: Tests + +# This GitHub action runs your tests for each pull request and push. +# Optionally, you can turn it on using a schedule for regular testing. +on: + pull_request: + paths-ignore: + - 'README.md' + push: + paths-ignore: + - 'README.md' + +# Testing only needs permissions to read the repository contents. +permissions: + contents: read + +jobs: + # Ensure project builds before running testing matrix + build: + name: Build + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 + with: + go-version-file: 'go.mod' + cache: true + - run: go mod download + - run: go build -v . + - name: Run linters + uses: golangci/golangci-lint-action@aaa42aa0628b4ae2578232a66b541047968fac86 # v6.1.0 + with: + version: latest + + generate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 + with: + go-version-file: 'go.mod' + cache: true + # Temporarily download Terraform 1.8 prerelease for function documentation support. + # When Terraform 1.8.0 final is released, this can be removed. + - uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2 + with: + terraform_version: '1.8.0-alpha20240216' + terraform_wrapper: false + - run: go generate ./... + - name: git diff + run: | + git diff --compact-summary --exit-code || \ + (echo; echo "Unexpected difference in directories after code generation. Run 'go generate ./...' command and commit."; exit 1) + + # Run acceptance tests in a matrix with Terraform CLI versions + test: + name: Terraform Provider Acceptance Tests + needs: build + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + # list whatever Terraform versions here you would like to support + terraform: + - '1.0.*' + - '1.1.*' + - '1.2.*' + - '1.3.*' + - '1.4.*' + - '1.5.*' + - '1.6.*' + - '1.7.*' + steps: + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 + with: + go-version-file: 'go.mod' + cache: true + - uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2 + with: + terraform_version: ${{ matrix.terraform }} + terraform_wrapper: false + - run: go mod download + - env: + TF_ACC: "1" + run: go test -v -cover ./internal/provider/ + timeout-minutes: 10 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa84275 --- /dev/null +++ b/.gitignore @@ -0,0 +1,55 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool +*.out +coverage.html + +# Dependency directories +vendor/ + +# Go workspace file +go.work + +# Terraform files +.terraform/ +.terraform.lock.hcl +*.tfstate +*.tfstate.* +crash.log +crash.*.log +override.tf +override.tf.json +*_override.tf +*_override.tf.json +.terraformrc +terraform.rc + +# Example state files +examples/**/*.tfstate +examples/**/*.tfstate.* +examples/**/.terraform/ + +# IDE files +.idea/ +*.swp +*.swo +*~ + +# OS files +.DS_Store +Thumbs.db + +# Provider binary +terraform-provider-githubx +terraform-provider-githubx.exe + +# Environment variables +.env diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..4fcc3fe --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,26 @@ +# Visit https://golangci-lint.run/ for usage documentation +# and information on other useful linters +issues: + max-per-linter: 0 + max-same-issues: 0 + +linters: + disable-all: true + enable: + - durationcheck + - errcheck + - copyloopvar + - forcetypeassert + - godot + - gofmt + - gosimple + - ineffassign + - makezero + - misspell + - nilerr + - predeclared + - staticcheck + - unconvert + - unparam + - unused + - govet diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..4143ffa --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,61 @@ +# Visit https://goreleaser.com for documentation on how to customize this +# behavior. +version: 2 +before: + hooks: + # this is just an example and not a requirement for provider building/publishing + - go mod tidy +builds: +- env: + # goreleaser does not work with CGO, it could also complicate + # usage by users in CI/CD systems like HCP Terraform where + # they are unable to install libraries. + - CGO_ENABLED=0 + mod_timestamp: '{{ .CommitTimestamp }}' + flags: + - -trimpath + ldflags: + - '-s -w -X main.version={{.Version}} -X main.commit={{.Commit}}' + goos: + - freebsd + - windows + - linux + - darwin + goarch: + - amd64 + - '386' + - arm + - arm64 + ignore: + - goos: darwin + goarch: '386' + binary: '{{ .ProjectName }}_v{{ .Version }}' +archives: +- format: zip + name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}' +checksum: + extra_files: + - glob: 'terraform-registry-manifest.json' + name_template: '{{ .ProjectName }}_{{ .Version }}_manifest.json' + name_template: '{{ .ProjectName }}_{{ .Version }}_SHA256SUMS' + algorithm: sha256 +signs: + - artifacts: checksum + args: + # if you are using this in a GitHub action or some other automated pipeline, you + # need to pass the batch flag to indicate its not interactive. + - "--batch" + - "--local-user" + - "{{ .Env.GPG_FINGERPRINT }}" # set this environment variable for your signing key + - "--output" + - "${signature}" + - "--detach-sign" + - "${artifact}" +release: + extra_files: + - glob: 'terraform-registry-manifest.json' + name_template: '{{ .ProjectName }}_{{ .Version }}_manifest.json' + # If you want to manually examine the release before its live, uncomment this line: + # draft: true +changelog: + disable: true diff --git a/.vscode/commitmsg-conform.yml b/.vscode/commitmsg-conform.yml new file mode 100644 index 0000000..4940385 --- /dev/null +++ b/.vscode/commitmsg-conform.yml @@ -0,0 +1,14 @@ +name: Commit Message Conformance + +on: + pull_request: {} + +permissions: + statuses: write + checks: write + contents: read + pull-requests: read + +jobs: + commitmsg-conform: + uses: actionsforge/actions/.github/workflows/commitmsg-conform.yml@main diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..5ebebc3 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,12 @@ +{ + "recommendations": [ + "davidanson.vscode-markdownlint", + "golang.go", + "hashicorp.terraform", + "Gruntfuggly.todo-tree", + "ms-azuretools.vscode-docker", + "vscode-icons-team.vscode-icons", + "golang.go-nightly", + "ms-vscode.makefile-tools" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..1005b13 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,64 @@ +{ + "todo-tree.tree.scanMode": "workspace only", + + // Go settings + "go.useLanguageServer": true, + "go.formatTool": "goimports", + "go.lintTool": "golangci-lint", + "go.lintOnSave": "package", + "go.testFlags": ["-v"], + "go.testTimeout": "30s", + "[go]": { + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit" + }, + "editor.defaultFormatter": "golang.go" + }, + "[go.mod]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "golang.go" + }, + + // Terraform settings + "[terraform]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "hashicorp.terraform" + }, + "[terraform-vars]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "hashicorp.terraform" + }, + "terraform.format.enable": true, + "terraform.languageServer.enable": true, + + // File associations + "files.associations": { + "*.tf": "terraform", + "*.tfvars": "terraform-vars", + "*.hcl": "hcl" + }, + + // Editor settings + "editor.trimAutoWhitespace": true, + "files.trimTrailingWhitespace": true, + "files.insertFinalNewline": true, + "files.trimFinalNewlines": true, + + // Exclude build artifacts from file watcher + "files.watcherExclude": { + "**/.terraform/**": true, + "**/terraform.tfstate*": true, + "**/.terraform.lock.hcl": true, + "**/vendor/**": true, + "**/bin/**": true + }, + + // Search exclude patterns + "search.exclude": { + "**/vendor": true, + "**/.terraform": true, + "**/go.sum": true, + "**/terraform.tfstate*": true + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b76e247 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.1.0 (Unreleased) + +FEATURES: diff --git a/GNUmakefile b/GNUmakefile new file mode 100644 index 0000000..271a05a --- /dev/null +++ b/GNUmakefile @@ -0,0 +1,127 @@ +.PHONY: build install test test-coverage testacc fmt docs lint clean + +# Default target +.DEFAULT_GOAL := help + +# Build the provider +build: + @echo "==> Building the provider..." + go build -buildvcs=false -o terraform-provider-githubx + +# Install the provider +install: build + @echo "==> Installing the provider..." + go install + +# Install provider locally for Terraform to use +install-local: build + @echo "==> Installing provider locally for Terraform..." + @VERSION="0.1.0" \ + PLATFORM="linux_amd64" \ + PLUGIN_DIR="$$HOME/.terraform.d/plugins/registry.terraform.io/tfstack/githubx/$$VERSION/$$PLATFORM" \ + && mkdir -p "$$PLUGIN_DIR" \ + && cp terraform-provider-githubx "$$PLUGIN_DIR/" \ + && echo "✅ Provider installed to $$PLUGIN_DIR" + +# Run tests +test: + @echo "==> Running tests..." + go test -v ./... + +# Run tests with coverage +test-coverage: + @echo "==> Running tests with coverage..." + go test -coverprofile=coverage.out ./... + @echo "==> Coverage report:" + @go tool cover -func=coverage.out + @echo "" + @echo "==> HTML coverage report generated: coverage.html" + @go tool cover -html=coverage.out -o coverage.html + +# Run acceptance tests +testacc: + @echo "==> Running acceptance tests..." + TF_ACC=1 go test -v ./... + +# Format code +fmt: + @echo "==> Formatting code..." + go fmt ./... + terraform fmt -recursive ./examples/ + +# Run linter +lint: + @echo "==> Running linter..." + @if command -v golangci-lint > /dev/null; then \ + VERSION=$$(golangci-lint --version 2>/dev/null | grep -oE 'version [0-9]+' | awk '{print $$2}' || echo "1"); \ + if [ "$$VERSION" -ge 2 ]; then \ + echo "==> Detected golangci-lint v2+, using config with version field (excluding v2-incompatible linters)..."; \ + TMP_CONFIG=$$(mktemp /tmp/golangci-XXXXXX.yml); \ + echo "version: 2" > $$TMP_CONFIG; \ + grep -v "^[[:space:]]*- gofmt" .golangci.yml | grep -v "^[[:space:]]*- gosimple" | grep -v "^[[:space:]]*- tenv" >> $$TMP_CONFIG; \ + GOFLAGS="-buildvcs=false" golangci-lint run --config $$TMP_CONFIG; \ + EXIT_CODE=$$?; \ + rm -f $$TMP_CONFIG; \ + exit $$EXIT_CODE; \ + else \ + GOFLAGS="-buildvcs=false" golangci-lint run; \ + fi; \ + else \ + echo "golangci-lint not found. Install it from https://golangci-lint.run/"; \ + exit 1; \ + fi + +# Generate documentation +docs: + @echo "==> Generating documentation..." + GOFLAGS="-buildvcs=false" go generate ./... + +# Initialize Terraform in all examples +init-examples: install-local + @echo "==> Initializing Terraform in examples..." + @for dir in examples/data-sources/*/ examples/resources/*/ examples/provider/; do \ + if [ -f "$$dir/data-source.tf" ] || [ -f "$$dir/resource.tf" ] || [ -f "$$dir/provider.tf" ] || [ -f "$$dir/main.tf" ] || [ -f "$$dir"*.tf ]; then \ + echo "Initializing $$dir..."; \ + cd "$$dir" && terraform init -upgrade > /dev/null 2>&1 && echo "✅ $$dir initialized" || echo "⚠️ $$dir skipped (may need variables)"; \ + cd - > /dev/null; \ + fi \ + done + +# Initialize a specific example +init-example: install-local + @if [ -z "$(EXAMPLE)" ]; then \ + echo "Usage: make init-example EXAMPLE=examples/data-sources/githubx_example"; \ + exit 1; \ + fi + @echo "==> Initializing $(EXAMPLE)..." + @cd $(EXAMPLE) && terraform init -upgrade + +# Clean build artifacts +clean: + @echo "==> Cleaning..." + rm -f terraform-provider-githubx + rm -f terraform-provider-githubx.exe + rm -f coverage.out coverage.html + go clean + @echo "==> Cleaning Terraform state files..." + @find examples -name ".terraform" -type d -exec rm -rf {} + 2>/dev/null || true + @find examples -name ".terraform.lock.hcl" -type f -delete 2>/dev/null || true + @find examples -name "*.tfstate" -type f -delete 2>/dev/null || true + @find examples -name "*.tfstate.*" -type f -delete 2>/dev/null || true + +# Help target +help: + @echo "Available targets:" + @echo " build - Build the provider binary" + @echo " install - Install the provider to GOPATH/bin" + @echo " install-local - Install provider locally for Terraform testing" + @echo " init-examples - Initialize Terraform in all examples (auto-installs provider)" + @echo " init-example - Initialize a specific example (use EXAMPLE=path)" + @echo " test - Run unit tests" + @echo " test-coverage - Run tests with coverage report" + @echo " testacc - Run acceptance tests (requires TF_ACC=1)" + @echo " fmt - Format code" + @echo " lint - Run golangci-lint" + @echo " docs - Generate documentation" + @echo " clean - Clean build artifacts and Terraform state" + @echo " help - Show this help message" diff --git a/README.md b/README.md index 50c94a9..4e1a3e0 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,524 @@ -# terraform-provider-githubx -A supplemental Terraform provider offering extended GitHub capabilities alongside the official provider +# Terraform Provider for GitHubx + +A supplemental Terraform provider offering extended GitHub capabilities alongside the official provider, built using the Terraform Plugin Framework. + +## Features + +- **User Information**: Query GitHub user information and profiles +- **Repository Management**: Create and manage GitHub repositories with extended capabilities +- **Branch Management**: Create and manage repository branches +- **File Management**: Create, update, and manage files in repositories +- **Pull Request Automation**: Create pull requests with auto-merge capabilities, including automatic approval and merge when ready +- **Data Sources**: Query GitHub users, repositories, branches, and files +- **Extensible**: Designed to complement the official GitHub provider with additional capabilities + +## Requirements + +- [Terraform](https://www.terraform.io/downloads.html) >= 1.0 +- [Go](https://golang.org/doc/install) >= 1.24 (to build the provider plugin) + +## Installation + +### Using Terraform Registry + +Add the provider to your Terraform configuration: + +```hcl +terraform { + required_providers { + githubx = { + source = "tfstack/githubx" + version = "~> 0.1" + } + } +} + +provider "githubx" { + token = var.github_token +} +``` + +### Building from Source + +1. Clone the repository: + + ```bash + git clone https://github.com/tfstack/terraform-provider-githubx.git + cd terraform-provider-githubx + ``` + +2. Build the provider: + + ```bash + go install + ``` + +3. Install the provider to your local Terraform plugins directory: + + ```bash + mkdir -p ~/.terraform.d/plugins/registry.terraform.io/tfstack/githubx/0.1.0/linux_amd64 + cp $GOPATH/bin/terraform-provider-githubx ~/.terraform.d/plugins/registry.terraform.io/tfstack/githubx/0.1.0/linux_amd64/ + ``` + +## Configuration + +The provider supports various configuration options for authentication, GitHub Enterprise Server, and development/testing. + +## Authentication + +The provider supports multiple authentication methods for higher rate limits and access to private resources. + +### Option 1: Provider Configuration Block (Personal Access Token) + +```hcl +provider "githubx" { + token = "your-github-token-here" +} +``` + +### Option 2: OAuth Token + +```hcl +provider "githubx" { + oauth_token = "your-oauth-token-here" +} +``` + +### Option 3: Environment Variable + +Set the `GITHUB_TOKEN` environment variable: + +```bash +export GITHUB_TOKEN="your-github-token-here" +``` + +Then use the provider without the token attribute: + +```hcl +provider "githubx" { + # token will be read from GITHUB_TOKEN environment variable +} +``` + +### Option 4: GitHub CLI Authentication + +If you have the GitHub CLI (`gh`) installed and authenticated, the provider will automatically use your GitHub CLI token: + +```bash +# Authenticate with GitHub CLI (if not already done) +gh auth login +``` + +Then use the provider without any configuration: + +```hcl +provider "githubx" { + # token will be automatically retrieved from 'gh auth token' +} +``` + +### Option 5: GitHub App Authentication + +For GitHub App authentication, you need to provide the App ID, Installation ID, and path to the private key PEM file: + +```hcl +provider "githubx" { + app_auth { + id = 123456 + installation_id = 789012 + pem_file = "/path/to/private-key.pem" + } +} +``` + +**Note:** The provider checks for authentication in this order: + +1. Provider `token` attribute (Personal Access Token) +2. Provider `oauth_token` attribute +3. `GITHUB_TOKEN` environment variable +4. GitHub CLI (`gh auth token`) +5. GitHub App authentication (`app_auth` block) +6. Unauthenticated (if none of the above are available) + +**Development Container:** If you're using the devcontainer, the host OS GitHub CLI authentication is automatically mounted and will be used by the provider. You only need to authenticate once on your host OS with `gh auth login`, and it will persist across container rebuilds. + +### Getting a Personal Access Token + +1. Go to [GitHub Settings > Developer settings > Personal access tokens](https://github.com/settings/tokens) +2. Click "Generate new token" (classic) or "Generate new token (fine-grained)" +3. Select the scopes/permissions you need +4. Click "Generate token" +5. Copy the token immediately (it won't be shown again) + +**Note**: For most data sources, a token is optional but recommended. Without a token, you'll be limited to 60 requests/hour. With a token, you get 5,000 requests/hour. + +### GitHub App Authentication + +GitHub App authentication is useful for CI/CD pipelines and automated workflows. To use it: + +1. **Create a GitHub App** in your organization or personal account +2. **Install the App** in the repositories or organization where you need access +3. **Generate a private key** for the App (download the PEM file) +4. **Configure the provider** with the App ID, Installation ID, and path to the PEM file + +The provider will automatically: + +- Generate a JWT token using the private key +- Exchange it for an installation access token +- Use the installation token for API requests + +**Note**: Installation tokens are automatically refreshed as needed (they expire after 1 hour). + +## Provider Configuration Options + +### Base URL (GitHub Enterprise Server) + +The provider supports GitHub Enterprise Server (GHES) by configuring a custom base URL: + +```hcl +provider "githubx" { + base_url = "https://github.example.com/api/v3/" +} +``` + +Or via environment variable: + +```bash +export GITHUB_BASE_URL="https://github.example.com/api/v3/" +``` + +**Default:** `https://api.github.com/` + +### Owner Configuration + +Specify the GitHub owner (user or organization) to manage: + +```hcl +provider "githubx" { + owner = "my-organization" +} +``` + +Or via environment variable: + +```bash +export GITHUB_OWNER="my-organization" +``` + +This can be useful for: + +- Multi-organization scenarios +- Resource scoping +- Validation and defaults + +### Insecure Mode (TLS) + +Enable insecure mode for testing with self-signed certificates: + +```hcl +provider "githubx" { + insecure = true +} +``` + +Or via environment variable: + +```bash +export GITHUB_INSECURE="true" +``` + +**Warning:** Only use this in development/testing environments. It disables TLS certificate verification. + +### Rate Limits + +- **Unauthenticated**: 60 requests/hour +- **Authenticated**: 5,000 requests/hour + +The provider will work without a token, but with very limited rate limits. For testing, this is usually sufficient for a few queries. + +## Quick Start + +Here's a simple example to get you started: + +```hcl +terraform { + required_providers { + githubx = { + source = "tfstack/githubx" + version = "~> 0.1" + } + } +} + +provider "githubx" { + # Token will be read from GITHUB_TOKEN environment variable +} + +data "githubx_user" "example" { + username = "octocat" +} + +output "user" { + value = data.githubx_user.example +} +``` + +For more examples, see the [`examples/`](examples/) directory. + +## Data Sources + +- [`githubx_user`](docs/data-sources/user.md) - Retrieves information about a GitHub user +- [`githubx_repository`](docs/data-sources/repository.md) - Retrieves information about a GitHub repository +- [`githubx_repository_branch`](docs/data-sources/repository_branch.md) - Retrieves information about a GitHub repository branch +- [`githubx_repository_file`](docs/data-sources/repository_file.md) - Retrieves information about a file in a GitHub repository + +## Resources + +- [`githubx_repository`](docs/resources/repository.md) - Creates and manages a GitHub repository +- [`githubx_repository_branch`](docs/resources/repository_branch.md) - Creates and manages a GitHub repository branch +- [`githubx_repository_file`](docs/resources/repository_file.md) - Creates and manages files in a GitHub repository +- [`githubx_repository_pull_request_auto_merge`](docs/resources/repository_pull_request_auto_merge.md) - Creates and manages a GitHub pull request with optional auto-merge capabilities + +## Local Testing (Development Container) + +When developing in the devcontainer, you can test the provider locally using the following steps: + +### 1. Build the Provider + +Build the provider binary: + +```bash +make build +# or +go build -o terraform-provider-githubx -buildvcs=false +``` + +### 2. Install Provider Locally + +Install the provider to Terraform's local plugin directory so Terraform can find it: + +Option A: Using Make (Recommended) + +```bash +make install-local +``` + +Option B: Manual installation + +```bash +# Create the plugin directory structure +mkdir -p ~/.terraform.d/plugins/registry.terraform.io/tfstack/githubx/0.1.0/linux_amd64 + +# Copy the built binary +cp terraform-provider-githubx ~/.terraform.d/plugins/registry.terraform.io/tfstack/githubx/0.1.0/linux_amd64/ +``` + +**Note:** The version number (`0.1.0`) should match the version in your Terraform configuration's `required_providers` block. + +### 3. Initialize Examples (Automated) + +Option A: Initialize all examples automatically + +```bash +make init-examples +``` + +This will: + +- Build and install the provider locally +- Initialize Terraform in all example directories +- Skip examples that require variables (you'll need to set those manually) + +Option B: Initialize a specific example + +```bash +make init-example EXAMPLE=examples/data-sources/githubx_user +``` + +Option C: Manual initialization + +Navigate to the example directory and initialize manually: + +```bash +cd examples/data-sources/githubx_user +terraform init +``` + +### 4. Test with Example Configuration + +After initialization, navigate to any example directory and test the provider: + +```bash +cd examples/data-sources/githubx_user + +# Option 1: Use .env file (recommended - edit .env with your values) +# Copy .env.example to .env and fill in your values, then: +source .env + +# Option 2: Set environment variables manually +# Set your GitHub token (optional but recommended) +export GITHUB_TOKEN="your-github-token-here" + +# Plan to see what Terraform will do +terraform plan + +# Apply to test the provider +terraform apply +``` + +You should see output like: + +```text +Outputs: + +user_bio = "GitHub's mascot" +user_id = 583231 +user_login = "octocat" +user_name = "The Octocat" +user_public_repos = 8 +``` + +### 5. Run Unit Tests + +Run the unit tests: + +```bash +make test +# or +go test -v ./... +``` + +### 6. Run Test Coverage + +Generate a test coverage report: + +Option A: Using Make (Recommended) + +```bash +make test-coverage +``` + +This will: + +- Run tests with coverage +- Display coverage summary in the terminal +- Generate an HTML coverage report (`coverage.html`) + +Option B: Manual commands + +```bash +# Generate coverage profile +go test -coverprofile=coverage.out ./... + +# View coverage report in terminal +go tool cover -func=coverage.out + +# Generate HTML coverage report +go tool cover -html=coverage.out -o coverage.html + +# View coverage for specific package +go test -cover ./internal/provider/ +``` + +**Coverage Options:** + +- `-coverprofile=coverage.out` - Generate coverage profile file +- `-covermode=count` - Show how many times each statement was executed (default: `set`) +- `-covermode=atomic` - Same as count but thread-safe (useful for parallel tests) +- `-coverpkg=./...` - Include coverage for all packages, not just tested ones + +**Example output:** + +```text +github.com/tfstack/terraform-provider-githubx/internal/provider/data_source_user.go:Metadata 100.0% +github.com/tfstack/terraform-provider-githubx/internal/provider/data_source_user.go:Schema 100.0% +... +total: (statements) 85.5% +``` + +### 7. Run Acceptance Tests + +Acceptance tests make real API calls to GitHub. Set the `TF_ACC` environment variable to enable them: + +```bash +export GITHUB_TOKEN="your-github-token-here" +export TF_ACC=1 +make testacc +# or +TF_ACC=1 go test -v ./... +``` + +**Warning:** Acceptance tests make real API calls to GitHub. Use a test token and be mindful of rate limits. + +### 8. Quick Setup Scripts + +Helper scripts are available to automate common tasks: + +**Install Provider Locally:** + +```bash +make install-local +``` + +**Initialize All Examples:** + +```bash +make init-examples +``` + +**Initialize Specific Example:** + +```bash +make init-example EXAMPLE=examples/data-sources/githubx_user +``` + +### Troubleshooting + +- **Provider not found:** Ensure the version in your Terraform config matches the directory version (`0.1.0`) +- **Permission denied:** Make sure the plugin directory is writable: `chmod -R 755 ~/.terraform.d/plugins/` +- **Provider version mismatch:** Update the version in your Terraform config or rename the plugin directory to match +- **Rate limit errors:** Set the `GITHUB_TOKEN` environment variable for higher rate limits (5,000 requests/hour vs 60 requests/hour) +- **Connection errors:** Verify your token is correct and has the necessary permissions + +## Examples + +Comprehensive examples are available in the [`examples/`](examples/) directory: + +- **Data Sources**: See [`examples/data-sources/`](examples/data-sources/) for examples of querying GitHub resources + - `githubx_user` - Query user information + - `githubx_repository` - Query repository information + - `githubx_repository_branch` - Query branch information + - `githubx_repository_file` - Query file content and metadata +- **Resources**: See [`examples/resources/`](examples/resources/) for examples of managing GitHub resources + - `githubx_repository` - Create and manage repositories + - `githubx_repository_branch` - Create and manage branches + - `githubx_repository_file` - Create and manage files + - `githubx_repository_pull_request_auto_merge` - Create pull requests with auto-merge +- **Provider**: See [`examples/provider/`](examples/provider/) for a simple provider example + +Each example includes a `data-source.tf`, `resource.tf`, or `provider.tf` file with working Terraform configuration. + +## Limitations + +- **Rate Limits**: Without authentication, you're limited to 60 requests/hour. Use a token for 5,000 requests/hour. +- **Scope**: This provider is designed to supplement the official GitHub provider, not replace it. Use it for extended capabilities alongside the official provider. + +## Documentation + +Full documentation for all data sources and resources is available in the [`docs/`](docs/) directory: + +- [Data Sources Documentation](docs/data-sources/) +- [Resources Documentation](docs/resources/) + +## Development + +See [CONTRIBUTING.md](CONTRIBUTING.md) for information on developing the provider. + +## Support + +- **Issues**: Report bugs and request features on [GitHub Issues](https://github.com/tfstack/terraform-provider-githubx/issues) +- **Discussions**: Ask questions and share ideas on [GitHub Discussions](https://github.com/tfstack/terraform-provider-githubx/discussions) + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. diff --git a/docs/data-sources/repository.md b/docs/data-sources/repository.md new file mode 100644 index 0000000..f7e89b1 --- /dev/null +++ b/docs/data-sources/repository.md @@ -0,0 +1,186 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "githubx_repository Data Source - githubx" +subcategory: "" +description: |- + Get information on a GitHub repository. +--- + +# githubx_repository (Data Source) + +Get information on a GitHub repository. + +## Example Usage + +```terraform +terraform { + required_providers { + githubx = { + source = "tfstack/githubx" + version = "~> 0.1" + } + } +} + +# Example 1: Using full_name +data "githubx_repository" "example_full_name" { + full_name = "cloudbuildlab/.github" +} + +output "repository_full_name" { + value = data.githubx_repository.example_full_name.full_name +} + +output "repository_description" { + value = data.githubx_repository.example_full_name.description +} + +output "repository_default_branch" { + value = data.githubx_repository.example_full_name.default_branch +} + +output "repository_html_url" { + value = data.githubx_repository.example_full_name.html_url +} + +# Example 2: Using name with provider-level owner +provider "githubx" { + owner = "cloudbuildlab" +} + +data "githubx_repository" "example_name" { + name = "actions-markdown-lint" +} + +output "repository_name" { + value = data.githubx_repository.example_name.name +} + +output "repository_visibility" { + value = data.githubx_repository.example_name.visibility +} + +output "repository_primary_language" { + value = data.githubx_repository.example_name.primary_language +} + +output "repository_topics" { + value = data.githubx_repository.example_name.topics +} +``` + + +## Schema + +### Optional + +- `full_name` (String) The full name of the repository (owner/repo). Conflicts with `name`. +- `name` (String) The name of the repository. Conflicts with `full_name`. If `name` is provided, the provider-level `owner` configuration will be used. + +### Read-Only + +- `allow_auto_merge` (Boolean) Whether auto-merge is enabled. +- `allow_merge_commit` (Boolean) Whether merge commits are allowed. +- `allow_rebase_merge` (Boolean) Whether rebase merges are allowed. +- `allow_squash_merge` (Boolean) Whether squash merges are allowed. +- `allow_update_branch` (Boolean) Whether branch updates are allowed. +- `archived` (Boolean) Whether the repository is archived. +- `default_branch` (String) The default branch of the repository. +- `delete_branch_on_merge` (Boolean) Whether to delete branches after merging pull requests. +- `description` (String) The description of the repository. +- `fork` (Boolean) Whether the repository is a fork. +- `git_clone_url` (String) The Git clone URL of the repository. +- `has_discussions` (Boolean) Whether the repository has discussions enabled. +- `has_downloads` (Boolean) Whether the repository has downloads enabled. +- `has_issues` (Boolean) Whether the repository has issues enabled. +- `has_projects` (Boolean) Whether the repository has projects enabled. +- `has_wiki` (Boolean) Whether the repository has wiki enabled. +- `homepage_url` (String) The homepage URL of the repository. +- `html_url` (String) The HTML URL of the repository. +- `http_clone_url` (String) The HTTP clone URL of the repository. +- `id` (String) The Terraform state ID (repository name). +- `is_template` (Boolean) Whether the repository is a template. +- `merge_commit_message` (String) The default commit message for merge commits. +- `merge_commit_title` (String) The default commit title for merge commits. +- `node_id` (String) The GitHub node ID of the repository. +- `pages` (Attributes) The GitHub Pages configuration for the repository. (see [below for nested schema](#nestedatt--pages)) +- `primary_language` (String) The primary programming language of the repository. +- `private` (Boolean) Whether the repository is private. +- `repo_id` (Number) The GitHub repository ID as an integer. +- `repository_license` (Attributes) The license information for the repository. (see [below for nested schema](#nestedatt--repository_license)) +- `squash_merge_commit_message` (String) The default commit message for squash merges. +- `squash_merge_commit_title` (String) The default commit title for squash merges. +- `ssh_clone_url` (String) The SSH clone URL of the repository. +- `svn_url` (String) The SVN URL of the repository. +- `template` (Attributes) The template repository information, if this repository was created from a template. (see [below for nested schema](#nestedatt--template)) +- `topics` (List of String) The topics (tags) associated with the repository. +- `visibility` (String) The visibility of the repository (public, private, or internal). + + +### Nested Schema for `pages` + +Read-Only: + +- `build_type` (String) +- `cname` (String) +- `custom_404` (Boolean) +- `html_url` (String) +- `source` (Attributes) (see [below for nested schema](#nestedatt--pages--source)) +- `status` (String) +- `url` (String) + + +### Nested Schema for `pages.source` + +Read-Only: + +- `branch` (String) +- `path` (String) + + + + +### Nested Schema for `repository_license` + +Read-Only: + +- `content` (String) +- `download_url` (String) +- `encoding` (String) +- `git_url` (String) +- `html_url` (String) +- `license` (Attributes) (see [below for nested schema](#nestedatt--repository_license--license)) +- `name` (String) +- `path` (String) +- `sha` (String) +- `size` (Number) +- `type` (String) +- `url` (String) + + +### Nested Schema for `repository_license.license` + +Read-Only: + +- `body` (String) +- `conditions` (List of String) +- `description` (String) +- `featured` (Boolean) +- `html_url` (String) +- `implementation` (String) +- `key` (String) +- `limitations` (List of String) +- `name` (String) +- `permissions` (List of String) +- `spdx_id` (String) +- `url` (String) + + + + +### Nested Schema for `template` + +Read-Only: + +- `owner` (String) +- `repository` (String) diff --git a/docs/data-sources/repository_branch.md b/docs/data-sources/repository_branch.md new file mode 100644 index 0000000..199bca7 --- /dev/null +++ b/docs/data-sources/repository_branch.md @@ -0,0 +1,83 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "githubx_repository_branch Data Source - githubx" +subcategory: "" +description: |- + Get information on a GitHub repository branch. +--- + +# githubx_repository_branch (Data Source) + +Get information on a GitHub repository branch. + +## Example Usage + +```terraform +terraform { + required_providers { + githubx = { + source = "tfstack/githubx" + version = "~> 0.1" + } + } +} + +# Example 1: Using full_name +data "githubx_repository_branch" "example_full_name" { + full_name = "cloudbuildlab/.github" + branch = "main" +} + +output "branch_ref" { + value = data.githubx_repository_branch.example_full_name.ref +} + +output "branch_sha" { + value = data.githubx_repository_branch.example_full_name.sha +} + +output "branch_etag" { + value = data.githubx_repository_branch.example_full_name.etag +} + +# Example 2: Using repository with provider-level owner +provider "githubx" { + owner = "cloudbuildlab" +} + +data "githubx_repository_branch" "example_repository" { + repository = "actions-markdown-lint" + branch = "main" +} + +output "branch_id" { + value = data.githubx_repository_branch.example_repository.id +} + +output "branch_ref_from_repo" { + value = data.githubx_repository_branch.example_repository.ref +} + +output "branch_sha_from_repo" { + value = data.githubx_repository_branch.example_repository.sha +} +``` + + +## Schema + +### Required + +- `branch` (String) The name of the branch. + +### Optional + +- `full_name` (String) The full name of the repository (owner/repo). Conflicts with `repository`. +- `repository` (String) The name of the repository. Conflicts with `full_name`. If `repository` is provided, the provider-level `owner` configuration will be used. + +### Read-Only + +- `etag` (String) The ETag header value from the API response. +- `id` (String) The Terraform state ID (repository/branch). +- `ref` (String) The full Git reference (e.g., refs/heads/main). +- `sha` (String) The SHA of the commit that the branch points to. diff --git a/docs/data-sources/repository_file.md b/docs/data-sources/repository_file.md new file mode 100644 index 0000000..e5de035 --- /dev/null +++ b/docs/data-sources/repository_file.md @@ -0,0 +1,116 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "githubx_repository_file Data Source - githubx" +subcategory: "" +description: |- + Get information on a file in a GitHub repository. +--- + +# githubx_repository_file (Data Source) + +Get information on a file in a GitHub repository. + +## Example Usage + +```terraform +terraform { + required_providers { + githubx = { + source = "tfstack/githubx" + version = "~> 0.1" + } + } +} + +# Example 1: Using full_name to read a file +data "githubx_repository_file" "example_full_name" { + full_name = "cloudbuildlab/.github" + file = "README.md" + branch = "main" +} + +output "file_content" { + value = data.githubx_repository_file.example_full_name.content +} + +output "file_sha" { + value = data.githubx_repository_file.example_full_name.sha +} + +output "file_ref" { + value = data.githubx_repository_file.example_full_name.ref +} + +output "file_commit_sha" { + value = data.githubx_repository_file.example_full_name.commit_sha +} + +output "file_commit_message" { + value = data.githubx_repository_file.example_full_name.commit_message +} + +output "file_commit_author" { + value = data.githubx_repository_file.example_full_name.commit_author +} + +# Example 2: Using repository with provider-level owner +provider "githubx" { + owner = "cloudbuildlab" +} + +data "githubx_repository_file" "example_repository" { + repository = "actions-markdown-lint" + file = ".github/workflows/lint.yml" + branch = "main" +} + +output "file_id" { + value = data.githubx_repository_file.example_repository.id +} + +output "file_content_from_repo" { + value = data.githubx_repository_file.example_repository.content +} + +output "file_sha_from_repo" { + value = data.githubx_repository_file.example_repository.sha +} + +output "file_commit_email" { + value = data.githubx_repository_file.example_repository.commit_email +} + +# Example 3: Reading file from default branch (branch not specified) +data "githubx_repository_file" "example_default_branch" { + full_name = "cloudbuildlab/.github" + file = "CONTRIBUTING.md" +} + +output "file_from_default_branch" { + value = data.githubx_repository_file.example_default_branch.content +} +``` + + +## Schema + +### Required + +- `file` (String) The file path to read. + +### Optional + +- `branch` (String) The branch name, defaults to the repository's default branch. +- `full_name` (String) The full name of the repository (owner/repo). Conflicts with `repository`. +- `repository` (String) The name of the repository. Conflicts with `full_name`. If `repository` is provided, the provider-level `owner` configuration will be used. + +### Read-Only + +- `commit_author` (String) The commit author name. +- `commit_email` (String) The commit author email address. +- `commit_message` (String) The commit message when the file was last modified. +- `commit_sha` (String) The SHA of the commit that modified the file. +- `content` (String) The file's content. +- `id` (String) The Terraform state ID (repository/file). +- `ref` (String) The name of the commit/branch/tag. +- `sha` (String) The blob SHA of the file. diff --git a/docs/data-sources/user.md b/docs/data-sources/user.md new file mode 100644 index 0000000..40a461c --- /dev/null +++ b/docs/data-sources/user.md @@ -0,0 +1,80 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "githubx_user Data Source - githubx" +subcategory: "" +description: |- + Get information on a GitHub user. +--- + +# githubx_user (Data Source) + +Get information on a GitHub user. + +## Example Usage + +```terraform +terraform { + required_providers { + githubx = { + source = "registry.terraform.io/tfstack/githubx" + version = "0.1.0" + } + } +} + +provider "githubx" { + # Token can be provided here or via GITHUB_TOKEN environment variable + # token = "your-github-token-here" +} + +data "githubx_user" "octocat" { + username = "octocat" +} + +output "user_id" { + value = data.githubx_user.octocat.user_id +} + +output "user_name" { + value = data.githubx_user.octocat.name +} + +output "user_login" { + value = data.githubx_user.octocat.username +} + +output "user_bio" { + value = data.githubx_user.octocat.bio +} + +output "user_public_repos" { + value = data.githubx_user.octocat.public_repos +} +``` + + +## Schema + +### Required + +- `username` (String) The GitHub username to look up. + +### Read-Only + +- `avatar_url` (String) The URL of the user's avatar. +- `bio` (String) The user's bio. +- `blog` (String) The user's blog URL. +- `company` (String) The user's company. +- `created_at` (String) The timestamp when the user account was created. +- `email` (String) The user's email address. +- `followers` (Number) The number of followers. +- `following` (Number) The number of users following. +- `html_url` (String) The GitHub URL of the user's profile. +- `id` (String) The GitHub user ID (as string for Terraform state ID). +- `location` (String) The user's location. +- `name` (String) The user's display name. +- `node_id` (String) The GitHub node ID of the user. +- `public_gists` (Number) The number of public gists. +- `public_repos` (Number) The number of public repositories. +- `updated_at` (String) The timestamp when the user account was last updated. +- `user_id` (Number) The GitHub user ID as an integer. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..22654f8 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,54 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "githubx Provider" +description: |- + +--- + +# githubx Provider + + + +## Example Usage + +```terraform +terraform { + required_providers { + githubx = { + source = "tfstack/githubx" + version = "~> 0.1" + } + } +} + +provider "githubx" {} + +data "githubx_user" "example" { + username = "octocat" +} + +output "user" { + value = data.githubx_user.example +} +``` + + +## Schema + +### Optional + +- `app_auth` (Attributes) GitHub App authentication configuration. Requires app_id, installation_id, and pem_file. (see [below for nested schema](#nestedatt--app_auth)) +- `base_url` (String) The GitHub Base API URL. Defaults to `https://api.github.com/`. Set this to your GitHub Enterprise Server API URL (e.g., `https://github.example.com/api/v3/`). +- `insecure` (Boolean) Enable insecure mode for testing purposes. This disables TLS certificate verification. Use only in development/testing environments. +- `oauth_token` (String, Sensitive) GitHub OAuth token for authentication. This is an alternative to the personal access token. +- `owner` (String) The GitHub owner name to manage. Use this field when managing individual accounts or organizations. +- `token` (String, Sensitive) GitHub personal access token for authentication. This token is required to authenticate with the GitHub API. You can obtain a token from GitHub Settings > Developer settings > Personal access tokens. Alternatively, you can set the GITHUB_TOKEN environment variable, or the provider will automatically use GitHub CLI authentication (gh auth token) if available. + + +### Nested Schema for `app_auth` + +Required: + +- `id` (Number) The GitHub App ID. +- `installation_id` (Number) The GitHub App installation ID. +- `pem_file` (String) Path to the GitHub App private key PEM file. diff --git a/docs/resources/repository.md b/docs/resources/repository.md new file mode 100644 index 0000000..c54714a --- /dev/null +++ b/docs/resources/repository.md @@ -0,0 +1,261 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "githubx_repository Resource - githubx" +subcategory: "" +description: |- + Creates and manages a GitHub repository. +--- + +# githubx_repository (Resource) + +Creates and manages a GitHub repository. + +## Example Usage + +```terraform +terraform { + required_providers { + githubx = { + source = "tfstack/githubx" + version = "~> 0.1" + } + } +} + +# Configure the provider with owner +# The owner can be set via environment variable GITHUB_OWNER instead +provider "githubx" { + owner = "cloudbuildlab" # Replace with your GitHub username or organization +} + +# Example 1: Basic public repository +resource "githubx_repository" "basic" { + name = "my-basic-repo" + description = "A basic public repository" + visibility = "public" +} + +output "basic_repository_url" { + value = githubx_repository.basic.html_url +} + +# Example 2: Private repository with features enabled +resource "githubx_repository" "private" { + name = "my-private-repo" + description = "A private repository with features enabled" + visibility = "private" + has_issues = true + has_projects = true + has_wiki = true +} + +output "private_repository_url" { + value = githubx_repository.private.html_url +} + +# Example 3: Repository with merge settings +resource "githubx_repository" "merge_settings" { + name = "my-merge-repo" + description = "Repository with custom merge settings" + visibility = "public" + allow_merge_commit = true + allow_squash_merge = true + allow_rebase_merge = false + allow_auto_merge = true + delete_branch_on_merge = true + squash_merge_commit_title = "PR_TITLE" + squash_merge_commit_message = "PR_BODY" # Valid combination with PR_TITLE + merge_commit_title = "PR_TITLE" + merge_commit_message = "PR_BODY" +} + +output "merge_settings_repository_url" { + value = githubx_repository.merge_settings.html_url +} + +# Example 4: Repository with topics +resource "githubx_repository" "with_topics" { + name = "my-topics-repo" + description = "Repository with topics" + visibility = "public" + topics = ["terraform", "github", "automation", "infrastructure"] +} + +output "topics_repository_url" { + value = githubx_repository.with_topics.html_url +} + +# Example 5: Repository with GitHub Pages +resource "githubx_repository" "with_pages" { + name = "my-pages-repo" + description = "Repository with GitHub Pages enabled" + visibility = "public" + + pages = { + source = { + branch = "main" + path = "/" + } + build_type = "legacy" + } +} + +output "pages_repository_url" { + value = githubx_repository.with_pages.html_url +} + +# Example 6: Template repository +resource "githubx_repository" "template" { + name = "my-template-repo" + description = "A template repository" + visibility = "public" + is_template = true +} + +output "template_repository_url" { + value = githubx_repository.template.html_url +} + +# Example 7: Repository with vulnerability alerts +resource "githubx_repository" "secure" { + name = "my-secure-repo" + description = "Repository with security features" + visibility = "public" + vulnerability_alerts = true +} + +output "secure_repository_url" { + value = githubx_repository.secure.html_url +} + +# # Example 8: Repository that archives on destroy +# # NOTE: When archive_on_destroy = true, the repository is archived (not deleted) when destroyed. +# # If you try to apply this again after destroying, it will fail because the repository already exists. +# # You would need to manually unarchive the repository in GitHub or use a different name. +# resource "githubx_repository" "archivable" { +# name = "my-archivable-repo" +# description = "Repository that archives instead of deleting" +# visibility = "private" +# archive_on_destroy = true +# } + +# output "archivable_repository_url" { +# value = githubx_repository.archivable.html_url +# } + +# Example 9: Complete repository with all features +resource "githubx_repository" "complete" { + name = "my-complete-repo" + description = "A complete repository example with all features" + homepage_url = "https://example.com" + visibility = "public" + has_issues = true + has_discussions = true + has_projects = true + has_downloads = true + has_wiki = true + allow_merge_commit = true + allow_squash_merge = true + allow_rebase_merge = true + allow_auto_merge = true + allow_update_branch = true + delete_branch_on_merge = true + squash_merge_commit_title = "PR_TITLE" + squash_merge_commit_message = "PR_BODY" + merge_commit_title = "PR_TITLE" + merge_commit_message = "PR_BODY" + topics = ["terraform", "github", "example"] + vulnerability_alerts = true + + pages = { + source = { + branch = "main" + path = "/docs" + } + build_type = "workflow" + } +} + +output "complete_repository_url" { + value = githubx_repository.complete.html_url +} + +output "complete_repository_full_name" { + value = githubx_repository.complete.full_name +} + +output "complete_repository_default_branch" { + value = githubx_repository.complete.default_branch +} +``` + + +## Schema + +### Required + +- `name` (String) The name of the repository. + +### Optional + +- `allow_auto_merge` (Boolean) Whether auto-merge is enabled. +- `allow_merge_commit` (Boolean) Whether merge commits are allowed. +- `allow_rebase_merge` (Boolean) Whether rebase merges are allowed. +- `allow_squash_merge` (Boolean) Whether squash merges are allowed. +- `allow_update_branch` (Boolean) Whether branch updates are allowed. +- `archive_on_destroy` (Boolean) Whether to archive the repository instead of deleting it when the resource is destroyed. +- `auto_init` (Boolean) Whether to initialize the repository with a README file. This will create the default branch. +- `delete_branch_on_merge` (Boolean) Whether to delete branches after merging pull requests. +- `description` (String) A description of the repository. +- `has_discussions` (Boolean) Whether the repository has discussions enabled. +- `has_downloads` (Boolean) Whether the repository has downloads enabled. +- `has_issues` (Boolean) Whether the repository has issues enabled. +- `has_projects` (Boolean) Whether the repository has projects enabled. +- `has_wiki` (Boolean) Whether the repository has wiki enabled. +- `homepage_url` (String) URL of a page describing the project. +- `is_template` (Boolean) Whether the repository is a template. +- `merge_commit_message` (String) The default commit message for merge commits. Can be 'PR_BODY', 'PR_TITLE', or 'BLANK'. +- `merge_commit_title` (String) The default commit title for merge commits. Can be 'PR_TITLE' or 'MERGE_MESSAGE'. +- `pages` (Attributes) The GitHub Pages configuration for the repository. (see [below for nested schema](#nestedatt--pages)) +- `squash_merge_commit_message` (String) The default commit message for squash merges. Can be 'PR_BODY', 'COMMIT_MESSAGES', or 'BLANK'. +- `squash_merge_commit_title` (String) The default commit title for squash merges. Can be 'PR_TITLE' or 'COMMIT_OR_PR_TITLE'. +- `topics` (Set of String) The topics (tags) associated with the repository. Order does not matter as topics are stored as a set. +- `visibility` (String) Can be 'public' or 'private'. If your organization is associated with an enterprise account using GitHub Enterprise Cloud or GitHub Enterprise Server 2.20+, visibility can also be 'internal'. +- `vulnerability_alerts` (Boolean) Whether vulnerability alerts are enabled for the repository. + +### Read-Only + +- `archived` (Boolean) Whether the repository is archived. +- `default_branch` (String) The default branch of the repository. +- `full_name` (String) The full name of the repository (owner/repo). +- `html_url` (String) The HTML URL of the repository. +- `id` (String) The repository name (same as `name`). +- `node_id` (String) The GitHub node ID of the repository. +- `repo_id` (Number) The GitHub repository ID as an integer. + + +### Nested Schema for `pages` + +Optional: + +- `build_type` (String) +- `cname` (String) +- `source` (Attributes) (see [below for nested schema](#nestedatt--pages--source)) + +Read-Only: + +- `custom_404` (Boolean) +- `html_url` (String) +- `status` (String) +- `url` (String) + + +### Nested Schema for `pages.source` + +Required: + +- `branch` (String) + +Optional: + +- `path` (String) diff --git a/docs/resources/repository_branch.md b/docs/resources/repository_branch.md new file mode 100644 index 0000000..1861f0e --- /dev/null +++ b/docs/resources/repository_branch.md @@ -0,0 +1,100 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "githubx_repository_branch Resource - githubx" +subcategory: "" +description: |- + Creates and manages a GitHub repository branch. +--- + +# githubx_repository_branch (Resource) + +Creates and manages a GitHub repository branch. + +## Example Usage + +```terraform +terraform { + required_providers { + githubx = { + source = "tfstack/githubx" + version = "~> 0.1" + } + } +} + +# Configure the provider +# The owner can be set via provider config, environment variable GITHUB_OWNER, or will default to authenticated user +provider "githubx" { + # owner = "cloudbuildlab" # Optional: set your GitHub username or organization. If not set, will use authenticated user. + # Token can be provided here or via GITHUB_TOKEN environment variable + # token = "your-github-token-here" +} + +# First, create a repository +resource "githubx_repository" "example" { + name = "my-branch-example-repo" + description = "Repository for branch examples" + visibility = "public" + auto_init = true # Initialize with README to create default branch +} + +# Example 1: Create a branch from default source (main) +resource "githubx_repository_branch" "develop" { + repository = githubx_repository.example.name + branch = "develop" +} + +output "develop_branch_ref" { + value = githubx_repository_branch.develop.ref +} + +output "develop_branch_sha" { + value = githubx_repository_branch.develop.sha +} + +# Example 2: Create a branch from a specific source branch +# Note: This depends on the develop branch being created first +resource "githubx_repository_branch" "feature" { + repository = githubx_repository.example.name + branch = "feature/new-feature" + source_branch = "develop" + depends_on = [githubx_repository_branch.develop] +} + +output "feature_branch_ref" { + value = githubx_repository_branch.feature.ref +} + +# Example 3: Create a branch from a specific commit SHA +# Note: Replace the SHA with an actual commit SHA from your repository +resource "githubx_repository_branch" "hotfix" { + repository = githubx_repository.example.name + branch = "hotfix/critical-fix" + # source_sha = "abc123def456..." # Uncomment and replace with actual commit SHA + depends_on = [githubx_repository.example] +} + +output "hotfix_branch_sha" { + value = githubx_repository_branch.hotfix.sha +} +``` + + +## Schema + +### Required + +- `branch` (String) The repository branch to create. +- `repository` (String) The GitHub repository name. + +### Optional + +- `etag` (String) An etag representing the Branch object. +- `source_branch` (String) The branch name to start from. Defaults to 'main'. +- `source_sha` (String) The commit hash to start from. Defaults to the tip of 'source_branch'. If provided, 'source_branch' is ignored. + +### Read-Only + +- `id` (String) The Terraform state ID (repository/branch). +- `ref` (String) A string representing a branch reference, in the form of 'refs/heads/'. +- `sha` (String) A string storing the reference's HEAD commit's SHA1. diff --git a/docs/resources/repository_file.md b/docs/resources/repository_file.md new file mode 100644 index 0000000..00cc4ab --- /dev/null +++ b/docs/resources/repository_file.md @@ -0,0 +1,157 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "githubx_repository_file Resource - githubx" +subcategory: "" +description: |- + Creates and manages a file in a GitHub repository. +--- + +# githubx_repository_file (Resource) + +Creates and manages a file in a GitHub repository. + +## Example Usage + +```terraform +terraform { + required_providers { + githubx = { + source = "tfstack/githubx" + version = "~> 0.1" + } + } +} + +# Configure the provider +provider "githubx" { + owner = "cloudbuildlab" # Optional: set your GitHub username or organization. If not set, will use authenticated user. + # Token can be provided here or via GITHUB_TOKEN environment variable + # token = "your-github-token-here" +} + +# First, create a repository +resource "githubx_repository" "example" { + name = "my-file-example-repo" + description = "Repository for file examples" + visibility = "public" + auto_init = true # Initialize with README to create default branch +} + +# Example 1: Create a simple file on the default branch +# Note: The repository is auto-initialized with a README.md, so we'll create a different file +resource "githubx_repository_file" "docs" { + repository = githubx_repository.example.name + file = "docs/GETTING_STARTED.md" + content = "# Getting Started\n\nThis is a getting started guide managed by Terraform." +} + +output "docs_commit_sha" { + value = githubx_repository_file.docs.commit_sha +} + +output "docs_sha" { + value = githubx_repository_file.docs.sha +} + +# Example 2: Create a file on a specific branch +resource "githubx_repository_branch" "develop" { + repository = githubx_repository.example.name + branch = "develop" + depends_on = [githubx_repository.example] +} + +resource "githubx_repository_file" "config" { + repository = githubx_repository.example.name + file = ".github/config.yml" + branch = githubx_repository_branch.develop.branch + content = "repository:\n name: ${githubx_repository.example.name}\n description: ${githubx_repository.example.description}\n" + depends_on = [githubx_repository_branch.develop] +} + +output "config_file_ref" { + value = githubx_repository_file.config.ref +} + +# Example 3: Create a file with custom commit message and author +resource "githubx_repository_file" "license" { + repository = githubx_repository.example.name + file = "LICENSE" + content = "MIT License\n\nCopyright (c) 2024\n" + commit_message = "Add MIT license file" + commit_author = "Terraform Bot" + commit_email = "terraform@example.com" +} + +output "license_commit_message" { + value = githubx_repository_file.license.commit_message +} + +# Example 4: Create a file with auto-create branch feature +resource "githubx_repository_file" "feature_file" { + repository = githubx_repository.example.name + file = "feature/new-feature.md" + branch = "feature/new-feature" + content = "# New Feature\n\nThis is a new feature file." + autocreate_branch = true + autocreate_branch_source_branch = "main" + depends_on = [githubx_repository.example] +} + +output "feature_file_id" { + value = githubx_repository_file.feature_file.id +} + +# Example 5: Overwrite existing file (created outside Terraform or by auto-init) +# Note: The repository auto_init creates a README.md, so we can overwrite it +# This demonstrates overwrite_on_create for files that exist but aren't managed by Terraform +resource "githubx_repository_file" "readme" { + repository = githubx_repository.example.name + file = "README.md" + content = "# My Example Repository\n\nThis is an updated README managed by Terraform.\n\n## Features\n\n- Feature 1\n- Feature 2\n" + overwrite_on_create = true + depends_on = [githubx_repository.example] +} + +output "readme_sha" { + value = githubx_repository_file.readme.sha +} + +# Example 6: Update an existing file (modify the same resource) +# This shows how to update a file by changing the content in the same resource +resource "githubx_repository_file" "changelog" { + repository = githubx_repository.example.name + file = "CHANGELOG.md" + content = "# Changelog\n\n## [Unreleased]\n\n### Added\n- Initial version\n" +} + +output "changelog_commit_sha" { + value = githubx_repository_file.changelog.commit_sha +} +``` + + +## Schema + +### Required + +- `content` (String) The file's content. +- `file` (String) The file path to manage. +- `repository` (String) The GitHub repository name. + +### Optional + +- `autocreate_branch` (Boolean) Automatically create the branch if it could not be found. Subsequent reads if the branch is deleted will occur from 'autocreate_branch_source_branch'. +- `autocreate_branch_source_branch` (String) The branch name to start from, if 'autocreate_branch' is set. Defaults to 'main'. +- `autocreate_branch_source_sha` (String) The commit hash to start from, if 'autocreate_branch' is set. Defaults to the tip of 'autocreate_branch_source_branch'. If provided, 'autocreate_branch_source_branch' is ignored. +- `branch` (String) The branch name, defaults to the repository's default branch. +- `commit_author` (String) The commit author name, defaults to the authenticated user's name. GitHub app users may omit author and email information so GitHub can verify commits as the GitHub App. +- `commit_email` (String) The commit author email address, defaults to the authenticated user's email address. GitHub app users may omit author and email information so GitHub can verify commits as the GitHub App. +- `commit_message` (String) The commit message when creating, updating or deleting the file. +- `overwrite_on_create` (Boolean) Enable overwriting existing files, defaults to "false". + +### Read-Only + +- `commit_sha` (String) The SHA of the commit that modified the file. +- `id` (String) The Terraform state ID (repository:file). +- `ref` (String) The name of the commit/branch/tag. +- `sha` (String) The blob SHA of the file. diff --git a/docs/resources/repository_pull_request_auto_merge.md b/docs/resources/repository_pull_request_auto_merge.md new file mode 100644 index 0000000..21f2853 --- /dev/null +++ b/docs/resources/repository_pull_request_auto_merge.md @@ -0,0 +1,118 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "githubx_repository_pull_request_auto_merge Resource - githubx" +subcategory: "" +description: |- + Creates and manages a GitHub pull request with optional auto-merge capabilities. Supports multiple files through branch-based commits. +--- + +# githubx_repository_pull_request_auto_merge (Resource) + +Creates and manages a GitHub pull request with optional auto-merge capabilities. Supports multiple files through branch-based commits. + +## Example Usage + +```terraform +terraform { + required_providers { + githubx = { + source = "tfstack/githubx" + version = "~> 0.1" + } + } +} + +# Configure the provider +provider "githubx" { + owner = "cloudbuildlab" # Optional: set your GitHub username or organization. If not set, will use authenticated user. + # Token can be provided here or via GITHUB_TOKEN environment variable + # token = "your-github-token-here" +} + +# First, create a repository +resource "githubx_repository" "example" { + name = "my-pr-example-repo" + description = "Repository for pull request examples" + visibility = "public" + auto_init = true # Initialize with README to create default branch +} + +# Create a feature branch for the PR +resource "githubx_repository_branch" "feature" { + repository = githubx_repository.example.name + branch = "feature/new-feature" + source_branch = "main" +} + +# Add some files to the feature branch +resource "githubx_repository_file" "feature_file1" { + repository = githubx_repository.example.name + branch = githubx_repository_branch.feature.branch + file = "feature/new-file.md" + content = "# New Feature\n\nThis is a new feature file added via Terraform." +} + +resource "githubx_repository_file" "feature_file2" { + repository = githubx_repository.example.name + branch = githubx_repository_branch.feature.branch + file = "feature/config.json" + content = jsonencode({ + feature = "enabled" + version = "1.0.0" + }) +} + +# Example 1: Basic pull request (no auto-merge) +# resource "githubx_repository_pull_request_auto_merge" "basic_pr" { +# repository = githubx_repository.example.name +# base_ref = "main" +# head_ref = githubx_repository_branch.feature.branch +# title = "Add new feature" +# body = "This PR adds a new feature with multiple files." +# # Note: Without merge_when_ready = true, this PR will be created but not merged +# } + +# Example 2: Pull request with auto-merge (waits for checks and approvals) +# Note: Only one PR can exist per base_ref/head_ref pair, so comment out Example 1 to use this +resource "githubx_repository_pull_request_auto_merge" "auto_merge_pr" { + repository = githubx_repository.example.name + base_ref = "main" + head_ref = githubx_repository_branch.feature.branch + title = "Auto-merge feature PR" + body = "This PR will be automatically merged when ready." + merge_when_ready = true + merge_method = "merge" # Use "merge" as default (or "squash"/"rebase" if allowed by repository settings) + wait_for_checks = true + auto_delete_branch = true +} +``` + + +## Schema + +### Required + +- `base_ref` (String) The base branch name (e.g., 'main', 'develop'). +- `head_ref` (String) The head branch name (e.g., 'feature-branch'). +- `repository` (String) The GitHub repository name. +- `title` (String) The title of the pull request. + +### Optional + +- `auto_delete_branch` (Boolean) Automatically delete the head branch after merge. +- `body` (String) The body/description of the pull request. +- `maintainer_can_modify` (Boolean) Allow maintainers to modify the pull request. +- `merge_method` (String) The merge method to use when auto-merging. Options: 'merge', 'squash', 'rebase'. Defaults to 'merge'. +- `merge_when_ready` (Boolean) Wait for all checks and approvals to pass, then automatically merge. +- `wait_for_checks` (Boolean) Wait for CI checks to pass before merging. Only applies when 'merge_when_ready' is true. + +### Read-Only + +- `base_sha` (String) The SHA of the base branch. +- `head_sha` (String) The SHA of the head branch. +- `id` (String) The Terraform state ID (repository:number). +- `merge_commit_sha` (String) The SHA of the merge commit. +- `merged` (Boolean) Whether the pull request has been merged. +- `merged_at` (String) The timestamp when the pull request was merged. +- `number` (Number) The pull request number. +- `state` (String) The state of the pull request (open, closed, merged). diff --git a/examples/data-sources/githubx_repository/data-source.tf b/examples/data-sources/githubx_repository/data-source.tf new file mode 100644 index 0000000..c73bc87 --- /dev/null +++ b/examples/data-sources/githubx_repository/data-source.tf @@ -0,0 +1,54 @@ +terraform { + required_providers { + githubx = { + source = "tfstack/githubx" + version = "~> 0.1" + } + } +} + +# Example 1: Using full_name +data "githubx_repository" "example_full_name" { + full_name = "cloudbuildlab/.github" +} + +output "repository_full_name" { + value = data.githubx_repository.example_full_name.full_name +} + +output "repository_description" { + value = data.githubx_repository.example_full_name.description +} + +output "repository_default_branch" { + value = data.githubx_repository.example_full_name.default_branch +} + +output "repository_html_url" { + value = data.githubx_repository.example_full_name.html_url +} + +# Example 2: Using name with provider-level owner +provider "githubx" { + owner = "cloudbuildlab" +} + +data "githubx_repository" "example_name" { + name = "actions-markdown-lint" +} + +output "repository_name" { + value = data.githubx_repository.example_name.name +} + +output "repository_visibility" { + value = data.githubx_repository.example_name.visibility +} + +output "repository_primary_language" { + value = data.githubx_repository.example_name.primary_language +} + +output "repository_topics" { + value = data.githubx_repository.example_name.topics +} diff --git a/examples/data-sources/githubx_repository_branch/data-source.tf b/examples/data-sources/githubx_repository_branch/data-source.tf new file mode 100644 index 0000000..414fb0a --- /dev/null +++ b/examples/data-sources/githubx_repository_branch/data-source.tf @@ -0,0 +1,48 @@ +terraform { + required_providers { + githubx = { + source = "tfstack/githubx" + version = "~> 0.1" + } + } +} + +# Example 1: Using full_name +data "githubx_repository_branch" "example_full_name" { + full_name = "cloudbuildlab/.github" + branch = "main" +} + +output "branch_ref" { + value = data.githubx_repository_branch.example_full_name.ref +} + +output "branch_sha" { + value = data.githubx_repository_branch.example_full_name.sha +} + +output "branch_etag" { + value = data.githubx_repository_branch.example_full_name.etag +} + +# Example 2: Using repository with provider-level owner +provider "githubx" { + owner = "cloudbuildlab" +} + +data "githubx_repository_branch" "example_repository" { + repository = "actions-markdown-lint" + branch = "main" +} + +output "branch_id" { + value = data.githubx_repository_branch.example_repository.id +} + +output "branch_ref_from_repo" { + value = data.githubx_repository_branch.example_repository.ref +} + +output "branch_sha_from_repo" { + value = data.githubx_repository_branch.example_repository.sha +} diff --git a/examples/data-sources/githubx_repository_file/data-source.tf b/examples/data-sources/githubx_repository_file/data-source.tf new file mode 100644 index 0000000..b245ce3 --- /dev/null +++ b/examples/data-sources/githubx_repository_file/data-source.tf @@ -0,0 +1,76 @@ +terraform { + required_providers { + githubx = { + source = "tfstack/githubx" + version = "~> 0.1" + } + } +} + +# Example 1: Using full_name to read a file +data "githubx_repository_file" "example_full_name" { + full_name = "cloudbuildlab/.github" + file = "README.md" + branch = "main" +} + +output "file_content" { + value = data.githubx_repository_file.example_full_name.content +} + +output "file_sha" { + value = data.githubx_repository_file.example_full_name.sha +} + +output "file_ref" { + value = data.githubx_repository_file.example_full_name.ref +} + +output "file_commit_sha" { + value = data.githubx_repository_file.example_full_name.commit_sha +} + +output "file_commit_message" { + value = data.githubx_repository_file.example_full_name.commit_message +} + +output "file_commit_author" { + value = data.githubx_repository_file.example_full_name.commit_author +} + +# Example 2: Using repository with provider-level owner +provider "githubx" { + owner = "cloudbuildlab" +} + +data "githubx_repository_file" "example_repository" { + repository = "actions-markdown-lint" + file = ".github/workflows/lint.yml" + branch = "main" +} + +output "file_id" { + value = data.githubx_repository_file.example_repository.id +} + +output "file_content_from_repo" { + value = data.githubx_repository_file.example_repository.content +} + +output "file_sha_from_repo" { + value = data.githubx_repository_file.example_repository.sha +} + +output "file_commit_email" { + value = data.githubx_repository_file.example_repository.commit_email +} + +# Example 3: Reading file from default branch (branch not specified) +data "githubx_repository_file" "example_default_branch" { + full_name = "cloudbuildlab/.github" + file = "CONTRIBUTING.md" +} + +output "file_from_default_branch" { + value = data.githubx_repository_file.example_default_branch.content +} diff --git a/examples/data-sources/githubx_user/data-source.tf b/examples/data-sources/githubx_user/data-source.tf new file mode 100644 index 0000000..ed92d2e --- /dev/null +++ b/examples/data-sources/githubx_user/data-source.tf @@ -0,0 +1,37 @@ +terraform { + required_providers { + githubx = { + source = "registry.terraform.io/tfstack/githubx" + version = "0.1.0" + } + } +} + +provider "githubx" { + # Token can be provided here or via GITHUB_TOKEN environment variable + # token = "your-github-token-here" +} + +data "githubx_user" "octocat" { + username = "octocat" +} + +output "user_id" { + value = data.githubx_user.octocat.user_id +} + +output "user_name" { + value = data.githubx_user.octocat.name +} + +output "user_login" { + value = data.githubx_user.octocat.username +} + +output "user_bio" { + value = data.githubx_user.octocat.bio +} + +output "user_public_repos" { + value = data.githubx_user.octocat.public_repos +} diff --git a/examples/provider/provider.tf b/examples/provider/provider.tf new file mode 100644 index 0000000..b56a3a1 --- /dev/null +++ b/examples/provider/provider.tf @@ -0,0 +1,18 @@ +terraform { + required_providers { + githubx = { + source = "tfstack/githubx" + version = "~> 0.1" + } + } +} + +provider "githubx" {} + +data "githubx_user" "example" { + username = "octocat" +} + +output "user" { + value = data.githubx_user.example +} diff --git a/examples/resources/githubx_repository/resource.tf b/examples/resources/githubx_repository/resource.tf new file mode 100644 index 0000000..e1efd48 --- /dev/null +++ b/examples/resources/githubx_repository/resource.tf @@ -0,0 +1,174 @@ +terraform { + required_providers { + githubx = { + source = "tfstack/githubx" + version = "~> 0.1" + } + } +} + +# Configure the provider with owner +# The owner can be set via environment variable GITHUB_OWNER instead +provider "githubx" { + owner = "cloudbuildlab" # Replace with your GitHub username or organization +} + +# Example 1: Basic public repository +resource "githubx_repository" "basic" { + name = "my-basic-repo" + description = "A basic public repository" + visibility = "public" +} + +output "basic_repository_url" { + value = githubx_repository.basic.html_url +} + +# Example 2: Private repository with features enabled +resource "githubx_repository" "private" { + name = "my-private-repo" + description = "A private repository with features enabled" + visibility = "private" + has_issues = true + has_projects = true + has_wiki = true +} + +output "private_repository_url" { + value = githubx_repository.private.html_url +} + +# Example 3: Repository with merge settings +resource "githubx_repository" "merge_settings" { + name = "my-merge-repo" + description = "Repository with custom merge settings" + visibility = "public" + allow_merge_commit = true + allow_squash_merge = true + allow_rebase_merge = false + allow_auto_merge = true + delete_branch_on_merge = true + squash_merge_commit_title = "PR_TITLE" + squash_merge_commit_message = "PR_BODY" # Valid combination with PR_TITLE + merge_commit_title = "PR_TITLE" + merge_commit_message = "PR_BODY" +} + +output "merge_settings_repository_url" { + value = githubx_repository.merge_settings.html_url +} + +# Example 4: Repository with topics +resource "githubx_repository" "with_topics" { + name = "my-topics-repo" + description = "Repository with topics" + visibility = "public" + topics = ["terraform", "github", "automation", "infrastructure"] +} + +output "topics_repository_url" { + value = githubx_repository.with_topics.html_url +} + +# Example 5: Repository with GitHub Pages +resource "githubx_repository" "with_pages" { + name = "my-pages-repo" + description = "Repository with GitHub Pages enabled" + visibility = "public" + + pages = { + source = { + branch = "main" + path = "/" + } + build_type = "legacy" + } +} + +output "pages_repository_url" { + value = githubx_repository.with_pages.html_url +} + +# Example 6: Template repository +resource "githubx_repository" "template" { + name = "my-template-repo" + description = "A template repository" + visibility = "public" + is_template = true +} + +output "template_repository_url" { + value = githubx_repository.template.html_url +} + +# Example 7: Repository with vulnerability alerts +resource "githubx_repository" "secure" { + name = "my-secure-repo" + description = "Repository with security features" + visibility = "public" + vulnerability_alerts = true +} + +output "secure_repository_url" { + value = githubx_repository.secure.html_url +} + +# # Example 8: Repository that archives on destroy +# # NOTE: When archive_on_destroy = true, the repository is archived (not deleted) when destroyed. +# # If you try to apply this again after destroying, it will fail because the repository already exists. +# # You would need to manually unarchive the repository in GitHub or use a different name. +# resource "githubx_repository" "archivable" { +# name = "my-archivable-repo" +# description = "Repository that archives instead of deleting" +# visibility = "private" +# archive_on_destroy = true +# } + +# output "archivable_repository_url" { +# value = githubx_repository.archivable.html_url +# } + +# Example 9: Complete repository with all features +resource "githubx_repository" "complete" { + name = "my-complete-repo" + description = "A complete repository example with all features" + homepage_url = "https://example.com" + visibility = "public" + has_issues = true + has_discussions = true + has_projects = true + has_downloads = true + has_wiki = true + allow_merge_commit = true + allow_squash_merge = true + allow_rebase_merge = true + allow_auto_merge = true + allow_update_branch = true + delete_branch_on_merge = true + squash_merge_commit_title = "PR_TITLE" + squash_merge_commit_message = "PR_BODY" + merge_commit_title = "PR_TITLE" + merge_commit_message = "PR_BODY" + topics = ["terraform", "github", "example"] + vulnerability_alerts = true + + pages = { + source = { + branch = "main" + path = "/docs" + } + build_type = "workflow" + } +} + +output "complete_repository_url" { + value = githubx_repository.complete.html_url +} + +output "complete_repository_full_name" { + value = githubx_repository.complete.full_name +} + +output "complete_repository_default_branch" { + value = githubx_repository.complete.default_branch +} diff --git a/examples/resources/githubx_repository_branch/resource.tf b/examples/resources/githubx_repository_branch/resource.tf new file mode 100644 index 0000000..03a1bb8 --- /dev/null +++ b/examples/resources/githubx_repository_branch/resource.tf @@ -0,0 +1,64 @@ +terraform { + required_providers { + githubx = { + source = "tfstack/githubx" + version = "~> 0.1" + } + } +} + +# Configure the provider +# The owner can be set via provider config, environment variable GITHUB_OWNER, or will default to authenticated user +provider "githubx" { + # owner = "cloudbuildlab" # Optional: set your GitHub username or organization. If not set, will use authenticated user. + # Token can be provided here or via GITHUB_TOKEN environment variable + # token = "your-github-token-here" +} + +# First, create a repository +resource "githubx_repository" "example" { + name = "my-branch-example-repo" + description = "Repository for branch examples" + visibility = "public" + auto_init = true # Initialize with README to create default branch +} + +# Example 1: Create a branch from default source (main) +resource "githubx_repository_branch" "develop" { + repository = githubx_repository.example.name + branch = "develop" +} + +output "develop_branch_ref" { + value = githubx_repository_branch.develop.ref +} + +output "develop_branch_sha" { + value = githubx_repository_branch.develop.sha +} + +# Example 2: Create a branch from a specific source branch +# Note: This depends on the develop branch being created first +resource "githubx_repository_branch" "feature" { + repository = githubx_repository.example.name + branch = "feature/new-feature" + source_branch = "develop" + depends_on = [githubx_repository_branch.develop] +} + +output "feature_branch_ref" { + value = githubx_repository_branch.feature.ref +} + +# Example 3: Create a branch from a specific commit SHA +# Note: Replace the SHA with an actual commit SHA from your repository +resource "githubx_repository_branch" "hotfix" { + repository = githubx_repository.example.name + branch = "hotfix/critical-fix" + # source_sha = "abc123def456..." # Uncomment and replace with actual commit SHA + depends_on = [githubx_repository.example] +} + +output "hotfix_branch_sha" { + value = githubx_repository_branch.hotfix.sha +} diff --git a/examples/resources/githubx_repository_file/resource.tf b/examples/resources/githubx_repository_file/resource.tf new file mode 100644 index 0000000..653c9f7 --- /dev/null +++ b/examples/resources/githubx_repository_file/resource.tf @@ -0,0 +1,114 @@ +terraform { + required_providers { + githubx = { + source = "tfstack/githubx" + version = "~> 0.1" + } + } +} + +# Configure the provider +provider "githubx" { + owner = "cloudbuildlab" # Optional: set your GitHub username or organization. If not set, will use authenticated user. + # Token can be provided here or via GITHUB_TOKEN environment variable + # token = "your-github-token-here" +} + +# First, create a repository +resource "githubx_repository" "example" { + name = "my-file-example-repo" + description = "Repository for file examples" + visibility = "public" + auto_init = true # Initialize with README to create default branch +} + +# Example 1: Create a simple file on the default branch +# Note: The repository is auto-initialized with a README.md, so we'll create a different file +resource "githubx_repository_file" "docs" { + repository = githubx_repository.example.name + file = "docs/GETTING_STARTED.md" + content = "# Getting Started\n\nThis is a getting started guide managed by Terraform." +} + +output "docs_commit_sha" { + value = githubx_repository_file.docs.commit_sha +} + +output "docs_sha" { + value = githubx_repository_file.docs.sha +} + +# Example 2: Create a file on a specific branch +resource "githubx_repository_branch" "develop" { + repository = githubx_repository.example.name + branch = "develop" + depends_on = [githubx_repository.example] +} + +resource "githubx_repository_file" "config" { + repository = githubx_repository.example.name + file = ".github/config.yml" + branch = githubx_repository_branch.develop.branch + content = "repository:\n name: ${githubx_repository.example.name}\n description: ${githubx_repository.example.description}\n" + depends_on = [githubx_repository_branch.develop] +} + +output "config_file_ref" { + value = githubx_repository_file.config.ref +} + +# Example 3: Create a file with custom commit message and author +resource "githubx_repository_file" "license" { + repository = githubx_repository.example.name + file = "LICENSE" + content = "MIT License\n\nCopyright (c) 2024\n" + commit_message = "Add MIT license file" + commit_author = "Terraform Bot" + commit_email = "terraform@example.com" +} + +output "license_commit_message" { + value = githubx_repository_file.license.commit_message +} + +# Example 4: Create a file with auto-create branch feature +resource "githubx_repository_file" "feature_file" { + repository = githubx_repository.example.name + file = "feature/new-feature.md" + branch = "feature/new-feature" + content = "# New Feature\n\nThis is a new feature file." + autocreate_branch = true + autocreate_branch_source_branch = "main" + depends_on = [githubx_repository.example] +} + +output "feature_file_id" { + value = githubx_repository_file.feature_file.id +} + +# Example 5: Overwrite existing file (created outside Terraform or by auto-init) +# Note: The repository auto_init creates a README.md, so we can overwrite it +# This demonstrates overwrite_on_create for files that exist but aren't managed by Terraform +resource "githubx_repository_file" "readme" { + repository = githubx_repository.example.name + file = "README.md" + content = "# My Example Repository\n\nThis is an updated README managed by Terraform.\n\n## Features\n\n- Feature 1\n- Feature 2\n" + overwrite_on_create = true + depends_on = [githubx_repository.example] +} + +output "readme_sha" { + value = githubx_repository_file.readme.sha +} + +# Example 6: Update an existing file (modify the same resource) +# This shows how to update a file by changing the content in the same resource +resource "githubx_repository_file" "changelog" { + repository = githubx_repository.example.name + file = "CHANGELOG.md" + content = "# Changelog\n\n## [Unreleased]\n\n### Added\n- Initial version\n" +} + +output "changelog_commit_sha" { + value = githubx_repository_file.changelog.commit_sha +} diff --git a/examples/resources/githubx_repository_pull_request_auto_merge/resource.tf b/examples/resources/githubx_repository_pull_request_auto_merge/resource.tf new file mode 100644 index 0000000..9a2d0b1 --- /dev/null +++ b/examples/resources/githubx_repository_pull_request_auto_merge/resource.tf @@ -0,0 +1,72 @@ +terraform { + required_providers { + githubx = { + source = "tfstack/githubx" + version = "~> 0.1" + } + } +} + +# Configure the provider +provider "githubx" { + owner = "cloudbuildlab" # Optional: set your GitHub username or organization. If not set, will use authenticated user. + # Token can be provided here or via GITHUB_TOKEN environment variable + # token = "your-github-token-here" +} + +# First, create a repository +resource "githubx_repository" "example" { + name = "my-pr-example-repo" + description = "Repository for pull request examples" + visibility = "public" + auto_init = true # Initialize with README to create default branch +} + +# Create a feature branch for the PR +resource "githubx_repository_branch" "feature" { + repository = githubx_repository.example.name + branch = "feature/new-feature" + source_branch = "main" +} + +# Add some files to the feature branch +resource "githubx_repository_file" "feature_file1" { + repository = githubx_repository.example.name + branch = githubx_repository_branch.feature.branch + file = "feature/new-file.md" + content = "# New Feature\n\nThis is a new feature file added via Terraform." +} + +resource "githubx_repository_file" "feature_file2" { + repository = githubx_repository.example.name + branch = githubx_repository_branch.feature.branch + file = "feature/config.json" + content = jsonencode({ + feature = "enabled" + version = "1.0.0" + }) +} + +# Example 1: Basic pull request (no auto-merge) +# resource "githubx_repository_pull_request_auto_merge" "basic_pr" { +# repository = githubx_repository.example.name +# base_ref = "main" +# head_ref = githubx_repository_branch.feature.branch +# title = "Add new feature" +# body = "This PR adds a new feature with multiple files." +# # Note: Without merge_when_ready = true, this PR will be created but not merged +# } + +# Example 2: Pull request with auto-merge (waits for checks and approvals) +# Note: Only one PR can exist per base_ref/head_ref pair, so comment out Example 1 to use this +resource "githubx_repository_pull_request_auto_merge" "auto_merge_pr" { + repository = githubx_repository.example.name + base_ref = "main" + head_ref = githubx_repository_branch.feature.branch + title = "Auto-merge feature PR" + body = "This PR will be automatically merged when ready." + merge_when_ready = true + merge_method = "merge" # Use "merge" as default (or "squash"/"rebase" if allowed by repository settings) + wait_for_checks = true + auto_delete_branch = true +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..cf8131d --- /dev/null +++ b/go.mod @@ -0,0 +1,82 @@ +module github.com/tfstack/terraform-provider-githubx + +go 1.24.0 + +toolchain go1.24.6 + +require ( + github.com/golang-jwt/jwt/v5 v5.3.0 + github.com/google/go-github/v60 v60.0.0 + github.com/hashicorp/terraform-plugin-docs v0.24.0 + github.com/hashicorp/terraform-plugin-framework v1.16.1 + github.com/hashicorp/terraform-plugin-framework-validators v0.19.0 + github.com/stretchr/testify v1.10.0 + golang.org/x/oauth2 v0.34.0 +) + +require ( + github.com/BurntSushi/toml v1.2.1 // indirect + github.com/Kunde21/markdownfmt/v3 v3.1.0 // indirect + github.com/Masterminds/goutils v1.1.1 // indirect + github.com/Masterminds/semver/v3 v3.2.0 // indirect + github.com/Masterminds/sprig/v3 v3.2.3 // indirect + github.com/ProtonMail/go-crypto v1.1.6 // indirect + github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect + github.com/armon/go-radix v1.0.0 // indirect + github.com/bgentry/speakeasy v0.1.0 // indirect + github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect + github.com/cloudflare/circl v1.6.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/fatih/color v1.16.0 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/cli v1.1.7 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-checkpoint v0.5.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-hclog v1.6.3 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-plugin v1.7.0 // indirect + github.com/hashicorp/go-retryablehttp v0.7.7 // indirect + github.com/hashicorp/go-uuid v1.0.3 // indirect + github.com/hashicorp/go-version v1.7.0 // indirect + github.com/hashicorp/hc-install v0.9.2 // indirect + github.com/hashicorp/terraform-exec v0.24.0 // indirect + github.com/hashicorp/terraform-json v0.27.2 // indirect + github.com/hashicorp/terraform-plugin-go v0.29.0 // indirect + github.com/hashicorp/terraform-plugin-log v0.9.0 // indirect + github.com/hashicorp/terraform-registry-address v0.4.0 // indirect + github.com/hashicorp/terraform-svchost v0.1.1 // indirect + github.com/hashicorp/yamux v0.1.2 // indirect + github.com/huandu/xstrings v1.3.3 // indirect + github.com/imdario/mergo v0.3.15 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.9 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/go-testing-interface v1.14.1 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/oklog/run v1.1.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/posener/complete v1.2.3 // indirect + github.com/shopspring/decimal v1.3.1 // indirect + github.com/spf13/cast v1.5.0 // indirect + github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect + github.com/yuin/goldmark v1.7.7 // indirect + github.com/yuin/goldmark-meta v1.1.0 // indirect + github.com/zclconf/go-cty v1.17.0 // indirect + go.abhg.dev/goldmark/frontmatter v0.2.0 // indirect + golang.org/x/crypto v0.41.0 // indirect + golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df // indirect + golang.org/x/mod v0.28.0 // indirect + golang.org/x/net v0.43.0 // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.30.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 // indirect + google.golang.org/grpc v1.75.1 // indirect + google.golang.org/protobuf v1.36.9 // indirect + gopkg.in/yaml.v2 v2.3.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9de8a2f --- /dev/null +++ b/go.sum @@ -0,0 +1,268 @@ +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak= +github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/Kunde21/markdownfmt/v3 v3.1.0 h1:KiZu9LKs+wFFBQKhrZJrFZwtLnCCWJahL+S+E/3VnM0= +github.com/Kunde21/markdownfmt/v3 v3.1.0/go.mod h1:tPXN1RTyOzJwhfHoon9wUr4HGYmWgVxSQN6VBJDkrVc= +github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= +github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= +github.com/Masterminds/semver/v3 v3.2.0 h1:3MEsd0SM6jqZojhjLWWeBY+Kcjy9i6MQAeY7YgDP83g= +github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= +github.com/Masterminds/sprig/v3 v3.2.3 h1:eL2fZNezLomi0uOLqjQoN6BfsDD+fyLtgbJMAj9n6YA= +github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBaRMhvYXJNkGuM= +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.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= +github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +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-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI= +github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/bgentry/speakeasy v0.1.0 h1:ByYyxL9InA1OWqxJqqp2A5pYHUrCiAL6K3J+LKSsQkY= +github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE= +github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= +github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= +github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= +github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= +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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE= +github.com/frankban/quicktest v1.14.3/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps= +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/v5 v5.14.0 h1:/MD3lCrGjCen5WfEAzKg00MJJffKhC8gzS80ycmCi60= +github.com/go-git/go-git/v5 v5.14.0/go.mod h1:Z5Xhoia5PcWA3NF8vRLURn9E5FRhSl7dGj9ItW3Wk5k= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +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/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-github/v60 v60.0.0 h1:oLG98PsLauFvvu4D/YPxq374jhSxFYdzQGNCyONLfn8= +github.com/google/go-github/v60 v60.0.0/go.mod h1:ByhX2dP9XT9o/ll2yXAu2VD8l5eNVg8hD4Cr0S/LmQk= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/cli v1.1.7 h1:/fZJ+hNdwfTSfsxMBa9WWMlfjUZbX8/LnUxgAd7lCVU= +github.com/hashicorp/cli v1.1.7/go.mod h1:e6Mfpga9OCT1vqzFuoGZiiF/KaG9CbUfO5s3ghU3YgU= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-checkpoint v0.5.0 h1:MFYpPZCnQqQTE18jFwSII6eUQrD/oxMFp3mlgcqk5mU= +github.com/hashicorp/go-checkpoint v0.5.0/go.mod h1:7nfLNL10NsxqO4iWuW6tWW0HjZuDrwkBuEQsVcpCOgg= +github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-plugin v1.7.0 h1:YghfQH/0QmPNc/AZMTFE3ac8fipZyZECHdDPshfk+mA= +github.com/hashicorp/go-plugin v1.7.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8= +github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= +github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= +github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/hc-install v0.9.2 h1:v80EtNX4fCVHqzL9Lg/2xkp62bbvQMnvPQ0G+OmtO24= +github.com/hashicorp/hc-install v0.9.2/go.mod h1:XUqBQNnuT4RsxoxiM9ZaUk0NX8hi2h+Lb6/c0OZnC/I= +github.com/hashicorp/terraform-exec v0.24.0 h1:mL0xlk9H5g2bn0pPF6JQZk5YlByqSqrO5VoaNtAf8OE= +github.com/hashicorp/terraform-exec v0.24.0/go.mod h1:lluc/rDYfAhYdslLJQg3J0oDqo88oGQAdHR+wDqFvo4= +github.com/hashicorp/terraform-json v0.27.2 h1:BwGuzM6iUPqf9JYM/Z4AF1OJ5VVJEEzoKST/tRDBJKU= +github.com/hashicorp/terraform-json v0.27.2/go.mod h1:GzPLJ1PLdUG5xL6xn1OXWIjteQRT2CNT9o/6A9mi9hE= +github.com/hashicorp/terraform-plugin-docs v0.24.0 h1:YNZYd+8cpYclQyXbl1EEngbld8w7/LPOm99GD5nikIU= +github.com/hashicorp/terraform-plugin-docs v0.24.0/go.mod h1:YLg+7LEwVmRuJc0EuCw0SPLxuQXw5mW8iJ5ml/kvi+o= +github.com/hashicorp/terraform-plugin-framework v1.16.1 h1:1+zwFm3MEqd/0K3YBB2v9u9DtyYHyEuhVOfeIXbteWA= +github.com/hashicorp/terraform-plugin-framework v1.16.1/go.mod h1:0xFOxLy5lRzDTayc4dzK/FakIgBhNf/lC4499R9cV4Y= +github.com/hashicorp/terraform-plugin-framework-validators v0.19.0 h1:Zz3iGgzxe/1XBkooZCewS0nJAaCFPFPHdNJd8FgE4Ow= +github.com/hashicorp/terraform-plugin-framework-validators v0.19.0/go.mod h1:GBKTNGbGVJohU03dZ7U8wHqc2zYnMUawgCN+gC0itLc= +github.com/hashicorp/terraform-plugin-go v0.29.0 h1:1nXKl/nSpaYIUBU1IG/EsDOX0vv+9JxAltQyDMpq5mU= +github.com/hashicorp/terraform-plugin-go v0.29.0/go.mod h1:vYZbIyvxyy0FWSmDHChCqKvI40cFTDGSb3D8D70i9GM= +github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0= +github.com/hashicorp/terraform-plugin-log v0.9.0/go.mod h1:rKL8egZQ/eXSyDqzLUuwUYLVdlYeamldAHSxjUFADow= +github.com/hashicorp/terraform-registry-address v0.4.0 h1:S1yCGomj30Sao4l5BMPjTGZmCNzuv7/GDTDX99E9gTk= +github.com/hashicorp/terraform-registry-address v0.4.0/go.mod h1:LRS1Ay0+mAiRkUyltGT+UHWkIqTFvigGn/LbMshfflE= +github.com/hashicorp/terraform-svchost v0.1.1 h1:EZZimZ1GxdqFRinZ1tpJwVxxt49xc/S52uzrw4x0jKQ= +github.com/hashicorp/terraform-svchost v0.1.1/go.mod h1:mNsjQfZyf/Jhz35v6/0LWcv26+X7JPS+buii2c9/ctc= +github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= +github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= +github.com/huandu/xstrings v1.3.3 h1:/Gcsuc1x8JVbJ9/rlye4xZnVAbEkGauT8lbebqcQws4= +github.com/huandu/xstrings v1.3.3/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= +github.com/imdario/mergo v0.3.15 h1:M8XP7IuFNsqUx6VPK2P9OSmsYsI/YFaGil0uD21V3dM= +github.com/imdario/mergo v0.3.15/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= +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/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94= +github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8= +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.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +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.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +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.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= +github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= +github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= +github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= +github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= +github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/posener/complete v1.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXqo= +github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= +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/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/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= +github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= +github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= +github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w= +github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +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/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= +github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +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/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark v1.7.7 h1:5m9rrB1sW3JUMToKFQfb+FGt1U7r57IHu5GrYrG2nqU= +github.com/yuin/goldmark v1.7.7/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +github.com/yuin/goldmark-meta v1.1.0 h1:pWw+JLHGZe8Rk0EGsMVssiNb/AaPMHfSRszZeUeiOUc= +github.com/yuin/goldmark-meta v1.1.0/go.mod h1:U4spWENafuA7Zyg+Lj5RqK/MF+ovMYtBvXi1lBb2VP0= +github.com/zclconf/go-cty v1.17.0 h1:seZvECve6XX4tmnvRzWtJNHdscMtYEx5R7bnnVyd/d0= +github.com/zclconf/go-cty v1.17.0/go.mod h1:wqFzcImaLTI6A5HfsRwB0nj5n0MRZFwmey8YoFPPs3U= +go.abhg.dev/goldmark/frontmatter v0.2.0 h1:P8kPG0YkL12+aYk2yU3xHv4tcXzeVnN+gU0tJ5JnxRw= +go.abhg.dev/goldmark/frontmatter v0.2.0/go.mod h1:XqrEkZuM57djk7zrlRUB02x8I5J0px76YjkOzhB4YlU= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= +go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= +go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= +go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df h1:UA2aFVmmsIlefxMk29Dp2juaUSth8Pyn3Tq5Y5mJGME= +golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= +golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/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-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= +golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 h1:pFyd6EwwL2TqFf8emdthzeX+gZE1ElRq3iM8pui4KBY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI= +google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= +google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= +google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +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.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/index.md b/index.md new file mode 100644 index 0000000..5c115f7 --- /dev/null +++ b/index.md @@ -0,0 +1,32 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "githubx Provider" +description: |- + The GitHubx provider is a supplemental Terraform provider offering extended GitHub capabilities alongside the official provider. +--- + +# githubx Provider + +The GitHubx provider is a supplemental Terraform provider offering extended GitHub capabilities alongside the official provider. + +## Example Usage + +```terraform +terraform { + required_providers { + githubx = { + source = "tfstack/githubx" + version = "~> 0.1" + } + } +} + +provider "githubx" {} +``` + + +## Schema + +### Optional + +- `token` (String, Sensitive) GitHub personal access token for authentication. This token is required to authenticate with the GitHub API. You can obtain a token from GitHub Settings > Developer settings > Personal access tokens. Alternatively, you can set the GITHUB_TOKEN environment variable instead of providing it here. This attribute is sensitive and will not be displayed in logs or output. diff --git a/internal/provider/data_source_repository.go b/internal/provider/data_source_repository.go new file mode 100644 index 0000000..3322e05 --- /dev/null +++ b/internal/provider/data_source_repository.go @@ -0,0 +1,751 @@ +package provider + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/google/go-github/v60/github" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ datasource.DataSource = &repositoryDataSource{} + _ datasource.DataSourceWithConfigure = &repositoryDataSource{} +) + +// NewRepositoryDataSource is a helper function to simplify the provider implementation. +func NewRepositoryDataSource() datasource.DataSource { + return &repositoryDataSource{} +} + +// repositoryDataSource is the data source implementation. +type repositoryDataSource struct { + client *github.Client + owner string +} + +// repositoryDataSourceModel maps the data source schema data. +type repositoryDataSourceModel struct { + FullName types.String `tfsdk:"full_name"` + Name types.String `tfsdk:"name"` + Description types.String `tfsdk:"description"` + HomepageURL types.String `tfsdk:"homepage_url"` + Private types.Bool `tfsdk:"private"` + Visibility types.String `tfsdk:"visibility"` + HasIssues types.Bool `tfsdk:"has_issues"` + HasDiscussions types.Bool `tfsdk:"has_discussions"` + HasProjects types.Bool `tfsdk:"has_projects"` + HasDownloads types.Bool `tfsdk:"has_downloads"` + HasWiki types.Bool `tfsdk:"has_wiki"` + IsTemplate types.Bool `tfsdk:"is_template"` + Fork types.Bool `tfsdk:"fork"` + AllowMergeCommit types.Bool `tfsdk:"allow_merge_commit"` + AllowSquashMerge types.Bool `tfsdk:"allow_squash_merge"` + AllowRebaseMerge types.Bool `tfsdk:"allow_rebase_merge"` + AllowAutoMerge types.Bool `tfsdk:"allow_auto_merge"` + AllowUpdateBranch types.Bool `tfsdk:"allow_update_branch"` + SquashMergeCommitTitle types.String `tfsdk:"squash_merge_commit_title"` + SquashMergeCommitMessage types.String `tfsdk:"squash_merge_commit_message"` + MergeCommitTitle types.String `tfsdk:"merge_commit_title"` + MergeCommitMessage types.String `tfsdk:"merge_commit_message"` + DefaultBranch types.String `tfsdk:"default_branch"` + PrimaryLanguage types.String `tfsdk:"primary_language"` + Archived types.Bool `tfsdk:"archived"` + RepositoryLicense types.Object `tfsdk:"repository_license"` + Pages types.Object `tfsdk:"pages"` + Topics types.List `tfsdk:"topics"` + HTMLURL types.String `tfsdk:"html_url"` + SSHCloneURL types.String `tfsdk:"ssh_clone_url"` + SVNURL types.String `tfsdk:"svn_url"` + GitCloneURL types.String `tfsdk:"git_clone_url"` + HTTPCloneURL types.String `tfsdk:"http_clone_url"` + Template types.Object `tfsdk:"template"` + NodeID types.String `tfsdk:"node_id"` + RepoID types.Int64 `tfsdk:"repo_id"` + DeleteBranchOnMerge types.Bool `tfsdk:"delete_branch_on_merge"` + ID types.String `tfsdk:"id"` +} + +// pagesModel represents GitHub Pages configuration. +type pagesModel struct { + Source types.Object `tfsdk:"source"` + BuildType types.String `tfsdk:"build_type"` + CNAME types.String `tfsdk:"cname"` + Custom404 types.Bool `tfsdk:"custom_404"` + HTMLURL types.String `tfsdk:"html_url"` + Status types.String `tfsdk:"status"` + URL types.String `tfsdk:"url"` +} + +// pagesSourceModel represents the Pages source configuration. +type pagesSourceModel struct { + Branch types.String `tfsdk:"branch"` + Path types.String `tfsdk:"path"` +} + +// Metadata returns the data source type name. +func (d *repositoryDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_repository" +} + +// Schema defines the schema for the data source. +func (d *repositoryDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Get information on a GitHub repository.", + Attributes: map[string]schema.Attribute{ + "full_name": schema.StringAttribute{ + Description: "The full name of the repository (owner/repo). Conflicts with `name`.", + Optional: true, + Computed: true, + }, + "name": schema.StringAttribute{ + Description: "The name of the repository. Conflicts with `full_name`. If `name` is provided, the provider-level `owner` configuration will be used.", + Optional: true, + Computed: true, + }, + "description": schema.StringAttribute{ + Description: "The description of the repository.", + Computed: true, + }, + "homepage_url": schema.StringAttribute{ + Description: "The homepage URL of the repository.", + Computed: true, + }, + "private": schema.BoolAttribute{ + Description: "Whether the repository is private.", + Computed: true, + }, + "visibility": schema.StringAttribute{ + Description: "The visibility of the repository (public, private, or internal).", + Computed: true, + }, + "has_issues": schema.BoolAttribute{ + Description: "Whether the repository has issues enabled.", + Computed: true, + }, + "has_discussions": schema.BoolAttribute{ + Description: "Whether the repository has discussions enabled.", + Computed: true, + }, + "has_projects": schema.BoolAttribute{ + Description: "Whether the repository has projects enabled.", + Computed: true, + }, + "has_downloads": schema.BoolAttribute{ + Description: "Whether the repository has downloads enabled.", + Computed: true, + }, + "has_wiki": schema.BoolAttribute{ + Description: "Whether the repository has wiki enabled.", + Computed: true, + }, + "is_template": schema.BoolAttribute{ + Description: "Whether the repository is a template.", + Computed: true, + }, + "fork": schema.BoolAttribute{ + Description: "Whether the repository is a fork.", + Computed: true, + }, + "allow_merge_commit": schema.BoolAttribute{ + Description: "Whether merge commits are allowed.", + Computed: true, + }, + "allow_squash_merge": schema.BoolAttribute{ + Description: "Whether squash merges are allowed.", + Computed: true, + }, + "allow_rebase_merge": schema.BoolAttribute{ + Description: "Whether rebase merges are allowed.", + Computed: true, + }, + "allow_auto_merge": schema.BoolAttribute{ + Description: "Whether auto-merge is enabled.", + Computed: true, + }, + "allow_update_branch": schema.BoolAttribute{ + Description: "Whether branch updates are allowed.", + Computed: true, + }, + "squash_merge_commit_title": schema.StringAttribute{ + Description: "The default commit title for squash merges.", + Computed: true, + }, + "squash_merge_commit_message": schema.StringAttribute{ + Description: "The default commit message for squash merges.", + Computed: true, + }, + "merge_commit_title": schema.StringAttribute{ + Description: "The default commit title for merge commits.", + Computed: true, + }, + "merge_commit_message": schema.StringAttribute{ + Description: "The default commit message for merge commits.", + Computed: true, + }, + "default_branch": schema.StringAttribute{ + Description: "The default branch of the repository.", + Computed: true, + }, + "primary_language": schema.StringAttribute{ + Description: "The primary programming language of the repository.", + Computed: true, + }, + "archived": schema.BoolAttribute{ + Description: "Whether the repository is archived.", + Computed: true, + }, + "repository_license": schema.SingleNestedAttribute{ + Description: "The license information for the repository.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Computed: true, + }, + "path": schema.StringAttribute{ + Computed: true, + }, + "license": schema.SingleNestedAttribute{ + Computed: true, + Attributes: map[string]schema.Attribute{ + "key": schema.StringAttribute{Computed: true}, + "name": schema.StringAttribute{Computed: true}, + "url": schema.StringAttribute{Computed: true}, + "spdx_id": schema.StringAttribute{Computed: true}, + "html_url": schema.StringAttribute{Computed: true}, + "featured": schema.BoolAttribute{Computed: true}, + "description": schema.StringAttribute{Computed: true}, + "implementation": schema.StringAttribute{Computed: true}, + "permissions": schema.ListAttribute{ElementType: types.StringType, Computed: true}, + "conditions": schema.ListAttribute{ElementType: types.StringType, Computed: true}, + "limitations": schema.ListAttribute{ElementType: types.StringType, Computed: true}, + "body": schema.StringAttribute{Computed: true}, + }, + }, + "sha": schema.StringAttribute{Computed: true}, + "size": schema.Int64Attribute{Computed: true}, + "url": schema.StringAttribute{Computed: true}, + "html_url": schema.StringAttribute{Computed: true}, + "git_url": schema.StringAttribute{Computed: true}, + "download_url": schema.StringAttribute{Computed: true}, + "type": schema.StringAttribute{Computed: true}, + "content": schema.StringAttribute{Computed: true}, + "encoding": schema.StringAttribute{Computed: true}, + }, + }, + "pages": schema.SingleNestedAttribute{ + Description: "The GitHub Pages configuration for the repository.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "source": schema.SingleNestedAttribute{ + Computed: true, + Attributes: map[string]schema.Attribute{ + "branch": schema.StringAttribute{Computed: true}, + "path": schema.StringAttribute{Computed: true}, + }, + }, + "build_type": schema.StringAttribute{Computed: true}, + "cname": schema.StringAttribute{Computed: true}, + "custom_404": schema.BoolAttribute{Computed: true}, + "html_url": schema.StringAttribute{Computed: true}, + "status": schema.StringAttribute{Computed: true}, + "url": schema.StringAttribute{Computed: true}, + }, + }, + "topics": schema.ListAttribute{ + Description: "The topics (tags) associated with the repository.", + ElementType: types.StringType, + Computed: true, + }, + "html_url": schema.StringAttribute{ + Description: "The HTML URL of the repository.", + Computed: true, + }, + "ssh_clone_url": schema.StringAttribute{ + Description: "The SSH clone URL of the repository.", + Computed: true, + }, + "svn_url": schema.StringAttribute{ + Description: "The SVN URL of the repository.", + Computed: true, + }, + "git_clone_url": schema.StringAttribute{ + Description: "The Git clone URL of the repository.", + Computed: true, + }, + "http_clone_url": schema.StringAttribute{ + Description: "The HTTP clone URL of the repository.", + Computed: true, + }, + "template": schema.SingleNestedAttribute{ + Description: "The template repository information, if this repository was created from a template.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "owner": schema.StringAttribute{Computed: true}, + "repository": schema.StringAttribute{Computed: true}, + }, + }, + "node_id": schema.StringAttribute{ + Description: "The GitHub node ID of the repository.", + Computed: true, + }, + "repo_id": schema.Int64Attribute{ + Description: "The GitHub repository ID as an integer.", + Computed: true, + }, + "delete_branch_on_merge": schema.BoolAttribute{ + Description: "Whether to delete branches after merging pull requests.", + Computed: true, + }, + "id": schema.StringAttribute{ + Description: "The Terraform state ID (repository name).", + Computed: true, + }, + }, + } +} + +// Configure enables provider-level data or clients to be set in the +// provider-defined data source type. It is separately executed for each +// ReadDataSource RPC. +func (d *repositoryDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + clientData, ok := req.ProviderData.(githubxClientData) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Data Source Configure Type", + fmt.Sprintf("Expected githubxClientData, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + return + } + + d.client = clientData.Client + d.owner = clientData.Owner +} + +// getOwner gets the owner, falling back to authenticated user if not set. +func (d *repositoryDataSource) getOwner(ctx context.Context) (string, error) { + if d.owner != "" { + return d.owner, nil + } + // Try to get authenticated user + user, _, err := d.client.Users.Get(ctx, "") + if err != nil { + return "", fmt.Errorf("unable to determine owner: provider-level `owner` is not set and unable to fetch authenticated user: %v", err) + } + if user == nil || user.Login == nil { + return "", fmt.Errorf("unable to determine owner: provider-level `owner` is not set and authenticated user information is unavailable") + } + return user.GetLogin(), nil +} + +// Read refreshes the Terraform state with the latest data. +func (d *repositoryDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var data repositoryDataSourceModel + + // Read Terraform configuration data into the model + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + if d.client == nil { + resp.Diagnostics.AddError( + "Client Error", + "GitHub client is not configured. Please ensure a token is provided.", + ) + return + } + + // Determine owner and repo name + var owner, repoName string + fullName := data.FullName.ValueString() + name := data.Name.ValueString() + + // Check for conflicts + if fullName != "" && name != "" { + resp.Diagnostics.AddError( + "Conflicting Attributes", + "Cannot specify both `full_name` and `name`. Please use only one.", + ) + return + } + + // Parse full_name or use name with owner + if fullName != "" { + var err error + owner, repoName, err = splitRepoFullName(fullName) + if err != nil { + resp.Diagnostics.AddError( + "Invalid Full Name", + fmt.Sprintf("Unable to parse full_name: %v", err), + ) + return + } + } else if name != "" { + repoName = name + var err error + owner, err = d.getOwner(ctx) + if err != nil { + resp.Diagnostics.AddError( + "Missing Owner", + fmt.Sprintf("Either `full_name` must be provided, or `name` must be provided along with provider-level `owner` configuration or authentication. Error: %v", err), + ) + return + } + } else { + resp.Diagnostics.AddError( + "Missing Required Attribute", + "Either `full_name` or `name` must be provided.", + ) + return + } + + // Fetch the repository from GitHub + repo, ghResp, err := d.client.Repositories.Get(ctx, owner, repoName) + if err != nil { + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) { + // Check both ghResp and ghErr.Response for 404 status + if (ghResp != nil && ghResp.StatusCode == http.StatusNotFound) || + (ghErr.Response != nil && ghErr.Response.StatusCode == http.StatusNotFound) { + resp.Diagnostics.AddWarning( + "Repository Not Found", + fmt.Sprintf("Repository %s/%s not found. Setting empty state.", owner, repoName), + ) + data.ID = types.StringValue("") + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) + return + } + } + resp.Diagnostics.AddError( + "Error fetching GitHub repository", + fmt.Sprintf("Unable to fetch repository %s/%s: %v", owner, repoName, err), + ) + return + } + + // Map basic fields + data.ID = types.StringValue(repo.GetName()) + data.Name = types.StringValue(repo.GetName()) + data.FullName = types.StringValue(repo.GetFullName()) + data.Description = types.StringValue(repo.GetDescription()) + data.HomepageURL = types.StringValue(repo.GetHomepage()) + data.Private = types.BoolValue(repo.GetPrivate()) + data.Visibility = types.StringValue(repo.GetVisibility()) + data.HasIssues = types.BoolValue(repo.GetHasIssues()) + data.HasDiscussions = types.BoolValue(repo.GetHasDiscussions()) + data.HasProjects = types.BoolValue(repo.GetHasProjects()) + data.HasDownloads = types.BoolValue(repo.GetHasDownloads()) + data.HasWiki = types.BoolValue(repo.GetHasWiki()) + data.IsTemplate = types.BoolValue(repo.GetIsTemplate()) + data.Fork = types.BoolValue(repo.GetFork()) + data.AllowMergeCommit = types.BoolValue(repo.GetAllowMergeCommit()) + data.AllowSquashMerge = types.BoolValue(repo.GetAllowSquashMerge()) + data.AllowRebaseMerge = types.BoolValue(repo.GetAllowRebaseMerge()) + data.AllowAutoMerge = types.BoolValue(repo.GetAllowAutoMerge()) + data.AllowUpdateBranch = types.BoolValue(repo.GetAllowUpdateBranch()) + data.SquashMergeCommitTitle = types.StringValue(repo.GetSquashMergeCommitTitle()) + data.SquashMergeCommitMessage = types.StringValue(repo.GetSquashMergeCommitMessage()) + data.MergeCommitTitle = types.StringValue(repo.GetMergeCommitTitle()) + data.MergeCommitMessage = types.StringValue(repo.GetMergeCommitMessage()) + data.DefaultBranch = types.StringValue(repo.GetDefaultBranch()) + data.PrimaryLanguage = types.StringValue(repo.GetLanguage()) + data.Archived = types.BoolValue(repo.GetArchived()) + data.HTMLURL = types.StringValue(repo.GetHTMLURL()) + data.SSHCloneURL = types.StringValue(repo.GetSSHURL()) + data.SVNURL = types.StringValue(repo.GetSVNURL()) + data.GitCloneURL = types.StringValue(repo.GetGitURL()) + data.HTTPCloneURL = types.StringValue(repo.GetCloneURL()) + data.NodeID = types.StringValue(repo.GetNodeID()) + data.RepoID = types.Int64Value(repo.GetID()) + data.DeleteBranchOnMerge = types.BoolValue(repo.GetDeleteBranchOnMerge()) + + // Handle topics + if repo.Topics != nil { + topics := make([]types.String, len(repo.Topics)) + for i, topic := range repo.Topics { + topics[i] = types.StringValue(topic) + } + topicsList, diags := types.ListValueFrom(ctx, types.StringType, topics) + resp.Diagnostics.Append(diags...) + data.Topics = topicsList + } else { + data.Topics = types.ListNull(types.StringType) + } + + // Handle pages + if repo.GetHasPages() { + pages, _, err := d.client.Repositories.GetPagesInfo(ctx, owner, repoName) + if err != nil { + resp.Diagnostics.AddWarning( + "Error fetching Pages info", + fmt.Sprintf("Unable to fetch Pages info: %v", err), + ) + data.Pages = types.ObjectNull(pagesObjectAttributeTypes()) + } else { + pagesObj, diags := flattenPages(ctx, pages) + resp.Diagnostics.Append(diags...) + data.Pages = pagesObj + } + } else { + data.Pages = types.ObjectNull(pagesObjectAttributeTypes()) + } + + // Handle license + if repo.License != nil { + license, _, err := d.client.Repositories.License(ctx, owner, repoName) + if err != nil { + resp.Diagnostics.AddWarning( + "Error fetching license", + fmt.Sprintf("Unable to fetch license: %v", err), + ) + data.RepositoryLicense = types.ObjectNull(repositoryLicenseObjectAttributeTypes()) + } else { + licenseObj, diags := flattenRepositoryLicense(ctx, license) + resp.Diagnostics.Append(diags...) + data.RepositoryLicense = licenseObj + } + } else { + data.RepositoryLicense = types.ObjectNull(repositoryLicenseObjectAttributeTypes()) + } + + // Handle template + if repo.TemplateRepository != nil { + templateObj, diags := flattenTemplate(ctx, repo.TemplateRepository) + resp.Diagnostics.Append(diags...) + data.Template = templateObj + } else { + data.Template = types.ObjectNull(templateObjectAttributeTypes()) + } + + // Save data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +// splitRepoFullName splits a full repository name (owner/repo) into owner and repo name. +func splitRepoFullName(fullName string) (string, string, error) { + parts := strings.Split(fullName, "/") + if len(parts) != 2 { + return "", "", fmt.Errorf("unexpected full name format (%q), expected owner/repo_name", fullName) + } + return parts[0], parts[1], nil +} + +// Helper functions for flattening nested objects. +func pagesObjectAttributeTypes() map[string]attr.Type { + return map[string]attr.Type{ + "source": types.ObjectType{AttrTypes: pagesSourceObjectAttributeTypes()}, + "build_type": types.StringType, + "cname": types.StringType, + "custom_404": types.BoolType, + "html_url": types.StringType, + "status": types.StringType, + "url": types.StringType, + } +} + +func pagesSourceObjectAttributeTypes() map[string]attr.Type { + return map[string]attr.Type{ + "branch": types.StringType, + "path": types.StringType, + } +} + +func repositoryLicenseObjectAttributeTypes() map[string]attr.Type { + return map[string]attr.Type{ + "name": types.StringType, + "path": types.StringType, + "license": types.ObjectType{AttrTypes: licenseInfoObjectAttributeTypes()}, + "sha": types.StringType, + "size": types.Int64Type, + "url": types.StringType, + "html_url": types.StringType, + "git_url": types.StringType, + "download_url": types.StringType, + "type": types.StringType, + "content": types.StringType, + "encoding": types.StringType, + } +} + +func licenseInfoObjectAttributeTypes() map[string]attr.Type { + return map[string]attr.Type{ + "key": types.StringType, + "name": types.StringType, + "url": types.StringType, + "spdx_id": types.StringType, + "html_url": types.StringType, + "featured": types.BoolType, + "description": types.StringType, + "implementation": types.StringType, + "permissions": types.ListType{ElemType: types.StringType}, + "conditions": types.ListType{ElemType: types.StringType}, + "limitations": types.ListType{ElemType: types.StringType}, + "body": types.StringType, + } +} + +func templateObjectAttributeTypes() map[string]attr.Type { + return map[string]attr.Type{ + "owner": types.StringType, + "repository": types.StringType, + } +} + +func flattenPages(_ context.Context, pages *github.Pages) (types.Object, diag.Diagnostics) { + if pages == nil { + return types.ObjectNull(pagesObjectAttributeTypes()), nil + } + + var sourceObj types.Object + if pages.Source != nil { + sourceAttrs := map[string]attr.Value{ + "branch": types.StringValue(pages.Source.GetBranch()), + "path": types.StringValue(pages.Source.GetPath()), + } + var diags diag.Diagnostics + sourceObj, diags = types.ObjectValue(pagesSourceObjectAttributeTypes(), sourceAttrs) + if diags.HasError() { + return types.ObjectNull(pagesObjectAttributeTypes()), diags + } + } else { + sourceObj = types.ObjectNull(pagesSourceObjectAttributeTypes()) + } + + attrs := map[string]attr.Value{ + "source": sourceObj, + "build_type": types.StringValue(pages.GetBuildType()), + "cname": types.StringValue(pages.GetCNAME()), + "custom_404": types.BoolValue(pages.GetCustom404()), + "html_url": types.StringValue(pages.GetHTMLURL()), + "status": types.StringValue(pages.GetStatus()), + "url": types.StringValue(pages.GetURL()), + } + + return types.ObjectValue(pagesObjectAttributeTypes(), attrs) +} + +func flattenRepositoryLicense(ctx context.Context, license *github.RepositoryLicense) (types.Object, diag.Diagnostics) { + if license == nil { + return types.ObjectNull(repositoryLicenseObjectAttributeTypes()), nil + } + + var licenseInfoObj types.Object + if license.License != nil { + licenseInfoAttrs := map[string]attr.Value{ + "key": types.StringValue(license.License.GetKey()), + "name": types.StringValue(license.License.GetName()), + "url": types.StringValue(license.License.GetURL()), + "spdx_id": types.StringValue(license.License.GetSPDXID()), + "html_url": types.StringValue(license.License.GetHTMLURL()), + "featured": types.BoolValue(license.License.GetFeatured()), + "description": types.StringValue(license.License.GetDescription()), + "implementation": types.StringValue(license.License.GetImplementation()), + "body": types.StringValue(license.License.GetBody()), + } + + // Handle permissions, conditions, limitations + var permissionsList types.List + permissions := license.License.GetPermissions() + if len(permissions) > 0 { + perms := make([]types.String, len(permissions)) + for i, p := range permissions { + perms[i] = types.StringValue(p) + } + var diags diag.Diagnostics + permissionsList, diags = types.ListValueFrom(ctx, types.StringType, perms) + if diags.HasError() { + return types.ObjectNull(repositoryLicenseObjectAttributeTypes()), diags + } + } else { + permissionsList = types.ListNull(types.StringType) + } + + var conditionsList types.List + conditions := license.License.GetConditions() + if len(conditions) > 0 { + conds := make([]types.String, len(conditions)) + for i, c := range conditions { + conds[i] = types.StringValue(c) + } + var diags diag.Diagnostics + conditionsList, diags = types.ListValueFrom(ctx, types.StringType, conds) + if diags.HasError() { + return types.ObjectNull(repositoryLicenseObjectAttributeTypes()), diags + } + } else { + conditionsList = types.ListNull(types.StringType) + } + + var limitationsList types.List + limitations := license.License.GetLimitations() + if len(limitations) > 0 { + lims := make([]types.String, len(limitations)) + for i, l := range limitations { + lims[i] = types.StringValue(l) + } + var diags diag.Diagnostics + limitationsList, diags = types.ListValueFrom(ctx, types.StringType, lims) + if diags.HasError() { + return types.ObjectNull(repositoryLicenseObjectAttributeTypes()), diags + } + } else { + limitationsList = types.ListNull(types.StringType) + } + + licenseInfoAttrs["permissions"] = permissionsList + licenseInfoAttrs["conditions"] = conditionsList + licenseInfoAttrs["limitations"] = limitationsList + + var diags diag.Diagnostics + licenseInfoObj, diags = types.ObjectValue(licenseInfoObjectAttributeTypes(), licenseInfoAttrs) + if diags.HasError() { + return types.ObjectNull(repositoryLicenseObjectAttributeTypes()), diags + } + } else { + licenseInfoObj = types.ObjectNull(licenseInfoObjectAttributeTypes()) + } + + attrs := map[string]attr.Value{ + "name": types.StringValue(license.GetName()), + "path": types.StringValue(license.GetPath()), + "license": licenseInfoObj, + "sha": types.StringValue(license.GetSHA()), + "size": types.Int64Value(int64(license.GetSize())), + "url": types.StringValue(license.GetURL()), + "html_url": types.StringValue(license.GetHTMLURL()), + "git_url": types.StringValue(license.GetGitURL()), + "download_url": types.StringValue(license.GetDownloadURL()), + "type": types.StringValue(license.GetType()), + "content": types.StringValue(license.GetContent()), + "encoding": types.StringValue(license.GetEncoding()), + } + + return types.ObjectValue(repositoryLicenseObjectAttributeTypes(), attrs) +} + +func flattenTemplate(_ context.Context, template *github.Repository) (types.Object, diag.Diagnostics) { + if template == nil { + return types.ObjectNull(templateObjectAttributeTypes()), nil + } + + attrs := map[string]attr.Value{ + "owner": types.StringValue(template.Owner.GetLogin()), + "repository": types.StringValue(template.GetName()), + } + + return types.ObjectValue(templateObjectAttributeTypes(), attrs) +} diff --git a/internal/provider/data_source_repository_branch.go b/internal/provider/data_source_repository_branch.go new file mode 100644 index 0000000..1e0eb68 --- /dev/null +++ b/internal/provider/data_source_repository_branch.go @@ -0,0 +1,254 @@ +package provider + +import ( + "context" + "errors" + "fmt" + "log" + "net/http" + + "github.com/google/go-github/v60/github" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ datasource.DataSource = &repositoryBranchDataSource{} + _ datasource.DataSourceWithConfigure = &repositoryBranchDataSource{} +) + +// NewRepositoryBranchDataSource is a helper function to simplify the provider implementation. +func NewRepositoryBranchDataSource() datasource.DataSource { + return &repositoryBranchDataSource{} +} + +// repositoryBranchDataSource is the data source implementation. +type repositoryBranchDataSource struct { + client *github.Client + owner string +} + +// repositoryBranchDataSourceModel maps the data source schema data. +type repositoryBranchDataSourceModel struct { + Repository types.String `tfsdk:"repository"` + FullName types.String `tfsdk:"full_name"` + Branch types.String `tfsdk:"branch"` + ETag types.String `tfsdk:"etag"` + Ref types.String `tfsdk:"ref"` + SHA types.String `tfsdk:"sha"` + ID types.String `tfsdk:"id"` +} + +// Metadata returns the data source type name. +func (d *repositoryBranchDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_repository_branch" +} + +// Schema defines the schema for the data source. +func (d *repositoryBranchDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Get information on a GitHub repository branch.", + Attributes: map[string]schema.Attribute{ + "repository": schema.StringAttribute{ + Description: "The name of the repository. Conflicts with `full_name`. If `repository` is provided, the provider-level `owner` configuration will be used.", + Optional: true, + }, + "full_name": schema.StringAttribute{ + Description: "The full name of the repository (owner/repo). Conflicts with `repository`.", + Optional: true, + }, + "branch": schema.StringAttribute{ + Description: "The name of the branch.", + Required: true, + }, + "etag": schema.StringAttribute{ + Description: "The ETag header value from the API response.", + Computed: true, + }, + "ref": schema.StringAttribute{ + Description: "The full Git reference (e.g., refs/heads/main).", + Computed: true, + }, + "sha": schema.StringAttribute{ + Description: "The SHA of the commit that the branch points to.", + Computed: true, + }, + "id": schema.StringAttribute{ + Description: "The Terraform state ID (repository/branch).", + Computed: true, + }, + }, + } +} + +// Configure enables provider-level data or clients to be set in the +// provider-defined data source type. It is separately executed for each +// ReadDataSource RPC. +func (d *repositoryBranchDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + clientData, ok := req.ProviderData.(githubxClientData) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Data Source Configure Type", + fmt.Sprintf("Expected githubxClientData, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + return + } + + d.client = clientData.Client + d.owner = clientData.Owner +} + +// getOwner gets the owner, falling back to authenticated user if not set. +func (d *repositoryBranchDataSource) getOwner(ctx context.Context) (string, error) { + if d.owner != "" { + return d.owner, nil + } + // Try to get authenticated user + user, _, err := d.client.Users.Get(ctx, "") + if err != nil { + return "", fmt.Errorf("unable to determine owner: provider-level `owner` is not set and unable to fetch authenticated user: %v", err) + } + if user == nil || user.Login == nil { + return "", fmt.Errorf("unable to determine owner: provider-level `owner` is not set and authenticated user information is unavailable") + } + return user.GetLogin(), nil +} + +// Read refreshes the Terraform state with the latest data. +func (d *repositoryBranchDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var data repositoryBranchDataSourceModel + + // Read Terraform configuration data into the model + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + if d.client == nil { + resp.Diagnostics.AddError( + "Client Error", + "GitHub client is not configured. Please ensure a token is provided.", + ) + return + } + + // Determine owner and repo name + var owner, repoName string + fullName := data.FullName.ValueString() + repository := data.Repository.ValueString() + + // Check for conflicts + if fullName != "" && repository != "" { + resp.Diagnostics.AddError( + "Conflicting Attributes", + "Cannot specify both `full_name` and `repository`. Please use only one.", + ) + return + } + + // Parse full_name or use repository with owner + if fullName != "" { + var err error + owner, repoName, err = splitRepoFullName(fullName) + if err != nil { + resp.Diagnostics.AddError( + "Invalid Full Name", + fmt.Sprintf("Unable to parse full_name: %v", err), + ) + return + } + } else if repository != "" { + repoName = repository + var err error + owner, err = d.getOwner(ctx) + if err != nil { + resp.Diagnostics.AddError( + "Missing Owner", + fmt.Sprintf("Either `full_name` must be provided, or `repository` must be provided along with provider-level `owner` configuration or authentication. Error: %v", err), + ) + return + } + } else { + resp.Diagnostics.AddError( + "Missing Required Attribute", + "Either `full_name` or `repository` must be provided.", + ) + return + } + + // Get branch name + branchName := data.Branch.ValueString() + if branchName == "" { + resp.Diagnostics.AddError( + "Missing Required Attribute", + "The `branch` attribute is required.", + ) + return + } + + // Build the Git reference name + branchRefName := "refs/heads/" + branchName + + // Fetch the branch reference from GitHub + ref, ghResp, err := d.client.Git.GetRef(ctx, owner, repoName, branchRefName) + if err != nil { + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) { + // Check both ghResp and ghErr.Response for 404 status + if (ghResp != nil && ghResp.StatusCode == http.StatusNotFound) || + (ghErr.Response != nil && ghErr.Response.StatusCode == http.StatusNotFound) { + log.Printf("[DEBUG] Missing GitHub branch %s/%s (%s)", owner, repoName, branchRefName) + resp.Diagnostics.AddWarning( + "Branch Not Found", + fmt.Sprintf("Branch %s not found in repository %s/%s. Setting empty state.", branchName, owner, repoName), + ) + data.ID = types.StringValue("") + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) + return + } + } + resp.Diagnostics.AddError( + "Error fetching GitHub branch", + fmt.Sprintf("Unable to fetch branch %s from repository %s/%s: %v", branchName, owner, repoName, err), + ) + return + } + + // Set the ID (repository/branch format) + data.ID = types.StringValue(fmt.Sprintf("%s/%s", repoName, branchName)) + + // Set ETag from response header + if ghResp != nil { + data.ETag = types.StringValue(ghResp.Header.Get("ETag")) + } else { + data.ETag = types.StringNull() + } + + // Set ref and SHA + if ref != nil { + if ref.Ref != nil { + data.Ref = types.StringValue(*ref.Ref) + } else { + data.Ref = types.StringNull() + } + + if ref.Object != nil && ref.Object.SHA != nil { + data.SHA = types.StringValue(*ref.Object.SHA) + } else { + data.SHA = types.StringNull() + } + } else { + data.Ref = types.StringNull() + data.SHA = types.StringNull() + } + + // Save data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} diff --git a/internal/provider/data_source_repository_branch_test.go b/internal/provider/data_source_repository_branch_test.go new file mode 100644 index 0000000..936c8d8 --- /dev/null +++ b/internal/provider/data_source_repository_branch_test.go @@ -0,0 +1,126 @@ +package provider + +import ( + "testing" + + "github.com/google/go-github/v60/github" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/stretchr/testify/assert" +) + +func TestRepositoryBranchDataSource_Metadata(t *testing.T) { + ds := NewRepositoryBranchDataSource() + req := datasource.MetadataRequest{ + ProviderTypeName: "githubx", + } + resp := &datasource.MetadataResponse{} + + ds.Metadata(t.Context(), req, resp) + + assert.Equal(t, "githubx_repository_branch", resp.TypeName) +} + +func TestRepositoryBranchDataSource_Schema(t *testing.T) { + ds := NewRepositoryBranchDataSource() + req := datasource.SchemaRequest{} + resp := &datasource.SchemaResponse{} + + ds.Schema(t.Context(), req, resp) + + assert.NotNil(t, resp.Schema) + assert.Contains(t, resp.Schema.Description, "Get information on a GitHub repository branch") + + // Check optional attributes (repository and full_name are mutually exclusive) + repositoryAttr, ok := resp.Schema.Attributes["repository"] + assert.True(t, ok) + assert.True(t, repositoryAttr.IsOptional()) + + fullNameAttr, ok := resp.Schema.Attributes["full_name"] + assert.True(t, ok) + assert.True(t, fullNameAttr.IsOptional()) + + // Check required attribute + branchAttr, ok := resp.Schema.Attributes["branch"] + assert.True(t, ok) + assert.True(t, branchAttr.IsRequired()) + + // Check computed attributes + idAttr, ok := resp.Schema.Attributes["id"] + assert.True(t, ok) + assert.True(t, idAttr.IsComputed()) + + etagAttr, ok := resp.Schema.Attributes["etag"] + assert.True(t, ok) + assert.True(t, etagAttr.IsComputed()) + + refAttr, ok := resp.Schema.Attributes["ref"] + assert.True(t, ok) + assert.True(t, refAttr.IsComputed()) + + shaAttr, ok := resp.Schema.Attributes["sha"] + assert.True(t, ok) + assert.True(t, shaAttr.IsComputed()) +} + +func TestRepositoryBranchDataSource_Configure(t *testing.T) { + tests := []struct { + name string + providerData interface{} + expectError bool + errorContains string + }{ + { + name: "valid githubxClientData", + providerData: githubxClientData{ + Client: github.NewClient(nil), + Owner: "test-owner", + }, + expectError: false, + }, + { + name: "invalid provider data type", + providerData: "invalid", + expectError: true, + errorContains: "Unexpected Data Source Configure Type", + }, + { + name: "nil provider data", + providerData: nil, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ds := &repositoryBranchDataSource{} + req := datasource.ConfigureRequest{ + ProviderData: tt.providerData, + } + resp := &datasource.ConfigureResponse{} + + ds.Configure(t.Context(), req, resp) + + if tt.expectError { + assert.True(t, resp.Diagnostics.HasError()) + if tt.errorContains != "" { + assert.Contains(t, resp.Diagnostics.Errors()[0].Summary(), tt.errorContains) + } + } else { + assert.False(t, resp.Diagnostics.HasError()) + // If provider data is valid, verify client and owner are set + if tt.providerData != nil { + clientData, ok := tt.providerData.(githubxClientData) + if ok { + assert.Equal(t, clientData.Client, ds.client) + assert.Equal(t, clientData.Owner, ds.owner) + } + } + } + }) + } +} + +// Note: Tests for Read() method that require GitHub API calls should be +// implemented as acceptance tests with TF_ACC=1 environment variable set. +// These unit tests verify the schema, metadata, and configuration validation +// without making API calls. diff --git a/internal/provider/data_source_repository_file.go b/internal/provider/data_source_repository_file.go new file mode 100644 index 0000000..389bae2 --- /dev/null +++ b/internal/provider/data_source_repository_file.go @@ -0,0 +1,332 @@ +package provider + +import ( + "context" + "errors" + "fmt" + "log" + "net/http" + "net/url" + + "github.com/google/go-github/v60/github" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var ( + _ datasource.DataSource = &repositoryFileDataSource{} + _ datasource.DataSourceWithConfigure = &repositoryFileDataSource{} +) + +func NewRepositoryFileDataSource() datasource.DataSource { + return &repositoryFileDataSource{} +} + +type repositoryFileDataSource struct { + client *github.Client + owner string +} + +type repositoryFileDataSourceModel struct { + Repository types.String `tfsdk:"repository"` + FullName types.String `tfsdk:"full_name"` + File types.String `tfsdk:"file"` + Branch types.String `tfsdk:"branch"` + Ref types.String `tfsdk:"ref"` + Content types.String `tfsdk:"content"` + CommitSHA types.String `tfsdk:"commit_sha"` + CommitMessage types.String `tfsdk:"commit_message"` + CommitAuthor types.String `tfsdk:"commit_author"` + CommitEmail types.String `tfsdk:"commit_email"` + SHA types.String `tfsdk:"sha"` + ID types.String `tfsdk:"id"` +} + +func (d *repositoryFileDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_repository_file" +} + +func (d *repositoryFileDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Get information on a file in a GitHub repository.", + Attributes: map[string]schema.Attribute{ + "repository": schema.StringAttribute{ + Description: "The name of the repository. Conflicts with `full_name`. If `repository` is provided, the provider-level `owner` configuration will be used.", + Optional: true, + }, + "full_name": schema.StringAttribute{ + Description: "The full name of the repository (owner/repo). Conflicts with `repository`.", + Optional: true, + }, + "file": schema.StringAttribute{ + Description: "The file path to read.", + Required: true, + }, + "branch": schema.StringAttribute{ + Description: "The branch name, defaults to the repository's default branch.", + Optional: true, + }, + "ref": schema.StringAttribute{ + Description: "The name of the commit/branch/tag.", + Computed: true, + }, + "content": schema.StringAttribute{ + Description: "The file's content.", + Computed: true, + }, + "commit_sha": schema.StringAttribute{ + Description: "The SHA of the commit that modified the file.", + Computed: true, + }, + "commit_message": schema.StringAttribute{ + Description: "The commit message when the file was last modified.", + Computed: true, + }, + "commit_author": schema.StringAttribute{ + Description: "The commit author name.", + Computed: true, + }, + "commit_email": schema.StringAttribute{ + Description: "The commit author email address.", + Computed: true, + }, + "sha": schema.StringAttribute{ + Description: "The blob SHA of the file.", + Computed: true, + }, + "id": schema.StringAttribute{ + Description: "The Terraform state ID (repository/file).", + Computed: true, + }, + }, + } +} + +func (d *repositoryFileDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + clientData, ok := req.ProviderData.(githubxClientData) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Data Source Configure Type", + fmt.Sprintf("Expected githubxClientData, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + return + } + + d.client = clientData.Client + d.owner = clientData.Owner +} + +func (d *repositoryFileDataSource) getOwner(ctx context.Context) (string, error) { + if d.owner != "" { + return d.owner, nil + } + + user, _, err := d.client.Users.Get(ctx, "") + if err != nil { + return "", fmt.Errorf("unable to determine owner: provider-level `owner` is not set and unable to fetch authenticated user: %v", err) + } + if user == nil || user.Login == nil { + return "", fmt.Errorf("unable to determine owner: provider-level `owner` is not set and authenticated user information is unavailable") + } + return user.GetLogin(), nil +} + +func (d *repositoryFileDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var data repositoryFileDataSourceModel + + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + if d.client == nil { + resp.Diagnostics.AddError( + "Client Error", + "GitHub client is not configured. Please ensure a token is provided.", + ) + return + } + + var owner, repoName string + fullName := data.FullName.ValueString() + repository := data.Repository.ValueString() + + if fullName != "" && repository != "" { + resp.Diagnostics.AddError( + "Conflicting Attributes", + "Cannot specify both `full_name` and `repository`. Please use only one.", + ) + return + } + + if fullName != "" { + var err error + owner, repoName, err = splitRepoFullName(fullName) + if err != nil { + resp.Diagnostics.AddError( + "Invalid Full Name", + fmt.Sprintf("Unable to parse full_name: %v", err), + ) + return + } + } else if repository != "" { + repoName = repository + var err error + owner, err = d.getOwner(ctx) + if err != nil { + resp.Diagnostics.AddError( + "Missing Owner", + fmt.Sprintf("Either `full_name` must be provided, or `repository` must be provided along with provider-level `owner` configuration or authentication. Error: %v", err), + ) + return + } + } else { + resp.Diagnostics.AddError( + "Missing Required Attribute", + "Either `full_name` or `repository` must be provided.", + ) + return + } + + filePath := data.File.ValueString() + if filePath == "" { + resp.Diagnostics.AddError( + "Missing Required Attribute", + "The `file` attribute is required.", + ) + return + } + + opts := &github.RepositoryContentGetOptions{} + if !data.Branch.IsNull() && !data.Branch.IsUnknown() { + opts.Ref = data.Branch.ValueString() + } + + fc, dc, _, err := d.client.Repositories.GetContents(ctx, owner, repoName, filePath, opts) + if err != nil { + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) { + if ghErr.Response != nil && ghErr.Response.StatusCode == http.StatusNotFound { + log.Printf("[DEBUG] Missing GitHub repository file %s/%s/%s", owner, repoName, filePath) + data.ID = types.StringValue("") + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) + return + } + } + resp.Diagnostics.AddError( + "Error fetching GitHub repository file", + fmt.Sprintf("Unable to fetch file %s from repository %s/%s: %v", filePath, owner, repoName, err), + ) + return + } + + data.Repository = types.StringValue(repoName) + data.ID = types.StringValue(fmt.Sprintf("%s/%s", repoName, filePath)) + data.File = types.StringValue(filePath) + + if dc != nil { + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) + return + } + + content, err := fc.GetContent() + if err != nil { + resp.Diagnostics.AddError( + "Error reading file content", + fmt.Sprintf("Unable to get content from file %s/%s/%s: %v", owner, repoName, filePath, err), + ) + return + } + + data.Content = types.StringValue(content) + data.SHA = types.StringValue(fc.GetSHA()) + + parsedURL, err := url.Parse(fc.GetURL()) + if err != nil { + resp.Diagnostics.AddWarning( + "Error parsing file URL", + fmt.Sprintf("Unable to parse file URL: %v", err), + ) + } else { + parsedQuery, err := url.ParseQuery(parsedURL.RawQuery) + if err != nil { + resp.Diagnostics.AddWarning( + "Error parsing query string", + fmt.Sprintf("Unable to parse query string: %v", err), + ) + } else { + if refValues, ok := parsedQuery["ref"]; ok && len(refValues) > 0 { + data.Ref = types.StringValue(refValues[0]) + } else { + data.Ref = types.StringNull() + } + } + } + + ref := data.Ref.ValueString() + if ref == "" && !data.Branch.IsNull() && !data.Branch.IsUnknown() { + ref = data.Branch.ValueString() + } + + if ref != "" { + log.Printf("[DEBUG] Fetching commit info for repository file: %s/%s/%s", owner, repoName, filePath) + commit, err := d.getFileCommit(ctx, owner, repoName, filePath, ref) + if err != nil { + resp.Diagnostics.AddWarning( + "Error fetching commit information", + fmt.Sprintf("Unable to fetch commit information for file %s/%s/%s: %v", owner, repoName, filePath, err), + ) + } else { + log.Printf("[DEBUG] Found file: %s/%s/%s, in commit SHA: %s", owner, repoName, filePath, commit.GetSHA()) + data.CommitSHA = types.StringValue(commit.GetSHA()) + + if commit.Commit != nil && commit.Commit.Committer != nil { + data.CommitAuthor = types.StringValue(commit.Commit.Committer.GetName()) + data.CommitEmail = types.StringValue(commit.Commit.Committer.GetEmail()) + } else { + data.CommitAuthor = types.StringNull() + data.CommitEmail = types.StringNull() + } + + if commit.Commit != nil { + data.CommitMessage = types.StringValue(commit.Commit.GetMessage()) + } else { + data.CommitMessage = types.StringNull() + } + } + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (d *repositoryFileDataSource) getFileCommit(ctx context.Context, owner, repo, file, ref string) (*github.RepositoryCommit, error) { + opts := &github.CommitsListOptions{ + Path: file, + SHA: ref, + ListOptions: github.ListOptions{ + PerPage: 1, + }, + } + + commits, _, err := d.client.Repositories.ListCommits(ctx, owner, repo, opts) + if err != nil { + return nil, err + } + + if len(commits) == 0 { + return nil, fmt.Errorf("no commits found for file %s in ref %s", file, ref) + } + + commitSHA := commits[0].GetSHA() + commit, _, err := d.client.Repositories.GetCommit(ctx, owner, repo, commitSHA, nil) + if err != nil { + return nil, err + } + + return commit, nil +} diff --git a/internal/provider/data_source_repository_file_test.go b/internal/provider/data_source_repository_file_test.go new file mode 100644 index 0000000..e8bbaab --- /dev/null +++ b/internal/provider/data_source_repository_file_test.go @@ -0,0 +1,146 @@ +package provider + +import ( + "testing" + + "github.com/google/go-github/v60/github" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/stretchr/testify/assert" +) + +func TestRepositoryFileDataSource_Metadata(t *testing.T) { + ds := NewRepositoryFileDataSource() + req := datasource.MetadataRequest{ + ProviderTypeName: "githubx", + } + resp := &datasource.MetadataResponse{} + + ds.Metadata(t.Context(), req, resp) + + assert.Equal(t, "githubx_repository_file", resp.TypeName) +} + +func TestRepositoryFileDataSource_Schema(t *testing.T) { + ds := NewRepositoryFileDataSource() + req := datasource.SchemaRequest{} + resp := &datasource.SchemaResponse{} + + ds.Schema(t.Context(), req, resp) + + assert.NotNil(t, resp.Schema) + assert.Contains(t, resp.Schema.Description, "Get information on a file in a GitHub repository") + + // Check optional attributes (repository and full_name are mutually exclusive) + repositoryAttr, ok := resp.Schema.Attributes["repository"] + assert.True(t, ok) + assert.True(t, repositoryAttr.IsOptional()) + + fullNameAttr, ok := resp.Schema.Attributes["full_name"] + assert.True(t, ok) + assert.True(t, fullNameAttr.IsOptional()) + + branchAttr, ok := resp.Schema.Attributes["branch"] + assert.True(t, ok) + assert.True(t, branchAttr.IsOptional()) + + // Check required attribute + fileAttr, ok := resp.Schema.Attributes["file"] + assert.True(t, ok) + assert.True(t, fileAttr.IsRequired()) + + // Check computed attributes + idAttr, ok := resp.Schema.Attributes["id"] + assert.True(t, ok) + assert.True(t, idAttr.IsComputed()) + + refAttr, ok := resp.Schema.Attributes["ref"] + assert.True(t, ok) + assert.True(t, refAttr.IsComputed()) + + contentAttr, ok := resp.Schema.Attributes["content"] + assert.True(t, ok) + assert.True(t, contentAttr.IsComputed()) + + shaAttr, ok := resp.Schema.Attributes["sha"] + assert.True(t, ok) + assert.True(t, shaAttr.IsComputed()) + + commitSHAAttr, ok := resp.Schema.Attributes["commit_sha"] + assert.True(t, ok) + assert.True(t, commitSHAAttr.IsComputed()) + + commitMessageAttr, ok := resp.Schema.Attributes["commit_message"] + assert.True(t, ok) + assert.True(t, commitMessageAttr.IsComputed()) + + commitAuthorAttr, ok := resp.Schema.Attributes["commit_author"] + assert.True(t, ok) + assert.True(t, commitAuthorAttr.IsComputed()) + + commitEmailAttr, ok := resp.Schema.Attributes["commit_email"] + assert.True(t, ok) + assert.True(t, commitEmailAttr.IsComputed()) +} + +func TestRepositoryFileDataSource_Configure(t *testing.T) { + tests := []struct { + name string + providerData interface{} + expectError bool + errorContains string + }{ + { + name: "valid githubxClientData", + providerData: githubxClientData{ + Client: github.NewClient(nil), + Owner: "test-owner", + }, + expectError: false, + }, + { + name: "invalid provider data type", + providerData: "invalid", + expectError: true, + errorContains: "Unexpected Data Source Configure Type", + }, + { + name: "nil provider data", + providerData: nil, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ds := &repositoryFileDataSource{} + req := datasource.ConfigureRequest{ + ProviderData: tt.providerData, + } + resp := &datasource.ConfigureResponse{} + + ds.Configure(t.Context(), req, resp) + + if tt.expectError { + assert.True(t, resp.Diagnostics.HasError()) + if tt.errorContains != "" { + assert.Contains(t, resp.Diagnostics.Errors()[0].Summary(), tt.errorContains) + } + } else { + assert.False(t, resp.Diagnostics.HasError()) + // If provider data is valid, verify client and owner are set + if tt.providerData != nil { + clientData, ok := tt.providerData.(githubxClientData) + if ok { + assert.Equal(t, clientData.Client, ds.client) + assert.Equal(t, clientData.Owner, ds.owner) + } + } + } + }) + } +} + +// Note: Tests for Read() method that require GitHub API calls should be +// implemented as acceptance tests with TF_ACC=1 environment variable set. +// These unit tests verify the schema, metadata, and configuration validation +// without making API calls. diff --git a/internal/provider/data_source_repository_test.go b/internal/provider/data_source_repository_test.go new file mode 100644 index 0000000..92b5471 --- /dev/null +++ b/internal/provider/data_source_repository_test.go @@ -0,0 +1,209 @@ +package provider + +import ( + "testing" + + "github.com/google/go-github/v60/github" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/stretchr/testify/assert" +) + +func TestRepositoryDataSource_Metadata(t *testing.T) { + ds := NewRepositoryDataSource() + req := datasource.MetadataRequest{ + ProviderTypeName: "githubx", + } + resp := &datasource.MetadataResponse{} + + ds.Metadata(t.Context(), req, resp) + + assert.Equal(t, "githubx_repository", resp.TypeName) +} + +func TestRepositoryDataSource_Schema(t *testing.T) { + ds := NewRepositoryDataSource() + req := datasource.SchemaRequest{} + resp := &datasource.SchemaResponse{} + + ds.Schema(t.Context(), req, resp) + + assert.NotNil(t, resp.Schema) + assert.Contains(t, resp.Schema.Description, "Get information on a GitHub repository") + + // Check optional attributes (full_name and name are mutually exclusive) + fullNameAttr, ok := resp.Schema.Attributes["full_name"] + assert.True(t, ok) + assert.True(t, fullNameAttr.IsOptional()) + assert.True(t, fullNameAttr.IsComputed()) + + nameAttr, ok := resp.Schema.Attributes["name"] + assert.True(t, ok) + assert.True(t, nameAttr.IsOptional()) + assert.True(t, nameAttr.IsComputed()) + + // Check computed attributes + idAttr, ok := resp.Schema.Attributes["id"] + assert.True(t, ok) + assert.True(t, idAttr.IsComputed()) + + descriptionAttr, ok := resp.Schema.Attributes["description"] + assert.True(t, ok) + assert.True(t, descriptionAttr.IsComputed()) + + privateAttr, ok := resp.Schema.Attributes["private"] + assert.True(t, ok) + assert.True(t, privateAttr.IsComputed()) + + visibilityAttr, ok := resp.Schema.Attributes["visibility"] + assert.True(t, ok) + assert.True(t, visibilityAttr.IsComputed()) + + defaultBranchAttr, ok := resp.Schema.Attributes["default_branch"] + assert.True(t, ok) + assert.True(t, defaultBranchAttr.IsComputed()) + + htmlURLAttr, ok := resp.Schema.Attributes["html_url"] + assert.True(t, ok) + assert.True(t, htmlURLAttr.IsComputed()) + + nodeIDAttr, ok := resp.Schema.Attributes["node_id"] + assert.True(t, ok) + assert.True(t, nodeIDAttr.IsComputed()) + + repoIDAttr, ok := resp.Schema.Attributes["repo_id"] + assert.True(t, ok) + assert.True(t, repoIDAttr.IsComputed()) + + // Check nested attributes + pagesAttr, ok := resp.Schema.Attributes["pages"] + assert.True(t, ok) + assert.True(t, pagesAttr.IsComputed()) + + repositoryLicenseAttr, ok := resp.Schema.Attributes["repository_license"] + assert.True(t, ok) + assert.True(t, repositoryLicenseAttr.IsComputed()) + + templateAttr, ok := resp.Schema.Attributes["template"] + assert.True(t, ok) + assert.True(t, templateAttr.IsComputed()) + + topicsAttr, ok := resp.Schema.Attributes["topics"] + assert.True(t, ok) + assert.True(t, topicsAttr.IsComputed()) +} + +func TestRepositoryDataSource_Configure(t *testing.T) { + tests := []struct { + name string + providerData interface{} + expectError bool + errorContains string + }{ + { + name: "valid githubxClientData", + providerData: githubxClientData{ + Client: github.NewClient(nil), + Owner: "test-owner", + }, + expectError: false, + }, + { + name: "invalid provider data type", + providerData: "invalid", + expectError: true, + errorContains: "Unexpected Data Source Configure Type", + }, + { + name: "nil provider data", + providerData: nil, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ds := &repositoryDataSource{} + req := datasource.ConfigureRequest{ + ProviderData: tt.providerData, + } + resp := &datasource.ConfigureResponse{} + + ds.Configure(t.Context(), req, resp) + + if tt.expectError { + assert.True(t, resp.Diagnostics.HasError()) + if tt.errorContains != "" { + assert.Contains(t, resp.Diagnostics.Errors()[0].Summary(), tt.errorContains) + } + } else { + assert.False(t, resp.Diagnostics.HasError()) + // If provider data is valid, verify client and owner are set + if tt.providerData != nil { + clientData, ok := tt.providerData.(githubxClientData) + if ok { + assert.Equal(t, clientData.Client, ds.client) + assert.Equal(t, clientData.Owner, ds.owner) + } + } + } + }) + } +} + +func TestSplitRepoFullName(t *testing.T) { + tests := []struct { + name string + fullName string + expectOwner string + expectRepo string + expectError bool + }{ + { + name: "valid full name", + fullName: "owner/repo", + expectOwner: "owner", + expectRepo: "repo", + expectError: false, + }, + { + name: "valid full name with dash", + fullName: "my-org/my-repo", + expectOwner: "my-org", + expectRepo: "my-repo", + expectError: false, + }, + { + name: "invalid format - no slash", + fullName: "invalid", + expectError: true, + }, + { + name: "invalid format - multiple slashes", + fullName: "owner/repo/extra", + expectError: true, + }, + { + name: "invalid format - empty", + fullName: "", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + owner, repo, err := splitRepoFullName(tt.fullName) + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectOwner, owner) + assert.Equal(t, tt.expectRepo, repo) + } + }) + } +} + +// Note: Tests for Read() method that require GitHub API calls should be +// implemented as acceptance tests with TF_ACC=1 environment variable set. +// These unit tests verify the schema, metadata, and configuration validation +// without making API calls. diff --git a/internal/provider/data_source_user.go b/internal/provider/data_source_user.go new file mode 100644 index 0000000..1376a90 --- /dev/null +++ b/internal/provider/data_source_user.go @@ -0,0 +1,226 @@ +package provider + +import ( + "context" + "fmt" + + "github.com/google/go-github/v60/github" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ datasource.DataSource = &userDataSource{} + _ datasource.DataSourceWithConfigure = &userDataSource{} +) + +// NewUserDataSource is a helper function to simplify the provider implementation. +func NewUserDataSource() datasource.DataSource { + return &userDataSource{} +} + +// userDataSource is the data source implementation. +type userDataSource struct { + client *github.Client +} + +// userDataSourceModel maps the data source schema data. +type userDataSourceModel struct { + Username types.String `tfsdk:"username"` + ID types.String `tfsdk:"id"` // This is used as the Terraform state ID + UserID types.Int64 `tfsdk:"user_id"` // The GitHub user ID as integer + NodeID types.String `tfsdk:"node_id"` + AvatarURL types.String `tfsdk:"avatar_url"` + HTMLURL types.String `tfsdk:"html_url"` + Name types.String `tfsdk:"name"` + Company types.String `tfsdk:"company"` + Blog types.String `tfsdk:"blog"` + Location types.String `tfsdk:"location"` + Email types.String `tfsdk:"email"` + Bio types.String `tfsdk:"bio"` + PublicRepos types.Int64 `tfsdk:"public_repos"` + PublicGists types.Int64 `tfsdk:"public_gists"` + Followers types.Int64 `tfsdk:"followers"` + Following types.Int64 `tfsdk:"following"` + CreatedAt types.String `tfsdk:"created_at"` + UpdatedAt types.String `tfsdk:"updated_at"` +} + +// Metadata returns the data source type name. +func (d *userDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_user" +} + +// Schema defines the schema for the data source. +func (d *userDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Get information on a GitHub user.", + Attributes: map[string]schema.Attribute{ + "username": schema.StringAttribute{ + Description: "The GitHub username to look up.", + Required: true, + }, + "id": schema.StringAttribute{ + Description: "The GitHub user ID (as string for Terraform state ID).", + Computed: true, + }, + "user_id": schema.Int64Attribute{ + Description: "The GitHub user ID as an integer.", + Computed: true, + }, + "node_id": schema.StringAttribute{ + Description: "The GitHub node ID of the user.", + Computed: true, + }, + "avatar_url": schema.StringAttribute{ + Description: "The URL of the user's avatar.", + Computed: true, + }, + "html_url": schema.StringAttribute{ + Description: "The GitHub URL of the user's profile.", + Computed: true, + }, + "name": schema.StringAttribute{ + Description: "The user's display name.", + Computed: true, + }, + "company": schema.StringAttribute{ + Description: "The user's company.", + Computed: true, + }, + "blog": schema.StringAttribute{ + Description: "The user's blog URL.", + Computed: true, + }, + "location": schema.StringAttribute{ + Description: "The user's location.", + Computed: true, + }, + "email": schema.StringAttribute{ + Description: "The user's email address.", + Computed: true, + }, + "bio": schema.StringAttribute{ + Description: "The user's bio.", + Computed: true, + }, + "public_repos": schema.Int64Attribute{ + Description: "The number of public repositories.", + Computed: true, + }, + "public_gists": schema.Int64Attribute{ + Description: "The number of public gists.", + Computed: true, + }, + "followers": schema.Int64Attribute{ + Description: "The number of followers.", + Computed: true, + }, + "following": schema.Int64Attribute{ + Description: "The number of users following.", + Computed: true, + }, + "created_at": schema.StringAttribute{ + Description: "The timestamp when the user account was created.", + Computed: true, + }, + "updated_at": schema.StringAttribute{ + Description: "The timestamp when the user account was last updated.", + Computed: true, + }, + }, + } +} + +// Configure enables provider-level data or clients to be set in the +// provider-defined data source type. It is separately executed for each +// ReadDataSource RPC. +func (d *userDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + clientData, ok := req.ProviderData.(githubxClientData) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Data Source Configure Type", + fmt.Sprintf("Expected githubxClientData, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + return + } + + d.client = clientData.Client +} + +// Read refreshes the Terraform state with the latest data. +func (d *userDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var data userDataSourceModel + + // Read Terraform configuration data into the model + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + if d.client == nil { + resp.Diagnostics.AddError( + "Client Error", + "GitHub client is not configured. Please ensure a token is provided.", + ) + return + } + + username := data.Username.ValueString() + if username == "" { + resp.Diagnostics.AddError( + "Missing Username", + "The username attribute is required.", + ) + return + } + + // Fetch the user from GitHub + user, _, err := d.client.Users.Get(ctx, username) + if err != nil { + resp.Diagnostics.AddError( + "Error fetching GitHub user", + fmt.Sprintf("Unable to fetch user %s: %v", username, err), + ) + return + } + + // Map response body to model + // Convert ID to string for Terraform state ID + userID := user.GetID() + data.ID = types.StringValue(fmt.Sprintf("%d", userID)) + data.UserID = types.Int64Value(userID) + data.NodeID = types.StringValue(user.GetNodeID()) + data.AvatarURL = types.StringValue(user.GetAvatarURL()) + data.HTMLURL = types.StringValue(user.GetHTMLURL()) + data.Name = types.StringValue(user.GetName()) + data.Company = types.StringValue(user.GetCompany()) + data.Blog = types.StringValue(user.GetBlog()) + data.Location = types.StringValue(user.GetLocation()) + data.Email = types.StringValue(user.GetEmail()) + data.Bio = types.StringValue(user.GetBio()) + data.PublicRepos = types.Int64Value(int64(user.GetPublicRepos())) + data.PublicGists = types.Int64Value(int64(user.GetPublicGists())) + data.Followers = types.Int64Value(int64(user.GetFollowers())) + data.Following = types.Int64Value(int64(user.GetFollowing())) + + if user.CreatedAt != nil { + data.CreatedAt = types.StringValue(user.CreatedAt.Format("2006-01-02T15:04:05Z07:00")) + } + if user.UpdatedAt != nil { + data.UpdatedAt = types.StringValue(user.UpdatedAt.Format("2006-01-02T15:04:05Z07:00")) + } + + // Set ID for the data source (using username as the ID) + data.Username = types.StringValue(username) + + // Save data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} diff --git a/internal/provider/data_source_user_test.go b/internal/provider/data_source_user_test.go new file mode 100644 index 0000000..044fb8f --- /dev/null +++ b/internal/provider/data_source_user_test.go @@ -0,0 +1,168 @@ +package provider + +import ( + "testing" + + "github.com/google/go-github/v60/github" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/stretchr/testify/assert" +) + +func TestUserDataSource_Metadata(t *testing.T) { + ds := NewUserDataSource() + req := datasource.MetadataRequest{ + ProviderTypeName: "githubx", + } + resp := &datasource.MetadataResponse{} + + ds.Metadata(t.Context(), req, resp) + + assert.Equal(t, "githubx_user", resp.TypeName) +} + +func TestUserDataSource_Schema(t *testing.T) { + ds := NewUserDataSource() + req := datasource.SchemaRequest{} + resp := &datasource.SchemaResponse{} + + ds.Schema(t.Context(), req, resp) + + assert.NotNil(t, resp.Schema) + assert.Contains(t, resp.Schema.Description, "Get information on a GitHub user") + + // Check required attribute + usernameAttr, ok := resp.Schema.Attributes["username"] + assert.True(t, ok) + assert.True(t, usernameAttr.IsRequired()) + + // Check computed attributes + idAttr, ok := resp.Schema.Attributes["id"] + assert.True(t, ok) + assert.True(t, idAttr.IsComputed()) + + userIDAttr, ok := resp.Schema.Attributes["user_id"] + assert.True(t, ok) + assert.True(t, userIDAttr.IsComputed()) + + nodeIDAttr, ok := resp.Schema.Attributes["node_id"] + assert.True(t, ok) + assert.True(t, nodeIDAttr.IsComputed()) + + avatarURLAttr, ok := resp.Schema.Attributes["avatar_url"] + assert.True(t, ok) + assert.True(t, avatarURLAttr.IsComputed()) + + htmlURLAttr, ok := resp.Schema.Attributes["html_url"] + assert.True(t, ok) + assert.True(t, htmlURLAttr.IsComputed()) + + nameAttr, ok := resp.Schema.Attributes["name"] + assert.True(t, ok) + assert.True(t, nameAttr.IsComputed()) + + companyAttr, ok := resp.Schema.Attributes["company"] + assert.True(t, ok) + assert.True(t, companyAttr.IsComputed()) + + blogAttr, ok := resp.Schema.Attributes["blog"] + assert.True(t, ok) + assert.True(t, blogAttr.IsComputed()) + + locationAttr, ok := resp.Schema.Attributes["location"] + assert.True(t, ok) + assert.True(t, locationAttr.IsComputed()) + + emailAttr, ok := resp.Schema.Attributes["email"] + assert.True(t, ok) + assert.True(t, emailAttr.IsComputed()) + + bioAttr, ok := resp.Schema.Attributes["bio"] + assert.True(t, ok) + assert.True(t, bioAttr.IsComputed()) + + publicReposAttr, ok := resp.Schema.Attributes["public_repos"] + assert.True(t, ok) + assert.True(t, publicReposAttr.IsComputed()) + + publicGistsAttr, ok := resp.Schema.Attributes["public_gists"] + assert.True(t, ok) + assert.True(t, publicGistsAttr.IsComputed()) + + followersAttr, ok := resp.Schema.Attributes["followers"] + assert.True(t, ok) + assert.True(t, followersAttr.IsComputed()) + + followingAttr, ok := resp.Schema.Attributes["following"] + assert.True(t, ok) + assert.True(t, followingAttr.IsComputed()) + + createdAtAttr, ok := resp.Schema.Attributes["created_at"] + assert.True(t, ok) + assert.True(t, createdAtAttr.IsComputed()) + + updatedAtAttr, ok := resp.Schema.Attributes["updated_at"] + assert.True(t, ok) + assert.True(t, updatedAtAttr.IsComputed()) +} + +func TestUserDataSource_Configure(t *testing.T) { + tests := []struct { + name string + providerData interface{} + expectError bool + errorContains string + }{ + { + name: "valid githubxClientData", + providerData: githubxClientData{ + Client: github.NewClient(nil), + Owner: "test-owner", + }, + expectError: false, + }, + { + name: "invalid provider data type", + providerData: "invalid", + expectError: true, + errorContains: "Unexpected Data Source Configure Type", + }, + { + name: "nil provider data", + providerData: nil, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ds := &userDataSource{} + req := datasource.ConfigureRequest{ + ProviderData: tt.providerData, + } + resp := &datasource.ConfigureResponse{} + + ds.Configure(t.Context(), req, resp) + + if tt.expectError { + assert.True(t, resp.Diagnostics.HasError()) + if tt.errorContains != "" { + assert.Contains(t, resp.Diagnostics.Errors()[0].Summary(), tt.errorContains) + } + } else { + assert.False(t, resp.Diagnostics.HasError()) + // If provider data is valid, verify client is set + if tt.providerData != nil { + clientData, ok := tt.providerData.(githubxClientData) + if ok { + assert.Equal(t, clientData.Client, ds.client) + } + } + } + }) + } +} + +// Note: Tests for Read() method that require GitHub API calls should be +// implemented as acceptance tests with TF_ACC=1 environment variable set. +// These unit tests verify the schema, metadata, and configuration validation +// without making API calls. diff --git a/internal/provider/provider.go b/internal/provider/provider.go new file mode 100644 index 0000000..933ac5b --- /dev/null +++ b/internal/provider/provider.go @@ -0,0 +1,413 @@ +package provider + +import ( + "context" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "encoding/pem" + "fmt" + "net/http" + "net/url" + "os" + "os/exec" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/go-github/v60/github" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/provider/schema" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + "golang.org/x/oauth2" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ provider.Provider = &githubxProvider{} +) + +// New is a helper function to simplify provider server and testing implementation. +func New(version string) func() provider.Provider { + return func() provider.Provider { + return &githubxProvider{ + version: version, + } + } +} + +// githubxProvider is the provider implementation. +type githubxProvider struct { + version string +} + +// githubxProviderModel maps provider schema data to a Go type. +type githubxProviderModel struct { + Token types.String `tfsdk:"token"` + OAuthToken types.String `tfsdk:"oauth_token"` + AppAuth *appAuthModel `tfsdk:"app_auth"` + BaseURL types.String `tfsdk:"base_url"` + Owner types.String `tfsdk:"owner"` + Insecure types.Bool `tfsdk:"insecure"` +} + +// appAuthModel represents GitHub App authentication configuration. +type appAuthModel struct { + ID types.Int64 `tfsdk:"id"` + InstallationID types.Int64 `tfsdk:"installation_id"` + PEMFile types.String `tfsdk:"pem_file"` +} + +// githubxClientData holds the GitHub API client for use in resources and data sources. +type githubxClientData struct { + Client *github.Client + Owner string +} + +// Metadata returns the provider type name. +func (p *githubxProvider) Metadata(_ context.Context, _ provider.MetadataRequest, resp *provider.MetadataResponse) { + resp.TypeName = "githubx" + resp.Version = p.version +} + +// Schema defines the provider-level schema for configuration data. +func (p *githubxProvider) Schema(_ context.Context, _ provider.SchemaRequest, resp *provider.SchemaResponse) { + resp.Schema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "token": schema.StringAttribute{ + Optional: true, + Sensitive: true, + Description: "GitHub personal access token for authentication. This token is required to authenticate with the GitHub API. You can obtain a token from GitHub Settings > Developer settings > Personal access tokens. Alternatively, you can set the GITHUB_TOKEN environment variable, or the provider will automatically use GitHub CLI authentication (gh auth token) if available.", + }, + "oauth_token": schema.StringAttribute{ + Optional: true, + Sensitive: true, + Description: "GitHub OAuth token for authentication. This is an alternative to the personal access token.", + }, + "app_auth": schema.SingleNestedAttribute{ + Optional: true, + Description: "GitHub App authentication configuration. Requires app_id, installation_id, and pem_file.", + Attributes: map[string]schema.Attribute{ + "id": schema.Int64Attribute{ + Required: true, + Description: "The GitHub App ID.", + }, + "installation_id": schema.Int64Attribute{ + Required: true, + Description: "The GitHub App installation ID.", + }, + "pem_file": schema.StringAttribute{ + Required: true, + Sensitive: false, + Description: "Path to the GitHub App private key PEM file.", + }, + }, + }, + "base_url": schema.StringAttribute{ + Optional: true, + Description: "The GitHub Base API URL. Defaults to `https://api.github.com/`. Set this to your GitHub Enterprise Server API URL (e.g., `https://github.example.com/api/v3/`).", + }, + "owner": schema.StringAttribute{ + Optional: true, + Description: "The GitHub owner name to manage. Use this field when managing individual accounts or organizations.", + }, + "insecure": schema.BoolAttribute{ + Optional: true, + Description: "Enable insecure mode for testing purposes. This disables TLS certificate verification. Use only in development/testing environments.", + }, + }, + } +} + +// Configure prepares a GitHub API client for data sources and resources. +func (p *githubxProvider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) { + var config githubxProviderModel + + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + + if resp.Diagnostics.HasError() { + return + } + + // Authentication precedence: + // 1. Provider token attribute + // 2. Provider oauth_token attribute + // 3. GITHUB_TOKEN environment variable + // 4. GitHub CLI (gh auth token) + // 5. GitHub App authentication + // 6. Unauthenticated (fallback) + + var client *github.Client + var token string + + // 1. Check provider token attribute + token = config.Token.ValueString() + + // 2. Check provider oauth_token attribute + if token == "" { + token = config.OAuthToken.ValueString() + } + + // 3. Check GITHUB_TOKEN environment variable + if token == "" { + token = os.Getenv("GITHUB_TOKEN") + } + + // 4. Check GitHub CLI authentication + if token == "" { + if ghToken := getGitHubCLIToken(); ghToken != "" { + token = ghToken + } + } + + // Parse base URL (default to GitHub.com API) - needed before App Auth + baseURLStr := config.BaseURL.ValueString() + if baseURLStr == "" { + baseURLStr = os.Getenv("GITHUB_BASE_URL") + } + if baseURLStr == "" { + baseURLStr = "https://api.github.com/" + } + + // Parse and validate base URL + baseURL, err := url.Parse(baseURLStr) + if err != nil { + resp.Diagnostics.AddError( + "Invalid Base URL", + fmt.Sprintf("Unable to parse base_url: %v", err), + ) + return + } + // Ensure base URL ends with / + if !strings.HasSuffix(baseURL.Path, "/") { + baseURL.Path += "/" + } + + // 5. Check GitHub App authentication (needs baseURL) + if token == "" && config.AppAuth != nil { + appToken, appResp := getGitHubAppToken(ctx, config.AppAuth, baseURL) + if appResp != nil && appResp.Diagnostics.HasError() { + resp.Diagnostics.Append(appResp.Diagnostics...) + return + } + if appToken != "" { + token = appToken + } + } + + // Get insecure mode setting + insecure := config.Insecure.ValueBool() + if !insecure { + // Check environment variable + if os.Getenv("GITHUB_INSECURE") == "true" { + insecure = true + } + } + + // Create HTTP client with optional insecure TLS + var httpClient *http.Client + if token != "" { + ts := oauth2.StaticTokenSource( + &oauth2.Token{AccessToken: token}, + ) + httpClient = oauth2.NewClient(ctx, ts) + } else { + httpClient = &http.Client{} + } + + // Configure insecure TLS if requested + if insecure { + tr := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + httpClient.Transport = tr + } + + // Create GitHub client + client = github.NewClient(httpClient) + + // Set base URL if not default + if baseURL.String() != "https://api.github.com/" { + client.BaseURL = baseURL + } + + // Get owner from config or environment + owner := config.Owner.ValueString() + if owner == "" { + owner = os.Getenv("GITHUB_OWNER") + } + + // If owner is still not set and we have a token, fetch the authenticated user + if owner == "" && token != "" { + user, _, err := client.Users.Get(ctx, "") + if err == nil && user != nil { + owner = user.GetLogin() + } + // If we can't get the user, owner will remain empty and resources can try again + } + + // Store the client for use in resources and data sources + clientData := githubxClientData{ + Client: client, + Owner: owner, + } + + resp.ResourceData = clientData + resp.DataSourceData = clientData +} + +// DataSources defines the data sources implemented in the provider. +func (p *githubxProvider) DataSources(_ context.Context) []func() datasource.DataSource { + return []func() datasource.DataSource{ + NewUserDataSource, + NewRepositoryDataSource, + NewRepositoryBranchDataSource, + NewRepositoryFileDataSource, + } +} + +// Resources defines the resources implemented in the provider. +func (p *githubxProvider) Resources(_ context.Context) []func() resource.Resource { + return []func() resource.Resource{ + NewRepositoryResource, + NewRepositoryBranchResource, + NewRepositoryFileResource, + NewRepositoryPullRequestAutoMergeResource, + } +} + +// getGitHubCLIToken attempts to retrieve a token from the GitHub CLI. +// Returns an empty string if gh CLI is not available or not authenticated. +func getGitHubCLIToken() string { + // Try to get token from 'gh auth token' command + cmd := exec.Command("gh", "auth", "token") + output, err := cmd.Output() + if err != nil { + // gh CLI not available or not authenticated + return "" + } + // Trim whitespace from the token + token := strings.TrimSpace(string(output)) + if token != "" { + return token + } + return "" +} + +// getGitHubAppToken generates an installation access token for a GitHub App. +func getGitHubAppToken(ctx context.Context, appAuth *appAuthModel, baseURL *url.URL) (string, *provider.ConfigureResponse) { + resp := &provider.ConfigureResponse{} + + appID := appAuth.ID.ValueInt64() + installationID := appAuth.InstallationID.ValueInt64() + pemFile := appAuth.PEMFile.ValueString() + + if appID == 0 { + resp.Diagnostics.AddError( + "Invalid App ID", + "GitHub App ID must be provided and greater than 0.", + ) + return "", resp + } + + if installationID == 0 { + resp.Diagnostics.AddError( + "Invalid Installation ID", + "GitHub App Installation ID must be provided and greater than 0.", + ) + return "", resp + } + + if pemFile == "" { + resp.Diagnostics.AddError( + "Missing PEM File", + "GitHub App PEM file path must be provided.", + ) + return "", resp + } + + // Read PEM file + pemData, err := os.ReadFile(pemFile) + if err != nil { + resp.Diagnostics.AddError( + "Failed to Read PEM File", + fmt.Sprintf("Unable to read GitHub App private key file: %v", err), + ) + return "", resp + } + + // Parse PEM block + block, _ := pem.Decode(pemData) + if block == nil { + resp.Diagnostics.AddError( + "Invalid PEM File", + "Failed to decode PEM file. Ensure the file contains a valid RSA private key.", + ) + return "", resp + } + + // Parse RSA private key + privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + // Try PKCS8 format + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + resp.Diagnostics.AddError( + "Invalid Private Key", + fmt.Sprintf("Failed to parse private key: %v", err), + ) + return "", resp + } + var ok bool + privateKey, ok = key.(*rsa.PrivateKey) + if !ok { + resp.Diagnostics.AddError( + "Invalid Key Type", + "Private key must be an RSA key.", + ) + return "", resp + } + } + + // Generate JWT + now := time.Now() + token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{ + "iat": now.Add(-60 * time.Second).Unix(), // Issued at time (allow 60s clock skew) + "exp": now.Add(10 * time.Minute).Unix(), // Expires in 10 minutes + "iss": appID, // Issuer (App ID) + }) + + jwtToken, err := token.SignedString(privateKey) + if err != nil { + resp.Diagnostics.AddError( + "Failed to Sign JWT", + fmt.Sprintf("Unable to sign JWT token: %v", err), + ) + return "", resp + } + + // Create a temporary client with JWT to get installation token + tempHTTPClient := oauth2.NewClient(ctx, oauth2.StaticTokenSource( + &oauth2.Token{AccessToken: jwtToken}, + )) + tempClient := github.NewClient(tempHTTPClient) + + // Set base URL if not default + if baseURL.String() != "https://api.github.com/" { + tempClient.BaseURL = baseURL + } + + // Get installation token + installationToken, _, err := tempClient.Apps.CreateInstallationToken(ctx, installationID, &github.InstallationTokenOptions{}) + if err != nil { + resp.Diagnostics.AddError( + "Failed to Create Installation Token", + fmt.Sprintf("Unable to create installation access token: %v", err), + ) + return "", resp + } + + return installationToken.GetToken(), resp +} diff --git a/internal/provider/resource_repository.go b/internal/provider/resource_repository.go new file mode 100644 index 0000000..f69aefc --- /dev/null +++ b/internal/provider/resource_repository.go @@ -0,0 +1,1505 @@ +package provider + +import ( + "context" + "fmt" + "log" + "net/http" + "regexp" + "sort" + "strings" + + "github.com/google/go-github/v60/github" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ resource.Resource = &repositoryResource{} + _ resource.ResourceWithConfigure = &repositoryResource{} + _ resource.ResourceWithImportState = &repositoryResource{} +) + +// NewRepositoryResource is a helper function to simplify the provider implementation. +func NewRepositoryResource() resource.Resource { + return &repositoryResource{} +} + +// repositoryResource is the resource implementation. +type repositoryResource struct { + client *github.Client + owner string + authenticatedUser string // Store authenticated user login for comparison +} + +// repositoryResourceModel maps the resource schema data. +type repositoryResourceModel struct { + Name types.String `tfsdk:"name"` + Description types.String `tfsdk:"description"` + HomepageURL types.String `tfsdk:"homepage_url"` + Visibility types.String `tfsdk:"visibility"` + HasIssues types.Bool `tfsdk:"has_issues"` + HasDiscussions types.Bool `tfsdk:"has_discussions"` + HasProjects types.Bool `tfsdk:"has_projects"` + HasDownloads types.Bool `tfsdk:"has_downloads"` + HasWiki types.Bool `tfsdk:"has_wiki"` + IsTemplate types.Bool `tfsdk:"is_template"` + AllowMergeCommit types.Bool `tfsdk:"allow_merge_commit"` + AllowSquashMerge types.Bool `tfsdk:"allow_squash_merge"` + AllowRebaseMerge types.Bool `tfsdk:"allow_rebase_merge"` + AllowAutoMerge types.Bool `tfsdk:"allow_auto_merge"` + AllowUpdateBranch types.Bool `tfsdk:"allow_update_branch"` + SquashMergeCommitTitle types.String `tfsdk:"squash_merge_commit_title"` + SquashMergeCommitMessage types.String `tfsdk:"squash_merge_commit_message"` + MergeCommitTitle types.String `tfsdk:"merge_commit_title"` + MergeCommitMessage types.String `tfsdk:"merge_commit_message"` + DeleteBranchOnMerge types.Bool `tfsdk:"delete_branch_on_merge"` + ArchiveOnDestroy types.Bool `tfsdk:"archive_on_destroy"` + Archived types.Bool `tfsdk:"archived"` + AutoInit types.Bool `tfsdk:"auto_init"` + Pages types.Object `tfsdk:"pages"` + Topics types.Set `tfsdk:"topics"` + VulnerabilityAlerts types.Bool `tfsdk:"vulnerability_alerts"` + ID types.String `tfsdk:"id"` + FullName types.String `tfsdk:"full_name"` + DefaultBranch types.String `tfsdk:"default_branch"` + HTMLURL types.String `tfsdk:"html_url"` + NodeID types.String `tfsdk:"node_id"` + RepoID types.Int64 `tfsdk:"repo_id"` +} + +// Metadata returns the resource type name. +func (r *repositoryResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_repository" +} + +// Schema defines the schema for the resource. +func (r *repositoryResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Creates and manages a GitHub repository.", + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Description: "The name of the repository.", + Required: true, + Validators: []validator.String{ + stringvalidator.RegexMatches( + regexp.MustCompile(`^[-a-zA-Z0-9_.]{1,100}$`), + "must include only alphanumeric characters, underscores or hyphens and consist of 100 characters or less", + ), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "description": schema.StringAttribute{ + Description: "A description of the repository.", + Optional: true, + }, + "homepage_url": schema.StringAttribute{ + Description: "URL of a page describing the project.", + Optional: true, + }, + "visibility": schema.StringAttribute{ + Description: "Can be 'public' or 'private'. If your organization is associated with an enterprise account using GitHub Enterprise Cloud or GitHub Enterprise Server 2.20+, visibility can also be 'internal'.", + Optional: true, + Computed: true, + Validators: []validator.String{ + stringvalidator.OneOf("public", "private", "internal"), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "has_issues": schema.BoolAttribute{ + Description: "Whether the repository has issues enabled.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + "has_discussions": schema.BoolAttribute{ + Description: "Whether the repository has discussions enabled.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + "has_projects": schema.BoolAttribute{ + Description: "Whether the repository has projects enabled.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + "has_downloads": schema.BoolAttribute{ + Description: "Whether the repository has downloads enabled.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + "has_wiki": schema.BoolAttribute{ + Description: "Whether the repository has wiki enabled.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + "is_template": schema.BoolAttribute{ + Description: "Whether the repository is a template.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + "allow_merge_commit": schema.BoolAttribute{ + Description: "Whether merge commits are allowed.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + "allow_squash_merge": schema.BoolAttribute{ + Description: "Whether squash merges are allowed.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + "allow_rebase_merge": schema.BoolAttribute{ + Description: "Whether rebase merges are allowed.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + "allow_auto_merge": schema.BoolAttribute{ + Description: "Whether auto-merge is enabled.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + "allow_update_branch": schema.BoolAttribute{ + Description: "Whether branch updates are allowed.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + "squash_merge_commit_title": schema.StringAttribute{ + Description: "The default commit title for squash merges. Can be 'PR_TITLE' or 'COMMIT_OR_PR_TITLE'.", + Optional: true, + Computed: true, + Validators: []validator.String{ + stringvalidator.OneOf("PR_TITLE", "COMMIT_OR_PR_TITLE"), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "squash_merge_commit_message": schema.StringAttribute{ + Description: "The default commit message for squash merges. Can be 'PR_BODY', 'COMMIT_MESSAGES', or 'BLANK'.", + Optional: true, + Computed: true, + Validators: []validator.String{ + stringvalidator.OneOf("PR_BODY", "COMMIT_MESSAGES", "BLANK"), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "merge_commit_title": schema.StringAttribute{ + Description: "The default commit title for merge commits. Can be 'PR_TITLE' or 'MERGE_MESSAGE'.", + Optional: true, + Computed: true, + Validators: []validator.String{ + stringvalidator.OneOf("PR_TITLE", "MERGE_MESSAGE"), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "merge_commit_message": schema.StringAttribute{ + Description: "The default commit message for merge commits. Can be 'PR_BODY', 'PR_TITLE', or 'BLANK'.", + Optional: true, + Computed: true, + Validators: []validator.String{ + stringvalidator.OneOf("PR_BODY", "PR_TITLE", "BLANK"), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "delete_branch_on_merge": schema.BoolAttribute{ + Description: "Whether to delete branches after merging pull requests.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + "archive_on_destroy": schema.BoolAttribute{ + Description: "Whether to archive the repository instead of deleting it when the resource is destroyed.", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + }, + "archived": schema.BoolAttribute{ + Description: "Whether the repository is archived.", + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + "auto_init": schema.BoolAttribute{ + Description: "Whether to initialize the repository with a README file. This will create the default branch.", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + }, + "pages": schema.SingleNestedAttribute{ + Description: "The GitHub Pages configuration for the repository.", + Optional: true, + Computed: true, + Attributes: map[string]schema.Attribute{ + "source": schema.SingleNestedAttribute{ + Optional: true, + Attributes: map[string]schema.Attribute{ + "branch": schema.StringAttribute{ + Required: true, + }, + "path": schema.StringAttribute{ + Optional: true, + Computed: true, + Default: stringdefault.StaticString("/"), + }, + }, + }, + "build_type": schema.StringAttribute{ + Optional: true, + Validators: []validator.String{ + stringvalidator.OneOf("legacy", "workflow"), + }, + }, + "cname": schema.StringAttribute{ + Optional: true, + }, + "custom_404": schema.BoolAttribute{ + Computed: true, + }, + "html_url": schema.StringAttribute{ + Computed: true, + }, + "status": schema.StringAttribute{ + Computed: true, + }, + "url": schema.StringAttribute{ + Computed: true, + }, + }, + }, + "topics": schema.SetAttribute{ + Description: "The topics (tags) associated with the repository. Order does not matter as topics are stored as a set.", + ElementType: types.StringType, + Optional: true, + }, + "vulnerability_alerts": schema.BoolAttribute{ + Description: "Whether vulnerability alerts are enabled for the repository.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + "id": schema.StringAttribute{ + Description: "The repository name (same as `name`).", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "full_name": schema.StringAttribute{ + Description: "The full name of the repository (owner/repo).", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "default_branch": schema.StringAttribute{ + Description: "The default branch of the repository.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "html_url": schema.StringAttribute{ + Description: "The HTML URL of the repository.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "node_id": schema.StringAttribute{ + Description: "The GitHub node ID of the repository.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "repo_id": schema.Int64Attribute{ + Description: "The GitHub repository ID as an integer.", + Computed: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.UseStateForUnknown(), + }, + }, + }, + } +} + +// Configure enables provider-level data or clients to be set in the +// provider-defined resource type. +func (r *repositoryResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + clientData, ok := req.ProviderData.(githubxClientData) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected githubxClientData, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + return + } + + r.client = clientData.Client + r.owner = clientData.Owner + + // Try to get authenticated user for comparison + if r.client != nil { + user, _, err := r.client.Users.Get(ctx, "") + if err == nil && user != nil && user.Login != nil { + r.authenticatedUser = user.GetLogin() + } + } +} + +// Create creates the resource and sets the initial Terraform state. +func (r *repositoryResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan repositoryResourceModel + + // Read Terraform plan data into the model + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + if r.client == nil { + resp.Diagnostics.AddError( + "Client Error", + "GitHub client is not configured. Please ensure a token is provided.", + ) + return + } + + // Get owner, falling back to authenticated user if not set + owner, err := r.getOwner(ctx) + if err != nil { + resp.Diagnostics.AddError( + "Missing Owner", + fmt.Sprintf("Unable to determine owner: %v. Please set provider-level `owner` configuration or ensure authentication is working.", err), + ) + return + } + + if plan.Topics.IsUnknown() { + plan.Topics = types.SetNull(types.StringType) + } + + repoReq := &github.Repository{ + Name: github.String(plan.Name.ValueString()), + Description: github.String(plan.Description.ValueString()), + } + + if !plan.HomepageURL.IsNull() && plan.HomepageURL.ValueString() != "" { + repoReq.Homepage = github.String(plan.HomepageURL.ValueString()) + } + + if !plan.Visibility.IsNull() { + visibility := plan.Visibility.ValueString() + repoReq.Visibility = github.String(visibility) + if visibility == "private" { + repoReq.Private = github.Bool(true) + } else { + repoReq.Private = github.Bool(false) + } + } + + if !plan.HasIssues.IsNull() { + repoReq.HasIssues = github.Bool(plan.HasIssues.ValueBool()) + } + if !plan.HasDiscussions.IsNull() { + repoReq.HasDiscussions = github.Bool(plan.HasDiscussions.ValueBool()) + } + if !plan.HasProjects.IsNull() { + repoReq.HasProjects = github.Bool(plan.HasProjects.ValueBool()) + } + if !plan.HasDownloads.IsNull() { + repoReq.HasDownloads = github.Bool(plan.HasDownloads.ValueBool()) + } + if !plan.HasWiki.IsNull() { + repoReq.HasWiki = github.Bool(plan.HasWiki.ValueBool()) + } + if !plan.IsTemplate.IsNull() { + repoReq.IsTemplate = github.Bool(plan.IsTemplate.ValueBool()) + } + + // GitHub requires at least one merge method to be enabled + hasMergeCommit := !plan.AllowMergeCommit.IsNull() + hasSquashMerge := !plan.AllowSquashMerge.IsNull() + hasRebaseMerge := !plan.AllowRebaseMerge.IsNull() + + var allowMergeCommit, allowSquashMerge, allowRebaseMerge bool + + if !hasMergeCommit && !hasSquashMerge && !hasRebaseMerge { + allowMergeCommit = true + allowSquashMerge = true + allowRebaseMerge = true + } else { + if hasMergeCommit { + allowMergeCommit = plan.AllowMergeCommit.ValueBool() + } else { + allowMergeCommit = true + } + + if hasSquashMerge { + allowSquashMerge = plan.AllowSquashMerge.ValueBool() + } else { + allowSquashMerge = true + } + + if hasRebaseMerge { + allowRebaseMerge = plan.AllowRebaseMerge.ValueBool() + } else { + allowRebaseMerge = true + } + + if !allowMergeCommit && !allowSquashMerge && !allowRebaseMerge { + allowMergeCommit = true + } + } + + repoReq.AllowMergeCommit = github.Bool(allowMergeCommit) + repoReq.AllowSquashMerge = github.Bool(allowSquashMerge) + repoReq.AllowRebaseMerge = github.Bool(allowRebaseMerge) + if !plan.AllowAutoMerge.IsNull() { + repoReq.AllowAutoMerge = github.Bool(plan.AllowAutoMerge.ValueBool()) + } + if !plan.AllowUpdateBranch.IsNull() { + repoReq.AllowUpdateBranch = github.Bool(plan.AllowUpdateBranch.ValueBool()) + } + + // Only set squash merge commit settings if squash merge is enabled + // GitHub requires squash merge to be enabled to set these fields + if allowSquashMerge { + if !plan.SquashMergeCommitTitle.IsNull() && !plan.SquashMergeCommitTitle.IsUnknown() && plan.SquashMergeCommitTitle.ValueString() != "" { + repoReq.SquashMergeCommitTitle = github.String(plan.SquashMergeCommitTitle.ValueString()) + } + if !plan.SquashMergeCommitMessage.IsNull() && !plan.SquashMergeCommitMessage.IsUnknown() && plan.SquashMergeCommitMessage.ValueString() != "" { + repoReq.SquashMergeCommitMessage = github.String(plan.SquashMergeCommitMessage.ValueString()) + } + } + + if allowMergeCommit { + hasMergeTitle := !plan.MergeCommitTitle.IsNull() && !plan.MergeCommitTitle.IsUnknown() && plan.MergeCommitTitle.ValueString() != "" + hasMergeMessage := !plan.MergeCommitMessage.IsNull() && !plan.MergeCommitMessage.IsUnknown() && plan.MergeCommitMessage.ValueString() != "" + + if hasMergeTitle && hasMergeMessage { + repoReq.MergeCommitTitle = github.String(plan.MergeCommitTitle.ValueString()) + repoReq.MergeCommitMessage = github.String(plan.MergeCommitMessage.ValueString()) + } + } + if !plan.DeleteBranchOnMerge.IsNull() { + repoReq.DeleteBranchOnMerge = github.Bool(plan.DeleteBranchOnMerge.ValueBool()) + } + + if !plan.AutoInit.IsNull() { + repoReq.AutoInit = github.Bool(plan.AutoInit.ValueBool()) + } + + createOwner := owner + if r.authenticatedUser != "" && owner == r.authenticatedUser { + createOwner = "" // Empty string creates under authenticated user + } + repo, _, err := r.client.Repositories.Create(ctx, createOwner, repoReq) + if err != nil { + resp.Diagnostics.AddError( + "Error creating repository", + fmt.Sprintf("Unable to create repository %s: %v", plan.Name.ValueString(), err), + ) + return + } + + if !plan.Topics.IsNull() && !plan.Topics.IsUnknown() { + topics := make([]string, 0, len(plan.Topics.Elements())) + resp.Diagnostics.Append(plan.Topics.ElementsAs(ctx, &topics, false)...) + if !resp.Diagnostics.HasError() && len(topics) > 0 { + sort.Strings(topics) + _, _, err := r.client.Repositories.ReplaceAllTopics(ctx, owner, repo.GetName(), topics) + if err != nil { + resp.Diagnostics.AddWarning( + "Error setting topics", + fmt.Sprintf("Unable to set topics: %v", err), + ) + } + } + } + + if !plan.Pages.IsNull() && !plan.Pages.IsUnknown() { + pageDiags := r.updatePages(ctx, owner, repo.GetName(), plan.Pages) + for _, d := range pageDiags { + resp.Diagnostics.AddWarning( + d.Summary(), + d.Detail(), + ) + } + } + + if !plan.VulnerabilityAlerts.IsNull() { + diags := r.updateVulnerabilityAlerts(ctx, owner, repo.GetName(), plan.VulnerabilityAlerts.ValueBool()) + resp.Diagnostics.Append(diags...) + } + + explicitHasWiki := plan.HasWiki + explicitHasIssues := plan.HasIssues + explicitHasProjects := plan.HasProjects + explicitHasDownloads := plan.HasDownloads + explicitHasDiscussions := plan.HasDiscussions + explicitPages := plan.Pages + + r.readRepository(ctx, owner, repo.GetName(), &plan, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + if !explicitHasWiki.IsNull() && !explicitHasWiki.IsUnknown() { + plan.HasWiki = explicitHasWiki + } + if !explicitHasIssues.IsNull() && !explicitHasIssues.IsUnknown() { + plan.HasIssues = explicitHasIssues + } + if !explicitHasProjects.IsNull() && !explicitHasProjects.IsUnknown() { + plan.HasProjects = explicitHasProjects + } + if !explicitHasDownloads.IsNull() && !explicitHasDownloads.IsUnknown() { + plan.HasDownloads = explicitHasDownloads + } + if !explicitHasDiscussions.IsNull() && !explicitHasDiscussions.IsUnknown() { + plan.HasDiscussions = explicitHasDiscussions + } + + if !explicitPages.IsNull() && !explicitPages.IsUnknown() { + pages, _, err := r.client.Repositories.GetPagesInfo(ctx, owner, repo.GetName()) + if err == nil && pages != nil { + githubPagesObj, pageDiags := flattenPages(ctx, pages) + if !pageDiags.HasError() { + mergedPages, mergeDiags := r.mergePagesValues(ctx, explicitPages, githubPagesObj) + resp.Diagnostics.Append(mergeDiags...) + if !mergeDiags.HasError() { + plan.Pages = mergedPages + } else { + plan.Pages = explicitPages + } + } else { + plan.Pages = explicitPages + } + } else { + mergedPages, mergeDiags := r.mergePagesValues(ctx, explicitPages, types.ObjectNull(pagesObjectAttributeTypes())) + resp.Diagnostics.Append(mergeDiags...) + if !mergeDiags.HasError() { + plan.Pages = mergedPages + } else { + plan.Pages = explicitPages + } + } + } else { + plan.Pages = types.ObjectNull(pagesObjectAttributeTypes()) + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +// Read refreshes the Terraform state with the latest data. +func (r *repositoryResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state repositoryResourceModel + + // Read Terraform state data into the model + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + if r.client == nil { + resp.Diagnostics.AddError( + "Client Error", + "GitHub client is not configured. Please ensure a token is provided.", + ) + return + } + + // Get owner, falling back to authenticated user if not set + owner, err := r.getOwner(ctx) + if err != nil { + resp.Diagnostics.AddError( + "Missing Owner", + fmt.Sprintf("Unable to determine owner: %v. Please set provider-level `owner` configuration or ensure authentication is working.", err), + ) + return + } + + repoName := state.ID.ValueString() + if repoName == "" { + resp.Diagnostics.AddError( + "Missing Repository Name", + "The repository name (id) is required.", + ) + return + } + + existingHasWiki := state.HasWiki + existingHasIssues := state.HasIssues + existingHasProjects := state.HasProjects + existingHasDownloads := state.HasDownloads + existingHasDiscussions := state.HasDiscussions + existingPages := state.Pages + + r.readRepository(ctx, owner, repoName, &state, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + if !existingHasWiki.IsNull() && !existingHasWiki.IsUnknown() { + state.HasWiki = existingHasWiki + } + if !existingHasIssues.IsNull() && !existingHasIssues.IsUnknown() { + state.HasIssues = existingHasIssues + } + if !existingHasProjects.IsNull() && !existingHasProjects.IsUnknown() { + state.HasProjects = existingHasProjects + } + if !existingHasDownloads.IsNull() && !existingHasDownloads.IsUnknown() { + state.HasDownloads = existingHasDownloads + } + if !existingHasDiscussions.IsNull() && !existingHasDiscussions.IsUnknown() { + state.HasDiscussions = existingHasDiscussions + } + + if !existingPages.IsNull() && !existingPages.IsUnknown() { + pages, _, err := r.client.Repositories.GetPagesInfo(ctx, owner, repoName) + if err == nil && pages != nil { + pagesObj, pageDiags := flattenPages(ctx, pages) + if !pageDiags.HasError() { + mergedPages, mergeDiags := r.mergePagesValues(ctx, existingPages, pagesObj) + resp.Diagnostics.Append(mergeDiags...) + if !mergeDiags.HasError() { + state.Pages = mergedPages + } else { + state.Pages = existingPages + } + } else { + state.Pages = existingPages + } + } else { + state.Pages = existingPages + } + } else { + state.Pages = types.ObjectNull(pagesObjectAttributeTypes()) + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +// Update updates the resource and sets the updated Terraform state on success. +func (r *repositoryResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, state repositoryResourceModel + + // Read Terraform plan and state data into the models + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + if r.client == nil { + resp.Diagnostics.AddError( + "Client Error", + "GitHub client is not configured. Please ensure a token is provided.", + ) + return + } + + // Get owner, falling back to authenticated user if not set + owner, err := r.getOwner(ctx) + if err != nil { + resp.Diagnostics.AddError( + "Missing Owner", + fmt.Sprintf("Unable to determine owner: %v. Please set provider-level `owner` configuration or ensure authentication is working.", err), + ) + return + } + + if plan.Topics.IsUnknown() { + plan.Topics = types.SetNull(types.StringType) + } + + repoName := state.ID.ValueString() + repoReq := &github.Repository{} + + if !plan.Description.Equal(state.Description) { + repoReq.Description = github.String(plan.Description.ValueString()) + } + + if !plan.HomepageURL.Equal(state.HomepageURL) { + if !plan.HomepageURL.IsNull() && plan.HomepageURL.ValueString() != "" { + repoReq.Homepage = github.String(plan.HomepageURL.ValueString()) + } else { + repoReq.Homepage = github.String("") + } + } + + if !plan.Visibility.Equal(state.Visibility) { + visibility := plan.Visibility.ValueString() + repoReq.Visibility = github.String(visibility) + if visibility == "private" { + repoReq.Private = github.Bool(true) + } else { + repoReq.Private = github.Bool(false) + } + } + + if !plan.HasIssues.Equal(state.HasIssues) { + repoReq.HasIssues = github.Bool(plan.HasIssues.ValueBool()) + } + if !plan.HasDiscussions.Equal(state.HasDiscussions) { + repoReq.HasDiscussions = github.Bool(plan.HasDiscussions.ValueBool()) + } + if !plan.HasProjects.Equal(state.HasProjects) { + repoReq.HasProjects = github.Bool(plan.HasProjects.ValueBool()) + } + if !plan.HasDownloads.Equal(state.HasDownloads) { + repoReq.HasDownloads = github.Bool(plan.HasDownloads.ValueBool()) + } + if !plan.HasWiki.Equal(state.HasWiki) { + repoReq.HasWiki = github.Bool(plan.HasWiki.ValueBool()) + } + if !plan.IsTemplate.Equal(state.IsTemplate) { + repoReq.IsTemplate = github.Bool(plan.IsTemplate.ValueBool()) + } + + allowMergeCommit := state.AllowMergeCommit.ValueBool() + allowSquashMerge := state.AllowSquashMerge.ValueBool() + allowRebaseMerge := state.AllowRebaseMerge.ValueBool() + + if !plan.AllowMergeCommit.IsNull() && !plan.AllowMergeCommit.IsUnknown() { + if !plan.AllowMergeCommit.Equal(state.AllowMergeCommit) { + allowMergeCommit = plan.AllowMergeCommit.ValueBool() + repoReq.AllowMergeCommit = github.Bool(allowMergeCommit) + } + } + if !plan.AllowSquashMerge.IsNull() && !plan.AllowSquashMerge.IsUnknown() { + if !plan.AllowSquashMerge.Equal(state.AllowSquashMerge) { + allowSquashMerge = plan.AllowSquashMerge.ValueBool() + repoReq.AllowSquashMerge = github.Bool(allowSquashMerge) + } + } + if !plan.AllowRebaseMerge.IsNull() && !plan.AllowRebaseMerge.IsUnknown() { + if !plan.AllowRebaseMerge.Equal(state.AllowRebaseMerge) { + allowRebaseMerge = plan.AllowRebaseMerge.ValueBool() + repoReq.AllowRebaseMerge = github.Bool(allowRebaseMerge) + } + } + + finalAllowMergeCommit := allowMergeCommit + finalAllowSquashMerge := allowSquashMerge + finalAllowRebaseMerge := allowRebaseMerge + + if repoReq.AllowMergeCommit != nil { + finalAllowMergeCommit = *repoReq.AllowMergeCommit + } + if repoReq.AllowSquashMerge != nil { + finalAllowSquashMerge = *repoReq.AllowSquashMerge + } + if repoReq.AllowRebaseMerge != nil { + finalAllowRebaseMerge = *repoReq.AllowRebaseMerge + } + + if !finalAllowMergeCommit && !finalAllowSquashMerge && !finalAllowRebaseMerge { + finalAllowMergeCommit = true + repoReq.AllowMergeCommit = github.Bool(true) + allowMergeCommit = true + } + + if !plan.AllowAutoMerge.IsNull() && !plan.AllowAutoMerge.IsUnknown() { + if !plan.AllowAutoMerge.Equal(state.AllowAutoMerge) { + repoReq.AllowAutoMerge = github.Bool(plan.AllowAutoMerge.ValueBool()) + } + } + if !plan.AllowUpdateBranch.IsNull() && !plan.AllowUpdateBranch.IsUnknown() { + if !plan.AllowUpdateBranch.Equal(state.AllowUpdateBranch) { + repoReq.AllowUpdateBranch = github.Bool(plan.AllowUpdateBranch.ValueBool()) + } + } + + // Only set squash merge commit settings if squash merge is enabled + // GitHub requires squash merge to be enabled to set these fields + if allowSquashMerge { + if !plan.SquashMergeCommitTitle.IsNull() && !plan.SquashMergeCommitTitle.IsUnknown() && plan.SquashMergeCommitTitle.ValueString() != "" { + if !plan.SquashMergeCommitTitle.Equal(state.SquashMergeCommitTitle) { + repoReq.SquashMergeCommitTitle = github.String(plan.SquashMergeCommitTitle.ValueString()) + } + } + if !plan.SquashMergeCommitMessage.IsNull() && !plan.SquashMergeCommitMessage.IsUnknown() && plan.SquashMergeCommitMessage.ValueString() != "" { + if !plan.SquashMergeCommitMessage.Equal(state.SquashMergeCommitMessage) { + repoReq.SquashMergeCommitMessage = github.String(plan.SquashMergeCommitMessage.ValueString()) + } + } + } + + if allowMergeCommit { + hasMergeTitle := !plan.MergeCommitTitle.IsNull() && !plan.MergeCommitTitle.IsUnknown() && plan.MergeCommitTitle.ValueString() != "" + hasMergeMessage := !plan.MergeCommitMessage.IsNull() && !plan.MergeCommitMessage.IsUnknown() && plan.MergeCommitMessage.ValueString() != "" + + if hasMergeTitle && hasMergeMessage { + titleChanged := !plan.MergeCommitTitle.Equal(state.MergeCommitTitle) + messageChanged := !plan.MergeCommitMessage.Equal(state.MergeCommitMessage) + + if titleChanged || messageChanged { + repoReq.MergeCommitTitle = github.String(plan.MergeCommitTitle.ValueString()) + repoReq.MergeCommitMessage = github.String(plan.MergeCommitMessage.ValueString()) + } + } + } + + if !plan.DeleteBranchOnMerge.Equal(state.DeleteBranchOnMerge) { + repoReq.DeleteBranchOnMerge = github.Bool(plan.DeleteBranchOnMerge.ValueBool()) + } + + if !plan.Archived.Equal(state.Archived) { + repoReq.Archived = github.Bool(plan.Archived.ValueBool()) + } + + if repoReq.AllowMergeCommit != nil || repoReq.AllowSquashMerge != nil || repoReq.AllowRebaseMerge != nil { + if !finalAllowMergeCommit && !finalAllowSquashMerge && !finalAllowRebaseMerge { + repoReq.AllowMergeCommit = github.Bool(true) + } + } else { + hasOtherChanges := repoReq.Description != nil || repoReq.Homepage != nil || repoReq.Visibility != nil || + repoReq.Private != nil || repoReq.HasIssues != nil || repoReq.HasDiscussions != nil || + repoReq.HasProjects != nil || repoReq.HasDownloads != nil || repoReq.HasWiki != nil || + repoReq.IsTemplate != nil + + if hasOtherChanges { + if !allowMergeCommit && !allowSquashMerge && !allowRebaseMerge { + allowMergeCommit = true + } + repoReq.AllowMergeCommit = github.Bool(allowMergeCommit) + repoReq.AllowSquashMerge = github.Bool(allowSquashMerge) + repoReq.AllowRebaseMerge = github.Bool(allowRebaseMerge) + } + } + + hasChanges := repoReq.Description != nil || repoReq.Homepage != nil || + repoReq.Visibility != nil || repoReq.Private != nil || + repoReq.HasIssues != nil || repoReq.HasDiscussions != nil || + repoReq.HasProjects != nil || repoReq.HasDownloads != nil || + repoReq.HasWiki != nil || repoReq.IsTemplate != nil || + repoReq.AllowMergeCommit != nil || repoReq.AllowSquashMerge != nil || + repoReq.AllowRebaseMerge != nil || repoReq.AllowAutoMerge != nil || + repoReq.AllowUpdateBranch != nil || repoReq.SquashMergeCommitTitle != nil || + repoReq.SquashMergeCommitMessage != nil || repoReq.MergeCommitTitle != nil || + repoReq.MergeCommitMessage != nil || repoReq.DeleteBranchOnMerge != nil || + repoReq.Archived != nil + + if hasChanges { + _, _, err := r.client.Repositories.Edit(ctx, owner, repoName, repoReq) + if err != nil { + if !strings.Contains(err.Error(), "422 Privacy is already set") { + resp.Diagnostics.AddError( + "Error updating repository", + fmt.Sprintf("Unable to update repository %s: %v", repoName, err), + ) + return + } + } + } + + if !plan.Topics.Equal(state.Topics) { + if !plan.Topics.IsNull() && !plan.Topics.IsUnknown() { + topics := make([]string, 0, len(plan.Topics.Elements())) + resp.Diagnostics.Append(plan.Topics.ElementsAs(ctx, &topics, false)...) + if !resp.Diagnostics.HasError() { + sort.Strings(topics) + _, _, err := r.client.Repositories.ReplaceAllTopics(ctx, owner, repoName, topics) + if err != nil { + resp.Diagnostics.AddWarning( + "Error updating topics", + fmt.Sprintf("Unable to update topics: %v", err), + ) + } + } + } else { + _, _, err := r.client.Repositories.ReplaceAllTopics(ctx, owner, repoName, []string{}) + if err != nil { + resp.Diagnostics.AddWarning( + "Error clearing topics", + fmt.Sprintf("Unable to clear topics: %v", err), + ) + } + } + } + + if !plan.Pages.IsNull() && !plan.Pages.IsUnknown() { + pagesChanged := false + if state.Pages.IsNull() || state.Pages.IsUnknown() { + pagesChanged = true + } else { + var planModel, stateModel pagesModel + planDiags := plan.Pages.As(ctx, &planModel, basetypes.ObjectAsOptions{}) + stateDiags := state.Pages.As(ctx, &stateModel, basetypes.ObjectAsOptions{}) + if !planDiags.HasError() && !stateDiags.HasError() { + if !planModel.Source.Equal(stateModel.Source) || + !planModel.BuildType.Equal(stateModel.BuildType) || + !planModel.CNAME.Equal(stateModel.CNAME) { + pagesChanged = true + } + } else { + pagesChanged = true + } + } + + if pagesChanged { + diags := r.updatePages(ctx, owner, repoName, plan.Pages) + resp.Diagnostics.Append(diags...) + } + } + + if !plan.VulnerabilityAlerts.Equal(state.VulnerabilityAlerts) { + diags := r.updateVulnerabilityAlerts(ctx, owner, repoName, plan.VulnerabilityAlerts.ValueBool()) + resp.Diagnostics.Append(diags...) + } + + planHasWiki := plan.HasWiki + planHasIssues := plan.HasIssues + planHasProjects := plan.HasProjects + planHasDownloads := plan.HasDownloads + planHasDiscussions := plan.HasDiscussions + planPages := plan.Pages + + r.readRepository(ctx, owner, repoName, &plan, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + if planHasWiki.Equal(state.HasWiki) { + plan.HasWiki = state.HasWiki + } else if !planHasWiki.IsNull() && !planHasWiki.IsUnknown() { + plan.HasWiki = planHasWiki + } else if !state.HasWiki.IsNull() && !state.HasWiki.IsUnknown() { + plan.HasWiki = state.HasWiki + } + + if planHasIssues.Equal(state.HasIssues) { + plan.HasIssues = state.HasIssues + } else if !planHasIssues.IsNull() && !planHasIssues.IsUnknown() { + plan.HasIssues = planHasIssues + } else if !state.HasIssues.IsNull() && !state.HasIssues.IsUnknown() { + plan.HasIssues = state.HasIssues + } + + if planHasProjects.Equal(state.HasProjects) { + plan.HasProjects = state.HasProjects + } else if !planHasProjects.IsNull() && !planHasProjects.IsUnknown() { + plan.HasProjects = planHasProjects + } else if !state.HasProjects.IsNull() && !state.HasProjects.IsUnknown() { + plan.HasProjects = state.HasProjects + } + + if planHasDownloads.Equal(state.HasDownloads) { + plan.HasDownloads = state.HasDownloads + } else if !planHasDownloads.IsNull() && !planHasDownloads.IsUnknown() { + plan.HasDownloads = planHasDownloads + } else if !state.HasDownloads.IsNull() && !state.HasDownloads.IsUnknown() { + plan.HasDownloads = state.HasDownloads + } + + if planHasDiscussions.Equal(state.HasDiscussions) { + plan.HasDiscussions = state.HasDiscussions + } else if !planHasDiscussions.IsNull() && !planHasDiscussions.IsUnknown() { + plan.HasDiscussions = planHasDiscussions + } else if !state.HasDiscussions.IsNull() && !state.HasDiscussions.IsUnknown() { + plan.HasDiscussions = state.HasDiscussions + } + + if planPages.IsNull() || planPages.IsUnknown() { + plan.Pages = types.ObjectNull(pagesObjectAttributeTypes()) + } else { + pages, _, err := r.client.Repositories.GetPagesInfo(ctx, owner, repoName) + if err == nil && pages != nil { + githubPagesObj, pageDiags := flattenPages(ctx, pages) + if !pageDiags.HasError() { + mergedPages, mergeDiags := r.mergePagesValues(ctx, planPages, githubPagesObj) + resp.Diagnostics.Append(mergeDiags...) + if !mergeDiags.HasError() { + plan.Pages = mergedPages + } else { + plan.Pages = planPages + } + } else { + plan.Pages = planPages + } + } else { + mergedPages, mergeDiags := r.mergePagesValues(ctx, planPages, types.ObjectNull(pagesObjectAttributeTypes())) + resp.Diagnostics.Append(mergeDiags...) + if !mergeDiags.HasError() { + plan.Pages = mergedPages + } else { + plan.Pages = planPages + } + } + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +// Delete deletes the resource and removes the Terraform state on success. +func (r *repositoryResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state repositoryResourceModel + + // Read Terraform state data into the model + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + if r.client == nil { + resp.Diagnostics.AddError( + "Client Error", + "GitHub client is not configured. Please ensure a token is provided.", + ) + return + } + + // Get owner, falling back to authenticated user if not set + owner, err := r.getOwner(ctx) + if err != nil { + resp.Diagnostics.AddError( + "Missing Owner", + fmt.Sprintf("Unable to determine owner: %v. Please set provider-level `owner` configuration or ensure authentication is working.", err), + ) + return + } + + repoName := state.ID.ValueString() + archiveOnDestroy := state.ArchiveOnDestroy.ValueBool() + if archiveOnDestroy { + if state.Archived.ValueBool() { + log.Printf("[DEBUG] Repository already archived, nothing to do on delete: %s/%s", owner, repoName) + return + } + + repoReq := &github.Repository{ + Archived: github.Bool(true), + } + log.Printf("[DEBUG] Archiving repository on delete: %s/%s", owner, repoName) + _, _, err := r.client.Repositories.Edit(ctx, owner, repoName, repoReq) + if err != nil { + resp.Diagnostics.AddError( + "Error archiving repository", + fmt.Sprintf("Unable to archive repository %s: %v", repoName, err), + ) + } + return + } + + log.Printf("[DEBUG] Deleting repository: %s/%s", owner, repoName) + _, err = r.client.Repositories.Delete(ctx, owner, repoName) + if err != nil { + resp.Diagnostics.AddError( + "Error deleting repository", + fmt.Sprintf("Unable to delete repository %s: %v", repoName, err), + ) + return + } +} + +func (r *repositoryResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + parts := strings.Split(req.ID, "/") + var repoName string + + if len(parts) == 2 { + repoName = parts[1] + } else if len(parts) == 1 { + repoName = parts[0] + _, err := r.getOwner(ctx) + if err != nil { + resp.Diagnostics.AddError( + "Invalid Import ID", + fmt.Sprintf("Import ID must be in format 'owner/repo' or 'repo' (when provider-level owner is configured or authentication is available). Error: %v", err), + ) + return + } + } else { + resp.Diagnostics.AddError( + "Invalid Import ID", + "Import ID must be in format 'owner/repo' or 'repo'.", + ) + return + } + + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), repoName)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("name"), repoName)...) +} + +func (r *repositoryResource) getOwner(ctx context.Context) (string, error) { + if r.owner != "" { + return r.owner, nil + } + + user, _, err := r.client.Users.Get(ctx, "") + if err != nil { + return "", fmt.Errorf("unable to determine owner: provider-level `owner` is not set and unable to fetch authenticated user: %v", err) + } + if user == nil || user.Login == nil { + return "", fmt.Errorf("unable to determine owner: provider-level `owner` is not set and authenticated user information is unavailable") + } + return user.GetLogin(), nil +} + +func (r *repositoryResource) readRepository(ctx context.Context, owner, repoName string, model *repositoryResourceModel, diags *diag.Diagnostics) { + repo, _, err := r.client.Repositories.Get(ctx, owner, repoName) + if err != nil { + diags.AddError( + "Error reading repository", + fmt.Sprintf("Unable to read repository %s/%s: %v", owner, repoName, err), + ) + return + } + + model.ID = types.StringValue(repo.GetName()) + model.Name = types.StringValue(repo.GetName()) + + fullName := repo.GetFullName() + if fullName == "" { + fullName = fmt.Sprintf("%s/%s", owner, repo.GetName()) + } + model.FullName = types.StringValue(fullName) + + model.Description = types.StringValue(repo.GetDescription()) + + homepage := repo.GetHomepage() + if homepage == "" { + model.HomepageURL = types.StringNull() + } else { + model.HomepageURL = types.StringValue(homepage) + } + + visibility := repo.GetVisibility() + if visibility == "" { + visibility = "public" + } + model.Visibility = types.StringValue(visibility) + + model.HasIssues = types.BoolValue(repo.GetHasIssues()) + model.HasDiscussions = types.BoolValue(repo.GetHasDiscussions()) + model.HasProjects = types.BoolValue(repo.GetHasProjects()) + model.HasDownloads = types.BoolValue(repo.GetHasDownloads()) + model.HasWiki = types.BoolValue(repo.GetHasWiki()) + model.IsTemplate = types.BoolValue(repo.GetIsTemplate()) + model.AllowMergeCommit = types.BoolValue(repo.GetAllowMergeCommit()) + model.AllowSquashMerge = types.BoolValue(repo.GetAllowSquashMerge()) + model.AllowRebaseMerge = types.BoolValue(repo.GetAllowRebaseMerge()) + model.AllowAutoMerge = types.BoolValue(repo.GetAllowAutoMerge()) + model.AllowUpdateBranch = types.BoolValue(repo.GetAllowUpdateBranch()) + model.SquashMergeCommitTitle = types.StringValue(repo.GetSquashMergeCommitTitle()) + model.SquashMergeCommitMessage = types.StringValue(repo.GetSquashMergeCommitMessage()) + model.MergeCommitTitle = types.StringValue(repo.GetMergeCommitTitle()) + model.MergeCommitMessage = types.StringValue(repo.GetMergeCommitMessage()) + model.DeleteBranchOnMerge = types.BoolValue(repo.GetDeleteBranchOnMerge()) + model.Archived = types.BoolValue(repo.GetArchived()) + + defaultBranch := repo.GetDefaultBranch() + if defaultBranch == "" { + defaultBranch = "main" + } + model.DefaultBranch = types.StringValue(defaultBranch) + + htmlURL := repo.GetHTMLURL() + if htmlURL == "" { + htmlURL = fmt.Sprintf("https://github.com/%s/%s", owner, repo.GetName()) + } + model.HTMLURL = types.StringValue(htmlURL) + + nodeID := repo.GetNodeID() + model.NodeID = types.StringValue(nodeID) + + model.RepoID = types.Int64Value(repo.GetID()) + + if len(repo.Topics) > 0 { + sortedTopics := make([]string, len(repo.Topics)) + copy(sortedTopics, repo.Topics) + sort.Strings(sortedTopics) + + topics := make([]types.String, len(sortedTopics)) + for i, topic := range sortedTopics { + topics[i] = types.StringValue(topic) + } + topicsSet, topicDiags := types.SetValueFrom(ctx, types.StringType, topics) + diags.Append(topicDiags...) + model.Topics = topicsSet + } else { + model.Topics = types.SetNull(types.StringType) + } + + model.Pages = types.ObjectNull(pagesObjectAttributeTypes()) + + _, resp, err := r.client.Repositories.GetVulnerabilityAlerts(ctx, owner, repoName) + if err != nil { + if errResp, ok := err.(*github.ErrorResponse); ok && errResp != nil && errResp.Response != nil && errResp.Response.StatusCode == http.StatusNotFound { + model.VulnerabilityAlerts = types.BoolValue(false) + } else { + diags.AddWarning( + "Error reading vulnerability alerts", + fmt.Sprintf("Unable to read vulnerability alerts: %v", err), + ) + model.VulnerabilityAlerts = types.BoolNull() + } + } else { + enabled := resp != nil && resp.StatusCode == http.StatusNoContent + model.VulnerabilityAlerts = types.BoolValue(enabled) + } +} + +func (r *repositoryResource) mergePagesValues(ctx context.Context, planPages, githubPages types.Object) (types.Object, diag.Diagnostics) { + var diags diag.Diagnostics + + if githubPages.IsNull() || githubPages.IsUnknown() { + if planPages.IsNull() || planPages.IsUnknown() { + return types.ObjectNull(pagesObjectAttributeTypes()), diags + } + + var planModel pagesModel + diags.Append(planPages.As(ctx, &planModel, basetypes.ObjectAsOptions{})...) + if diags.HasError() { + return types.ObjectNull(pagesObjectAttributeTypes()), diags + } + + var source types.Object + if planModel.Source.IsNull() || planModel.Source.IsUnknown() { + source = types.ObjectNull(pagesSourceObjectAttributeTypes()) + } else { + var sourceModel pagesSourceModel + sourceDiags := planModel.Source.As(ctx, &sourceModel, basetypes.ObjectAsOptions{}) + if sourceDiags.HasError() { + source = types.ObjectNull(pagesSourceObjectAttributeTypes()) + } else { + branch := sourceModel.Branch + if branch.IsNull() || branch.IsUnknown() { + branch = types.StringNull() + } + path := sourceModel.Path + pathValue := path.ValueString() + if path.IsNull() || path.IsUnknown() || pathValue == "" { + path = types.StringValue("/") + } + sourceAttrs := map[string]attr.Value{ + "branch": branch, + "path": path, + } + sourceObj, sourceObjDiags := types.ObjectValue(pagesSourceObjectAttributeTypes(), sourceAttrs) + if sourceObjDiags.HasError() { + source = types.ObjectNull(pagesSourceObjectAttributeTypes()) + } else { + source = sourceObj + } + } + } + + buildType := planModel.BuildType + if buildType.IsUnknown() { + buildType = types.StringNull() + } + cname := planModel.CNAME + if cname.IsUnknown() { + cname = types.StringNull() + } + + attrs := map[string]attr.Value{ + "source": source, + "build_type": buildType, + "cname": cname, + "custom_404": types.BoolNull(), + "html_url": types.StringNull(), + "status": types.StringNull(), + "url": types.StringNull(), + } + + mergedObj, objDiags := types.ObjectValue(pagesObjectAttributeTypes(), attrs) + diags.Append(objDiags...) + return mergedObj, diags + } + + var planModel pagesModel + var githubModel pagesModel + + if !planPages.IsNull() && !planPages.IsUnknown() { + diags.Append(planPages.As(ctx, &planModel, basetypes.ObjectAsOptions{})...) + if diags.HasError() { + return types.ObjectNull(pagesObjectAttributeTypes()), diags + } + } + + diags.Append(githubPages.As(ctx, &githubModel, basetypes.ObjectAsOptions{})...) + if diags.HasError() { + return types.ObjectNull(pagesObjectAttributeTypes()), diags + } + + source := planModel.Source + if source.IsNull() || source.IsUnknown() { + source = githubModel.Source + } + buildType := planModel.BuildType + if buildType.IsNull() || buildType.IsUnknown() { + buildType = githubModel.BuildType + } + cname := planModel.CNAME + if cname.IsNull() || cname.IsUnknown() { + cname = githubModel.CNAME + } + + mergedAttrs := map[string]attr.Value{ + "source": source, + "build_type": buildType, + "cname": cname, + "custom_404": githubModel.Custom404, + "html_url": githubModel.HTMLURL, + "status": githubModel.Status, + "url": githubModel.URL, + } + + mergedObj, objDiags := types.ObjectValue(pagesObjectAttributeTypes(), mergedAttrs) + diags.Append(objDiags...) + return mergedObj, diags +} + +func (r *repositoryResource) updatePages(ctx context.Context, owner, repoName string, pagesObj types.Object) diag.Diagnostics { + var diags diag.Diagnostics + + if pagesObj.IsNull() || pagesObj.IsUnknown() { + log.Printf("[DEBUG] Pages configuration cannot be removed via API") + return diags + } + + var pagesModel pagesModel + diags.Append(pagesObj.As(ctx, &pagesModel, basetypes.ObjectAsOptions{})...) + if diags.HasError() { + return diags + } + + pagesUpdate := &github.PagesUpdate{} + + if !pagesModel.CNAME.IsNull() { + cname := pagesModel.CNAME.ValueString() + if cname != "" { + pagesUpdate.CNAME = github.String(cname) + } + } + + if !pagesModel.BuildType.IsNull() { + buildType := pagesModel.BuildType.ValueString() + if buildType != "" { + pagesUpdate.BuildType = github.String(buildType) + } + } + + if !pagesModel.Source.IsNull() { + var sourceModel pagesSourceModel + diags.Append(pagesModel.Source.As(ctx, &sourceModel, basetypes.ObjectAsOptions{})...) + if !diags.HasError() { + branch := sourceModel.Branch.ValueString() + path := sourceModel.Path.ValueString() + if path == "" || path == "/" { + path = "" + } + pagesUpdate.Source = &github.PagesSource{ + Branch: github.String(branch), + Path: github.String(path), + } + } + } + + _, err := r.client.Repositories.UpdatePages(ctx, owner, repoName, pagesUpdate) + if err != nil { + if errResp, ok := err.(*github.ErrorResponse); ok && errResp != nil && errResp.Response != nil && errResp.Response.StatusCode == http.StatusNotFound { + log.Printf("[DEBUG] Pages not yet available for repository %s/%s (404): %v. Pages will be configured once the repository has content.", owner, repoName, err) + } else { + diags.AddWarning( + "Error updating Pages", + fmt.Sprintf("Unable to update Pages configuration: %v. Pages may not be available until the repository has content.", err), + ) + } + } + + return diags +} + +func (r *repositoryResource) updateVulnerabilityAlerts(ctx context.Context, owner, repoName string, enabled bool) diag.Diagnostics { + var diags diag.Diagnostics + + if enabled { + _, err := r.client.Repositories.EnableVulnerabilityAlerts(ctx, owner, repoName) + if err != nil { + diags.AddWarning( + "Error enabling vulnerability alerts", + fmt.Sprintf("Unable to enable vulnerability alerts: %v", err), + ) + } + } else { + _, err := r.client.Repositories.DisableVulnerabilityAlerts(ctx, owner, repoName) + if err != nil { + diags.AddWarning( + "Error disabling vulnerability alerts", + fmt.Sprintf("Unable to disable vulnerability alerts: %v", err), + ) + } + } + + return diags +} diff --git a/internal/provider/resource_repository_branch.go b/internal/provider/resource_repository_branch.go new file mode 100644 index 0000000..32ba3cc --- /dev/null +++ b/internal/provider/resource_repository_branch.go @@ -0,0 +1,553 @@ +package provider + +import ( + "context" + "errors" + "fmt" + "log" + "net/http" + "strings" + + "github.com/google/go-github/v60/github" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ resource.Resource = &repositoryBranchResource{} + _ resource.ResourceWithConfigure = &repositoryBranchResource{} + _ resource.ResourceWithImportState = &repositoryBranchResource{} +) + +// NewRepositoryBranchResource is a helper function to simplify the provider implementation. +func NewRepositoryBranchResource() resource.Resource { + return &repositoryBranchResource{} +} + +// repositoryBranchResource is the resource implementation. +type repositoryBranchResource struct { + client *github.Client + owner string +} + +// repositoryBranchResourceModel maps the resource schema data. +type repositoryBranchResourceModel struct { + Repository types.String `tfsdk:"repository"` + Branch types.String `tfsdk:"branch"` + SourceBranch types.String `tfsdk:"source_branch"` + SourceSHA types.String `tfsdk:"source_sha"` + ETag types.String `tfsdk:"etag"` + Ref types.String `tfsdk:"ref"` + SHA types.String `tfsdk:"sha"` + ID types.String `tfsdk:"id"` +} + +// Metadata returns the resource type name. +func (r *repositoryBranchResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_repository_branch" +} + +// Schema defines the schema for the resource. +func (r *repositoryBranchResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Creates and manages a GitHub repository branch.", + Attributes: map[string]schema.Attribute{ + "repository": schema.StringAttribute{ + Description: "The GitHub repository name.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "branch": schema.StringAttribute{ + Description: "The repository branch to create.", + Required: true, + }, + "source_branch": schema.StringAttribute{ + Description: "The branch name to start from. Defaults to 'main'.", + Optional: true, + Computed: true, + Default: stringdefault.StaticString("main"), + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "source_sha": schema.StringAttribute{ + Description: "The commit hash to start from. Defaults to the tip of 'source_branch'. If provided, 'source_branch' is ignored.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "etag": schema.StringAttribute{ + Description: "An etag representing the Branch object.", + Optional: true, + Computed: true, + }, + "ref": schema.StringAttribute{ + Description: "A string representing a branch reference, in the form of 'refs/heads/'.", + Computed: true, + }, + "sha": schema.StringAttribute{ + Description: "A string storing the reference's HEAD commit's SHA1.", + Computed: true, + }, + "id": schema.StringAttribute{ + Description: "The Terraform state ID (repository/branch).", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + }, + } +} + +// Configure enables provider-level data or clients to be set in the +// provider-defined resource type. +func (r *repositoryBranchResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + clientData, ok := req.ProviderData.(githubxClientData) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected githubxClientData, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + return + } + + r.client = clientData.Client + r.owner = clientData.Owner +} + +// Create creates the resource and sets the initial Terraform state. +func (r *repositoryBranchResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan repositoryBranchResourceModel + + // Read Terraform plan data into the model + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + if r.client == nil { + resp.Diagnostics.AddError( + "Client Error", + "GitHub client is not configured. Please ensure a token is provided.", + ) + return + } + + // Get owner, falling back to authenticated user if not set + owner, err := r.getOwner(ctx) + if err != nil { + resp.Diagnostics.AddError( + "Missing Owner", + fmt.Sprintf("Unable to determine owner: %v. Please set provider-level `owner` configuration or ensure authentication is working.", err), + ) + return + } + + repoName := plan.Repository.ValueString() + branchName := plan.Branch.ValueString() + branchRefName := "refs/heads/" + branchName + sourceBranchName := plan.SourceBranch.ValueString() + if sourceBranchName == "" { + sourceBranchName = "main" + } + sourceBranchRefName := "refs/heads/" + sourceBranchName + + // Get source SHA + var sourceBranchSHA string + if !plan.SourceSHA.IsNull() && !plan.SourceSHA.IsUnknown() && plan.SourceSHA.ValueString() != "" { + // Use provided source SHA + sourceBranchSHA = plan.SourceSHA.ValueString() + } else { + // Get SHA from source branch + ref, _, refErr := r.client.Git.GetRef(ctx, owner, repoName, sourceBranchRefName) + if refErr != nil { + resp.Diagnostics.AddError( + "Error querying source branch", + fmt.Sprintf("Unable to query GitHub branch reference %s/%s (%s): %v", owner, repoName, sourceBranchRefName, refErr), + ) + return + } + if ref.Object != nil && ref.Object.SHA != nil { + sourceBranchSHA = *ref.Object.SHA + // Set source_sha in plan for state + plan.SourceSHA = types.StringValue(sourceBranchSHA) + } else { + resp.Diagnostics.AddError( + "Invalid source branch", + fmt.Sprintf("Source branch %s does not have a valid SHA", sourceBranchName), + ) + return + } + } + + // Create the branch + _, _, createErr := r.client.Git.CreateRef(ctx, owner, repoName, &github.Reference{ + Ref: &branchRefName, + Object: &github.GitObject{SHA: &sourceBranchSHA}, + }) + // If the branch already exists, rather than erroring out just continue on to reading the branch + // This avoids the case where a repo with gitignore_template and branch are being created at the same time crashing terraform + if createErr != nil && !strings.HasSuffix(createErr.Error(), "422 Reference already exists []") { + resp.Diagnostics.AddError( + "Error creating branch", + fmt.Sprintf("Unable to create GitHub branch reference %s/%s (%s): %v", owner, repoName, branchRefName, createErr), + ) + return + } + + // Set ID using colon delimiter (standard Terraform pattern) + plan.ID = types.StringValue(buildTwoPartID(repoName, branchName)) + + // Read the branch to get all computed values + r.readBranch(ctx, owner, repoName, branchName, &plan, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + // Save state + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +// Read refreshes the Terraform state with the latest data. +func (r *repositoryBranchResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state repositoryBranchResourceModel + + // Read Terraform state data into the model + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + if r.client == nil { + resp.Diagnostics.AddError( + "Client Error", + "GitHub client is not configured. Please ensure a token is provided.", + ) + return + } + + // Get owner, falling back to authenticated user if not set + owner, err := r.getOwner(ctx) + if err != nil { + resp.Diagnostics.AddError( + "Missing Owner", + fmt.Sprintf("Unable to determine owner: %v. Please set provider-level `owner` configuration or ensure authentication is working.", err), + ) + return + } + + // Parse ID (format: repository:branch or repository/branch for backward compatibility) + id := state.ID.ValueString() + repoName, branchName, err := parseTwoPartID(id, "repository", "branch") + if err != nil { + resp.Diagnostics.AddError( + "Invalid ID", + fmt.Sprintf("Invalid ID format: %s. Expected 'repository:branch' or 'repository/branch'. Error: %v", id, err), + ) + return + } + + // Read the branch + r.readBranch(ctx, owner, repoName, branchName, &state, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + // Migrate ID to new format if it was in old format + // This ensures backward compatibility and updates state to new format + if !strings.Contains(state.ID.ValueString(), ":") { + // Old format detected, update to new format + state.ID = types.StringValue(buildTwoPartID(repoName, branchName)) + } + + // Save updated state + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +// Update updates the resource and sets the updated Terraform state on success. +func (r *repositoryBranchResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, state repositoryBranchResourceModel + + // Read Terraform plan and state data into the models + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + if r.client == nil { + resp.Diagnostics.AddError( + "Client Error", + "GitHub client is not configured. Please ensure a token is provided.", + ) + return + } + + // Get owner, falling back to authenticated user if not set + owner, err := r.getOwner(ctx) + if err != nil { + resp.Diagnostics.AddError( + "Missing Owner", + fmt.Sprintf("Unable to determine owner: %v. Please set provider-level `owner` configuration or ensure authentication is working.", err), + ) + return + } + + // Parse ID from state (format: repository:branch or repository/branch for backward compatibility) + id := state.ID.ValueString() + repoName, oldBranchName, err := parseTwoPartID(id, "repository", "branch") + if err != nil { + resp.Diagnostics.AddError( + "Invalid ID", + fmt.Sprintf("Invalid ID format: %s. Expected 'repository:branch' or 'repository/branch'. Error: %v", id, err), + ) + return + } + newBranchName := plan.Branch.ValueString() + + // Check if branch name changed + if !plan.Branch.Equal(state.Branch) { + // Rename the branch + _, _, err := r.client.Repositories.RenameBranch(ctx, owner, repoName, oldBranchName, newBranchName) + if err != nil { + resp.Diagnostics.AddError( + "Error renaming branch", + fmt.Sprintf("Unable to rename GitHub branch %s/%s (%s -> %s): %v", owner, repoName, oldBranchName, newBranchName, err), + ) + return + } + + // Update ID + plan.ID = types.StringValue(buildTwoPartID(repoName, newBranchName)) + } else { + plan.ID = state.ID + } + + // Read the branch to get all computed values + r.readBranch(ctx, owner, repoName, newBranchName, &plan, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + // Save updated state + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +// Delete deletes the resource and removes the Terraform state on success. +func (r *repositoryBranchResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state repositoryBranchResourceModel + + // Read Terraform state data into the model + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + if r.client == nil { + resp.Diagnostics.AddError( + "Client Error", + "GitHub client is not configured. Please ensure a token is provided.", + ) + return + } + + // Get owner, falling back to authenticated user if not set + owner, err := r.getOwner(ctx) + if err != nil { + resp.Diagnostics.AddError( + "Missing Owner", + fmt.Sprintf("Unable to determine owner: %v. Please set provider-level `owner` configuration or ensure authentication is working.", err), + ) + return + } + + // Parse ID (format: repository:branch or repository/branch for backward compatibility) + id := state.ID.ValueString() + repoName, branchName, parseErr := parseTwoPartID(id, "repository", "branch") + if parseErr != nil { + resp.Diagnostics.AddError( + "Invalid ID", + fmt.Sprintf("Invalid ID format: %s. Expected 'repository:branch' or 'repository/branch'. Error: %v", id, parseErr), + ) + return + } + branchRefName := "refs/heads/" + branchName + + // Delete the branch + log.Printf("[DEBUG] Deleting branch: %s/%s (%s)", owner, repoName, branchRefName) + _, err = r.client.Git.DeleteRef(ctx, owner, repoName, branchRefName) + if err != nil { + // Check if the branch already doesn't exist (404 or 422 with "Reference does not exist") + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) && ghErr.Response != nil { + statusCode := ghErr.Response.StatusCode + // 404: Branch not found + // 422: Reference does not exist (branch was already deleted, e.g., by auto-merge) + if statusCode == http.StatusNotFound || + (statusCode == http.StatusUnprocessableEntity && + strings.Contains(err.Error(), "Reference does not exist")) { + log.Printf("[INFO] Branch %s/%s (%s) no longer exists, removing from state", owner, repoName, branchRefName) + return // Successfully removed from state + } + } + resp.Diagnostics.AddError( + "Error deleting branch", + fmt.Sprintf("Unable to delete GitHub branch reference %s/%s (%s): %v", owner, repoName, branchRefName, err), + ) + return + } +} + +// ImportState imports the resource into Terraform state. +func (r *repositoryBranchResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + // Parse the import ID (format: repository:branch or repository:branch:source_branch) + // Use colon as delimiter (standard Terraform pattern) + parts := strings.SplitN(req.ID, ":", 3) + if len(parts) < 2 { + resp.Diagnostics.AddError( + "Invalid Import ID", + "Import ID must be in format 'repository:branch' or 'repository:branch:source_branch'.", + ) + return + } + + repoName := parts[0] + branchName := parts[1] + + // Check if source_branch is specified (third part) + var sourceBranch string + if len(parts) == 3 { + sourceBranch = parts[2] + } else { + sourceBranch = "main" + } + + // Set the ID using colon delimiter + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), buildTwoPartID(repoName, branchName))...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("repository"), repoName)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("branch"), branchName)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("source_branch"), sourceBranch)...) +} + +// Helper methods + +// getOwner gets the owner, falling back to authenticated user if not set. +func (r *repositoryBranchResource) getOwner(ctx context.Context) (string, error) { + if r.owner != "" { + return r.owner, nil + } + // Try to get authenticated user + user, _, err := r.client.Users.Get(ctx, "") + if err != nil { + return "", fmt.Errorf("unable to determine owner: provider-level `owner` is not set and unable to fetch authenticated user: %v", err) + } + if user == nil || user.Login == nil { + return "", fmt.Errorf("unable to determine owner: provider-level `owner` is not set and authenticated user information is unavailable") + } + return user.GetLogin(), nil +} + +// buildTwoPartID creates a two-part ID using colon as delimiter (standard Terraform pattern). +// Format: "repository:branch". +func buildTwoPartID(part1, part2 string) string { + return fmt.Sprintf("%s:%s", part1, part2) +} + +// parseTwoPartID parses a two-part ID using colon as delimiter (preferred) or slash (backward compatibility). +// Returns the two parts and an error if the format is invalid. +// Supports both "repository:branch" (new format) and "repository/branch" (old format) for backward compatibility. +func parseTwoPartID(id, part1Name, part2Name string) (string, string, error) { + // Try colon delimiter first (new format) + if strings.Contains(id, ":") { + parts := strings.SplitN(id, ":", 2) + if len(parts) == 2 { + return parts[0], parts[1], nil + } + } + + // Fall back to slash delimiter (old format for backward compatibility) + // Use SplitN to only split on the first "/" since branch names can contain slashes + parts := strings.SplitN(id, "/", 2) + if len(parts) == 2 { + return parts[0], parts[1], nil + } + + return "", "", fmt.Errorf("unexpected format of ID (%s), expected %s:%s or %s/%s", id, part1Name, part2Name, part1Name, part2Name) +} + +// readBranch reads branch data from GitHub and populates the model. +func (r *repositoryBranchResource) readBranch(ctx context.Context, owner, repoName, branchName string, model *repositoryBranchResourceModel, diags *diag.Diagnostics) { + branchRefName := "refs/heads/" + branchName + + ref, resp, err := r.client.Git.GetRef(ctx, owner, repoName, branchRefName) + if err != nil { + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) { + if resp != nil && resp.StatusCode == http.StatusNotModified { + // Branch hasn't changed, use existing state + return + } + if (resp != nil && resp.StatusCode == http.StatusNotFound) || + (ghErr.Response != nil && ghErr.Response.StatusCode == http.StatusNotFound) { + log.Printf("[INFO] Removing branch %s/%s (%s) from state because it no longer exists in GitHub", + owner, repoName, branchName) + model.ID = types.StringValue("") + return + } + } + diags.AddError( + "Error reading branch", + fmt.Sprintf("Unable to read branch %s/%s (%s): %v", owner, repoName, branchRefName, err), + ) + return + } + + // Set ID using colon delimiter + model.ID = types.StringValue(buildTwoPartID(repoName, branchName)) + + // Set repository and branch + model.Repository = types.StringValue(repoName) + model.Branch = types.StringValue(branchName) + + // Set ETag from response header + if resp != nil { + model.ETag = types.StringValue(resp.Header.Get("ETag")) + } else { + model.ETag = types.StringNull() + } + + // Set ref and SHA + if ref != nil { + if ref.Ref != nil { + model.Ref = types.StringValue(*ref.Ref) + } else { + model.Ref = types.StringNull() + } + + if ref.Object != nil && ref.Object.SHA != nil { + model.SHA = types.StringValue(*ref.Object.SHA) + } else { + model.SHA = types.StringNull() + } + } else { + model.Ref = types.StringNull() + model.SHA = types.StringNull() + } +} diff --git a/internal/provider/resource_repository_branch_test.go b/internal/provider/resource_repository_branch_test.go new file mode 100644 index 0000000..5070acb --- /dev/null +++ b/internal/provider/resource_repository_branch_test.go @@ -0,0 +1,133 @@ +package provider + +import ( + "testing" + + "github.com/google/go-github/v60/github" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/stretchr/testify/assert" +) + +func TestRepositoryBranchResource_Metadata(t *testing.T) { + r := NewRepositoryBranchResource() + req := resource.MetadataRequest{ + ProviderTypeName: "githubx", + } + resp := &resource.MetadataResponse{} + + r.Metadata(t.Context(), req, resp) + + assert.Equal(t, "githubx_repository_branch", resp.TypeName) +} + +func TestRepositoryBranchResource_Schema(t *testing.T) { + r := NewRepositoryBranchResource() + req := resource.SchemaRequest{} + resp := &resource.SchemaResponse{} + + r.Schema(t.Context(), req, resp) + + assert.NotNil(t, resp.Schema) + assert.Contains(t, resp.Schema.Description, "Creates and manages a GitHub repository branch") + + // Check required attributes + repositoryAttr, ok := resp.Schema.Attributes["repository"] + assert.True(t, ok) + assert.True(t, repositoryAttr.IsRequired()) + + branchAttr, ok := resp.Schema.Attributes["branch"] + assert.True(t, ok) + assert.True(t, branchAttr.IsRequired()) + + // Check optional attributes + sourceBranchAttr, ok := resp.Schema.Attributes["source_branch"] + assert.True(t, ok) + assert.True(t, sourceBranchAttr.IsOptional()) + assert.True(t, sourceBranchAttr.IsComputed()) + + sourceSHAAttr, ok := resp.Schema.Attributes["source_sha"] + assert.True(t, ok) + assert.True(t, sourceSHAAttr.IsOptional()) + assert.True(t, sourceSHAAttr.IsComputed()) + + etagAttr, ok := resp.Schema.Attributes["etag"] + assert.True(t, ok) + assert.True(t, etagAttr.IsOptional()) + assert.True(t, etagAttr.IsComputed()) + + // Check computed attributes + idAttr, ok := resp.Schema.Attributes["id"] + assert.True(t, ok) + assert.True(t, idAttr.IsComputed()) + + refAttr, ok := resp.Schema.Attributes["ref"] + assert.True(t, ok) + assert.True(t, refAttr.IsComputed()) + + shaAttr, ok := resp.Schema.Attributes["sha"] + assert.True(t, ok) + assert.True(t, shaAttr.IsComputed()) +} + +func TestRepositoryBranchResource_Configure(t *testing.T) { + tests := []struct { + name string + providerData interface{} + expectError bool + errorContains string + }{ + { + name: "valid githubxClientData", + providerData: githubxClientData{ + Client: github.NewClient(nil), + Owner: "test-owner", + }, + expectError: false, + }, + { + name: "invalid provider data type", + providerData: "invalid", + expectError: true, + errorContains: "Unexpected Resource Configure Type", + }, + { + name: "nil provider data", + providerData: nil, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rs := &repositoryBranchResource{} + req := resource.ConfigureRequest{ + ProviderData: tt.providerData, + } + resp := &resource.ConfigureResponse{} + + rs.Configure(t.Context(), req, resp) + + if tt.expectError { + assert.True(t, resp.Diagnostics.HasError()) + if tt.errorContains != "" { + assert.Contains(t, resp.Diagnostics.Errors()[0].Summary(), tt.errorContains) + } + } else { + assert.False(t, resp.Diagnostics.HasError()) + // If provider data is valid, verify client and owner are set + if tt.providerData != nil { + clientData, ok := tt.providerData.(githubxClientData) + if ok { + assert.Equal(t, clientData.Client, rs.client) + assert.Equal(t, clientData.Owner, rs.owner) + } + } + } + }) + } +} + +// Note: Tests for Create(), Read(), Update(), and Delete() methods that require GitHub API calls +// should be implemented as acceptance tests with TF_ACC=1 environment variable set. +// These unit tests verify the schema, metadata, and configuration validation +// without making API calls. diff --git a/internal/provider/resource_repository_file.go b/internal/provider/resource_repository_file.go new file mode 100644 index 0000000..4b4fafc --- /dev/null +++ b/internal/provider/resource_repository_file.go @@ -0,0 +1,893 @@ +package provider + +import ( + "context" + "errors" + "fmt" + "log" + "net/http" + "net/url" + "strings" + + "github.com/google/go-github/v60/github" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var ( + _ resource.Resource = &repositoryFileResource{} + _ resource.ResourceWithConfigure = &repositoryFileResource{} + _ resource.ResourceWithImportState = &repositoryFileResource{} +) + +func NewRepositoryFileResource() resource.Resource { + return &repositoryFileResource{} +} + +type repositoryFileResource struct { + client *github.Client + owner string +} + +type repositoryFileResourceModel struct { + Repository types.String `tfsdk:"repository"` + File types.String `tfsdk:"file"` + Content types.String `tfsdk:"content"` + Branch types.String `tfsdk:"branch"` + Ref types.String `tfsdk:"ref"` + CommitSHA types.String `tfsdk:"commit_sha"` + CommitMessage types.String `tfsdk:"commit_message"` + CommitAuthor types.String `tfsdk:"commit_author"` + CommitEmail types.String `tfsdk:"commit_email"` + SHA types.String `tfsdk:"sha"` + OverwriteOnCreate types.Bool `tfsdk:"overwrite_on_create"` + AutocreateBranch types.Bool `tfsdk:"autocreate_branch"` + AutocreateBranchSource types.String `tfsdk:"autocreate_branch_source_branch"` + AutocreateBranchSourceSHA types.String `tfsdk:"autocreate_branch_source_sha"` + ID types.String `tfsdk:"id"` +} + +func (r *repositoryFileResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_repository_file" +} + +func (r *repositoryFileResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Creates and manages a file in a GitHub repository.", + Attributes: map[string]schema.Attribute{ + "repository": schema.StringAttribute{ + Description: "The GitHub repository name.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "file": schema.StringAttribute{ + Description: "The file path to manage.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "content": schema.StringAttribute{ + Description: "The file's content.", + Required: true, + }, + "branch": schema.StringAttribute{ + Description: "The branch name, defaults to the repository's default branch.", + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "ref": schema.StringAttribute{ + Description: "The name of the commit/branch/tag.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "commit_sha": schema.StringAttribute{ + Description: "The SHA of the commit that modified the file.", + Computed: true, + }, + "commit_message": schema.StringAttribute{ + Description: "The commit message when creating, updating or deleting the file.", + Optional: true, + Computed: true, + }, + "commit_author": schema.StringAttribute{ + Description: "The commit author name, defaults to the authenticated user's name. GitHub app users may omit author and email information so GitHub can verify commits as the GitHub App.", + Optional: true, + }, + "commit_email": schema.StringAttribute{ + Description: "The commit author email address, defaults to the authenticated user's email address. GitHub app users may omit author and email information so GitHub can verify commits as the GitHub App.", + Optional: true, + }, + "sha": schema.StringAttribute{ + Description: "The blob SHA of the file.", + Computed: true, + }, + "overwrite_on_create": schema.BoolAttribute{ + Description: "Enable overwriting existing files, defaults to \"false\".", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + }, + "autocreate_branch": schema.BoolAttribute{ + Description: "Automatically create the branch if it could not be found. Subsequent reads if the branch is deleted will occur from 'autocreate_branch_source_branch'.", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + }, + "autocreate_branch_source_branch": schema.StringAttribute{ + Description: "The branch name to start from, if 'autocreate_branch' is set. Defaults to 'main'.", + Optional: true, + Computed: true, + Default: stringdefault.StaticString("main"), + }, + "autocreate_branch_source_sha": schema.StringAttribute{ + Description: "The commit hash to start from, if 'autocreate_branch' is set. Defaults to the tip of 'autocreate_branch_source_branch'. If provided, 'autocreate_branch_source_branch' is ignored.", + Optional: true, + Computed: true, + }, + "id": schema.StringAttribute{ + Description: "The Terraform state ID (repository:file).", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + }, + } +} + +func (r *repositoryFileResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + clientData, ok := req.ProviderData.(githubxClientData) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected githubxClientData, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + return + } + + r.client = clientData.Client + r.owner = clientData.Owner +} + +func (r *repositoryFileResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan repositoryFileResourceModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + if r.client == nil { + resp.Diagnostics.AddError( + "Client Error", + "GitHub client is not configured. Please ensure a token is provided.", + ) + return + } + + owner, err := r.getOwner(ctx) + if err != nil { + resp.Diagnostics.AddError( + "Missing Owner", + fmt.Sprintf("Unable to determine owner: %v. Please set provider-level `owner` configuration or ensure authentication is working.", err), + ) + return + } + + repoName := plan.Repository.ValueString() + filePath := plan.File.ValueString() + content := plan.Content.ValueString() + + if !r.checkAndCreateBranchIfNeeded(ctx, owner, repoName, &plan, &resp.Diagnostics) { + return + } + + opts, diags := r.buildFileOptions(ctx, &plan, content) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + + if opts.Message == nil || *opts.Message == "" { + msg := fmt.Sprintf("Add %s", filePath) + opts.Message = &msg + plan.CommitMessage = types.StringValue(msg) + } + + if plan.OverwriteOnCreate.ValueBool() { + checkOpts := &github.RepositoryContentGetOptions{} + if !plan.Branch.IsNull() && !plan.Branch.IsUnknown() { + checkOpts.Ref = plan.Branch.ValueString() + } + fc, _, _, err := r.client.Repositories.GetContents(ctx, owner, repoName, filePath, checkOpts) + if err == nil && fc != nil { + opts.SHA = github.String(fc.GetSHA()) + } + } + + var create *github.RepositoryContentResponse + maxRetries := 5 + for attempt := 0; attempt < maxRetries; attempt++ { + create, _, err = r.client.Repositories.CreateFile(ctx, owner, repoName, filePath, opts) + if err == nil { + break + } + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) && ghErr.Response != nil && ghErr.Response.StatusCode == http.StatusConflict { + checkOpts := &github.RepositoryContentGetOptions{} + if !plan.Branch.IsNull() && !plan.Branch.IsUnknown() { + checkOpts.Ref = plan.Branch.ValueString() + } + fc, _, _, readErr := r.client.Repositories.GetContents(ctx, owner, repoName, filePath, checkOpts) + if readErr == nil && fc != nil { + if plan.OverwriteOnCreate.ValueBool() { + opts.SHA = github.String(fc.GetSHA()) + continue + } else { + resp.Diagnostics.AddError( + "File Already Exists", + fmt.Sprintf("File %s already exists in repository %s/%s. Set 'overwrite_on_create' to true to overwrite it.", filePath, owner, repoName), + ) + return + } + } + continue + } + break + } + if err != nil { + resp.Diagnostics.AddError( + "Error creating file", + fmt.Sprintf("Unable to create file %s in repository %s/%s after %d attempts: %v", filePath, owner, repoName, maxRetries, err), + ) + return + } + + plan.ID = types.StringValue(fmt.Sprintf("%s:%s", repoName, filePath)) + if create != nil { + plan.CommitSHA = types.StringValue(create.GetSHA()) + } + + r.readFile(ctx, owner, repoName, filePath, &plan, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + if !plan.AutocreateBranch.ValueBool() { + plan.AutocreateBranchSourceSHA = types.StringNull() + } else if plan.AutocreateBranchSourceSHA.IsNull() || plan.AutocreateBranchSourceSHA.IsUnknown() { + plan.AutocreateBranchSourceSHA = types.StringNull() + } + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *repositoryFileResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state repositoryFileResourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + if r.client == nil { + resp.Diagnostics.AddError( + "Client Error", + "GitHub client is not configured. Please ensure a token is provided.", + ) + return + } + + owner, err := r.getOwner(ctx) + if err != nil { + resp.Diagnostics.AddError( + "Missing Owner", + fmt.Sprintf("Unable to determine owner: %v. Please set provider-level `owner` configuration or ensure authentication is working.", err), + ) + return + } + + id := state.ID.ValueString() + if id == "" { + // ID is empty, resource should be removed from state + log.Printf("[INFO] Resource ID is empty, removing from state") + state.ID = types.StringValue("") + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) + return + } + + parts := strings.SplitN(id, ":", 2) + if len(parts) != 2 { + resp.Diagnostics.AddError( + "Invalid ID", + fmt.Sprintf("Invalid ID format: %s. Expected 'repository:file'.", id), + ) + return + } + + repoName := parts[0] + filePath := parts[1] + + if !state.Branch.IsNull() && !state.Branch.IsUnknown() { + branchName := state.Branch.ValueString() + if err := r.checkRepositoryBranchExists(ctx, owner, repoName, branchName); err != nil { + if state.AutocreateBranch.ValueBool() { + state.Branch = state.AutocreateBranchSource + } else { + log.Printf("[INFO] Removing repository file %s/%s/%s from state because the branch no longer exists in GitHub", + owner, repoName, filePath) + state.ID = types.StringValue("") + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) + return + } + } + } + + r.readFile(ctx, owner, repoName, filePath, &state, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + if !state.AutocreateBranch.ValueBool() { + state.AutocreateBranchSourceSHA = types.StringNull() + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *repositoryFileResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, state repositoryFileResourceModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + if r.client == nil { + resp.Diagnostics.AddError( + "Client Error", + "GitHub client is not configured. Please ensure a token is provided.", + ) + return + } + + owner, err := r.getOwner(ctx) + if err != nil { + resp.Diagnostics.AddError( + "Missing Owner", + fmt.Sprintf("Unable to determine owner: %v. Please set provider-level `owner` configuration or ensure authentication is working.", err), + ) + return + } + + id := state.ID.ValueString() + if id == "" { + // ID is empty, resource should be removed from state + log.Printf("[INFO] Resource ID is empty during update, removing from state") + state.ID = types.StringValue("") + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) + return + } + + parts := strings.SplitN(id, ":", 2) + if len(parts) != 2 { + resp.Diagnostics.AddError( + "Invalid ID", + fmt.Sprintf("Invalid ID format: %s. Expected 'repository:file'.", id), + ) + return + } + + repoName := parts[0] + filePath := parts[1] + content := plan.Content.ValueString() + + if !r.checkAndCreateBranchIfNeeded(ctx, owner, repoName, &plan, &resp.Diagnostics) { + return + } + + opts, diags := r.buildFileOptions(ctx, &plan, content) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + + if !state.SHA.IsNull() && !state.SHA.IsUnknown() { + opts.SHA = github.String(state.SHA.ValueString()) + } + + if opts.Message == nil || *opts.Message == "" || *opts.Message == fmt.Sprintf("Add %s", filePath) { + msg := fmt.Sprintf("Update %s", filePath) + opts.Message = &msg + plan.CommitMessage = types.StringValue(msg) + } + + var create *github.RepositoryContentResponse + maxRetries := 5 + for attempt := 0; attempt < maxRetries; attempt++ { + create, _, err = r.client.Repositories.CreateFile(ctx, owner, repoName, filePath, opts) + if err == nil { + break + } + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) && ghErr.Response != nil && ghErr.Response.StatusCode == http.StatusConflict { + updateOpts := &github.RepositoryContentGetOptions{} + if !plan.Branch.IsNull() && !plan.Branch.IsUnknown() { + updateOpts.Ref = plan.Branch.ValueString() + } + fc, _, _, retryErr := r.client.Repositories.GetContents(ctx, owner, repoName, filePath, updateOpts) + if retryErr == nil && fc != nil { + opts.SHA = github.String(fc.GetSHA()) + continue + } + } + break + } + if err != nil { + resp.Diagnostics.AddError( + "Error updating file", + fmt.Sprintf("Unable to update file %s in repository %s/%s after %d attempts: %v", filePath, owner, repoName, maxRetries, err), + ) + return + } + + plan.ID = state.ID + plan.CommitSHA = types.StringValue(create.GetSHA()) + + r.readFile(ctx, owner, repoName, filePath, &plan, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *repositoryFileResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state repositoryFileResourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + if r.client == nil { + resp.Diagnostics.AddError( + "Client Error", + "GitHub client is not configured. Please ensure a token is provided.", + ) + return + } + + owner, err := r.getOwner(ctx) + if err != nil { + resp.Diagnostics.AddError( + "Missing Owner", + fmt.Sprintf("Unable to determine owner: %v. Please set provider-level `owner` configuration or ensure authentication is working.", err), + ) + return + } + + id := state.ID.ValueString() + if id == "" { + // ID is empty, resource already removed from state + log.Printf("[INFO] Resource ID is empty during delete, nothing to delete") + return + } + + parts := strings.SplitN(id, ":", 2) + if len(parts) != 2 { + resp.Diagnostics.AddError( + "Invalid ID", + fmt.Sprintf("Invalid ID format: %s. Expected 'repository:file'.", id), + ) + return + } + + repoName := parts[0] + filePath := parts[1] + + if !r.checkAndCreateBranchIfNeeded(ctx, owner, repoName, &state, &resp.Diagnostics) { + return + } + + opts, diags := r.buildFileOptions(ctx, &state, "") + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + + if opts.Message == nil || *opts.Message == "" || *opts.Message == fmt.Sprintf("Add %s", filePath) { + msg := fmt.Sprintf("Delete %s", filePath) + opts.Message = &msg + } + + if !state.SHA.IsNull() && !state.SHA.IsUnknown() { + opts.SHA = github.String(state.SHA.ValueString()) + } + + maxRetries := 5 + for attempt := 0; attempt < maxRetries; attempt++ { + _, _, err = r.client.Repositories.DeleteFile(ctx, owner, repoName, filePath, opts) + if err == nil { + return + } + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) && ghErr.Response != nil { + if ghErr.Response.StatusCode == http.StatusNotFound { + return + } else if ghErr.Response.StatusCode == http.StatusConflict { + getOpts := &github.RepositoryContentGetOptions{} + if !state.Branch.IsNull() && !state.Branch.IsUnknown() { + getOpts.Ref = state.Branch.ValueString() + } + fc, _, _, readErr := r.client.Repositories.GetContents(ctx, owner, repoName, filePath, getOpts) + if readErr != nil { + var readGhErr *github.ErrorResponse + if errors.As(readErr, &readGhErr) && readGhErr.Response != nil && readGhErr.Response.StatusCode == http.StatusNotFound { + return + } + } + if fc != nil { + opts.SHA = github.String(fc.GetSHA()) + continue + } + } + } + break + } + if err != nil { + resp.Diagnostics.AddError( + "Error deleting file", + fmt.Sprintf("Unable to delete file %s from repository %s/%s after %d attempts: %v", filePath, owner, repoName, maxRetries, err), + ) + return + } +} + +func (r *repositoryFileResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + parts := strings.SplitN(req.ID, ":", 3) + if len(parts) < 2 { + resp.Diagnostics.AddError( + "Invalid Import ID", + "Import ID must be in format 'repository:file' or 'repository:file:branch'.", + ) + return + } + + repoName := parts[0] + filePath := parts[1] + var branch string + if len(parts) == 3 { + branch = parts[2] + } + + owner, err := r.getOwner(ctx) + if err != nil { + resp.Diagnostics.AddError( + "Missing Owner", + fmt.Sprintf("Unable to determine owner: %v", err), + ) + return + } + + opts := &github.RepositoryContentGetOptions{} + if branch != "" { + opts.Ref = branch + } + + fc, _, _, err := r.client.Repositories.GetContents(ctx, owner, repoName, filePath, opts) + if err != nil { + resp.Diagnostics.AddError( + "Error importing file", + fmt.Sprintf("Unable to read file %s from repository %s/%s: %v", filePath, owner, repoName, err), + ) + return + } + + if fc == nil { + resp.Diagnostics.AddError( + "File Not Found", + fmt.Sprintf("File %s is not a file in repository %s/%s or repository is not readable", filePath, owner, repoName), + ) + return + } + + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), fmt.Sprintf("%s:%s", repoName, filePath))...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("repository"), repoName)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("file"), filePath)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("overwrite_on_create"), false)...) + if branch != "" { + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("branch"), branch)...) + } +} + +func (r *repositoryFileResource) getOwner(ctx context.Context) (string, error) { + if r.owner != "" { + return r.owner, nil + } + + user, _, err := r.client.Users.Get(ctx, "") + if err != nil { + return "", fmt.Errorf("unable to determine owner: provider-level `owner` is not set and unable to fetch authenticated user: %v", err) + } + if user == nil || user.Login == nil { + return "", fmt.Errorf("unable to determine owner: provider-level `owner` is not set and authenticated user information is unavailable") + } + return user.GetLogin(), nil +} + +func (r *repositoryFileResource) checkRepositoryBranchExists(ctx context.Context, owner, repo, branch string) error { + branchRefName := "refs/heads/" + branch + _, _, err := r.client.Git.GetRef(ctx, owner, repo, branchRefName) + return err +} + +func (r *repositoryFileResource) checkAndCreateBranchIfNeeded(ctx context.Context, owner, repo string, model *repositoryFileResourceModel, diags *diag.Diagnostics) bool { + if model.Branch.IsNull() || model.Branch.IsUnknown() { + return true + } + + branchName := model.Branch.ValueString() + if err := r.checkRepositoryBranchExists(ctx, owner, repo, branchName); err != nil { + if !model.AutocreateBranch.ValueBool() { + diags.AddError( + "Branch Not Found", + fmt.Sprintf("Branch %s not found in repository %s/%s. Set 'autocreate_branch' to true to automatically create it.", branchName, owner, repo), + ) + return false + } + + branchRefName := "refs/heads/" + branchName + sourceBranchName := model.AutocreateBranchSource.ValueString() + if sourceBranchName == "" { + sourceBranchName = "main" + } + sourceBranchRefName := "refs/heads/" + sourceBranchName + + var sourceBranchSHA string + if !model.AutocreateBranchSourceSHA.IsNull() && !model.AutocreateBranchSourceSHA.IsUnknown() && model.AutocreateBranchSourceSHA.ValueString() != "" { + sourceBranchSHA = model.AutocreateBranchSourceSHA.ValueString() + } else { + ref, _, err := r.client.Git.GetRef(ctx, owner, repo, sourceBranchRefName) + if err != nil { + diags.AddError( + "Error querying source branch", + fmt.Sprintf("Unable to query GitHub branch reference %s/%s (%s): %v", owner, repo, sourceBranchRefName, err), + ) + return false + } + if ref.Object != nil && ref.Object.SHA != nil { + sourceBranchSHA = *ref.Object.SHA + model.AutocreateBranchSourceSHA = types.StringValue(sourceBranchSHA) + } else { + diags.AddError( + "Invalid source branch", + fmt.Sprintf("Source branch %s does not have a valid SHA", sourceBranchName), + ) + return false + } + } + + _, _, err := r.client.Git.CreateRef(ctx, owner, repo, &github.Reference{ + Ref: &branchRefName, + Object: &github.GitObject{SHA: &sourceBranchSHA}, + }) + if err != nil { + diags.AddError( + "Error creating branch", + fmt.Sprintf("Unable to create GitHub branch reference %s/%s (%s): %v", owner, repo, branchRefName, err), + ) + return false + } + } + return true +} + +func (r *repositoryFileResource) buildFileOptions(_ context.Context, model *repositoryFileResourceModel, content string) (*github.RepositoryContentFileOptions, diag.Diagnostics) { + var diags diag.Diagnostics + + opts := &github.RepositoryContentFileOptions{ + Content: []byte(content), + } + + if !model.Branch.IsNull() && !model.Branch.IsUnknown() { + opts.Branch = github.String(model.Branch.ValueString()) + } + + if !model.CommitMessage.IsNull() && !model.CommitMessage.IsUnknown() { + msg := model.CommitMessage.ValueString() + opts.Message = &msg + } + + hasCommitAuthor := !model.CommitAuthor.IsNull() && !model.CommitAuthor.IsUnknown() + hasCommitEmail := !model.CommitEmail.IsNull() && !model.CommitEmail.IsUnknown() + + if hasCommitAuthor && !hasCommitEmail { + diags.AddError( + "Invalid Commit Author Configuration", + "Cannot set commit_author without setting commit_email", + ) + return nil, diags + } + + if hasCommitEmail && !hasCommitAuthor { + diags.AddError( + "Invalid Commit Author Configuration", + "Cannot set commit_email without setting commit_author", + ) + return nil, diags + } + + if hasCommitAuthor && hasCommitEmail { + name := model.CommitAuthor.ValueString() + email := model.CommitEmail.ValueString() + opts.Author = &github.CommitAuthor{Name: &name, Email: &email} + opts.Committer = &github.CommitAuthor{Name: &name, Email: &email} + } + + return opts, diags +} + +func (r *repositoryFileResource) readFile(ctx context.Context, owner, repoName, filePath string, model *repositoryFileResourceModel, diags *diag.Diagnostics) { + opts := &github.RepositoryContentGetOptions{} + + if !model.Branch.IsNull() && !model.Branch.IsUnknown() { + opts.Ref = model.Branch.ValueString() + } + + fc, _, _, err := r.client.Repositories.GetContents(ctx, owner, repoName, filePath, opts) + if err != nil { + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) { + if ghErr.Response != nil && ghErr.Response.StatusCode == http.StatusTooManyRequests { + diags.AddError( + "Rate Limit Exceeded", + fmt.Sprintf("GitHub API rate limit exceeded: %v", err), + ) + return + } + if ghErr.Response != nil && ghErr.Response.StatusCode == http.StatusNotFound { + log.Printf("[INFO] Removing repository file %s/%s/%s from state because it no longer exists in GitHub", + owner, repoName, filePath) + model.ID = types.StringValue("") + return + } + } + diags.AddError( + "Error reading file", + fmt.Sprintf("Unable to read file %s from repository %s/%s: %v", filePath, owner, repoName, err), + ) + return + } + + if fc == nil { + log.Printf("[INFO] Removing repository file %s/%s/%s from state because it no longer exists in GitHub", + owner, repoName, filePath) + model.ID = types.StringValue("") + return + } + + content, err := fc.GetContent() + if err != nil { + diags.AddError( + "Error reading file content", + fmt.Sprintf("Unable to get content from file %s/%s/%s: %v", owner, repoName, filePath, err), + ) + return + } + + model.Content = types.StringValue(content) + model.Repository = types.StringValue(repoName) + model.File = types.StringValue(filePath) + model.SHA = types.StringValue(fc.GetSHA()) + + parsedURL, err := url.Parse(fc.GetURL()) + if err != nil { + diags.AddWarning( + "Error parsing file URL", + fmt.Sprintf("Unable to parse file URL: %v", err), + ) + } else { + parsedQuery, err := url.ParseQuery(parsedURL.RawQuery) + if err != nil { + diags.AddWarning( + "Error parsing query string", + fmt.Sprintf("Unable to parse query string: %v", err), + ) + } else { + if refValues, ok := parsedQuery["ref"]; ok && len(refValues) > 0 { + model.Ref = types.StringValue(refValues[0]) + } else { + model.Ref = types.StringNull() + } + } + } + + ref := model.Ref.ValueString() + if ref == "" && !model.Branch.IsNull() && !model.Branch.IsUnknown() { + ref = model.Branch.ValueString() + } + + if ref != "" { + var commit *github.RepositoryCommit + if !model.CommitSHA.IsNull() && !model.CommitSHA.IsUnknown() { + commit, _, err = r.client.Repositories.GetCommit(ctx, owner, repoName, model.CommitSHA.ValueString(), nil) + } else { + commit, err = r.getFileCommit(ctx, owner, repoName, filePath, ref) + } + if err != nil { + diags.AddWarning( + "Error fetching commit information", + fmt.Sprintf("Unable to fetch commit information for file %s/%s/%s: %v", owner, repoName, filePath, err), + ) + } else { + model.CommitSHA = types.StringValue(commit.GetSHA()) + + if commit.Commit != nil { + model.CommitMessage = types.StringValue(commit.Commit.GetMessage()) + + if commit.Commit.Committer != nil { + commitAuthor := commit.Commit.Committer.GetName() + commitEmail := commit.Commit.Committer.GetEmail() + + hasCommitAuthor := !model.CommitAuthor.IsNull() && !model.CommitAuthor.IsUnknown() + hasCommitEmail := !model.CommitEmail.IsNull() && !model.CommitEmail.IsUnknown() + + if commitAuthor != "GitHub" && commitEmail != "noreply@github.com" && hasCommitAuthor && hasCommitEmail { + model.CommitAuthor = types.StringValue(commitAuthor) + model.CommitEmail = types.StringValue(commitEmail) + } + } + } + } + } +} + +func (r *repositoryFileResource) getFileCommit(ctx context.Context, owner, repo, file, ref string) (*github.RepositoryCommit, error) { + opts := &github.CommitsListOptions{ + Path: file, + SHA: ref, + ListOptions: github.ListOptions{ + PerPage: 1, + }, + } + + commits, _, err := r.client.Repositories.ListCommits(ctx, owner, repo, opts) + if err != nil { + return nil, err + } + + if len(commits) == 0 { + return nil, fmt.Errorf("no commits found for file %s in ref %s", file, ref) + } + + commitSHA := commits[0].GetSHA() + commit, _, err := r.client.Repositories.GetCommit(ctx, owner, repo, commitSHA, nil) + if err != nil { + return nil, err + } + + return commit, nil +} diff --git a/internal/provider/resource_repository_file_test.go b/internal/provider/resource_repository_file_test.go new file mode 100644 index 0000000..b4caa23 --- /dev/null +++ b/internal/provider/resource_repository_file_test.go @@ -0,0 +1,163 @@ +package provider + +import ( + "testing" + + "github.com/google/go-github/v60/github" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/stretchr/testify/assert" +) + +func TestRepositoryFileResource_Metadata(t *testing.T) { + r := NewRepositoryFileResource() + req := resource.MetadataRequest{ + ProviderTypeName: "githubx", + } + resp := &resource.MetadataResponse{} + + r.Metadata(t.Context(), req, resp) + + assert.Equal(t, "githubx_repository_file", resp.TypeName) +} + +func TestRepositoryFileResource_Schema(t *testing.T) { + r := NewRepositoryFileResource() + req := resource.SchemaRequest{} + resp := &resource.SchemaResponse{} + + r.Schema(t.Context(), req, resp) + + assert.NotNil(t, resp.Schema) + assert.Contains(t, resp.Schema.Description, "Creates and manages a file in a GitHub repository") + + // Check required attributes + repositoryAttr, ok := resp.Schema.Attributes["repository"] + assert.True(t, ok) + assert.True(t, repositoryAttr.IsRequired()) + + fileAttr, ok := resp.Schema.Attributes["file"] + assert.True(t, ok) + assert.True(t, fileAttr.IsRequired()) + + contentAttr, ok := resp.Schema.Attributes["content"] + assert.True(t, ok) + assert.True(t, contentAttr.IsRequired()) + + // Check optional attributes + branchAttr, ok := resp.Schema.Attributes["branch"] + assert.True(t, ok) + assert.True(t, branchAttr.IsOptional()) + + commitMessageAttr, ok := resp.Schema.Attributes["commit_message"] + assert.True(t, ok) + assert.True(t, commitMessageAttr.IsOptional()) + assert.True(t, commitMessageAttr.IsComputed()) + + commitAuthorAttr, ok := resp.Schema.Attributes["commit_author"] + assert.True(t, ok) + assert.True(t, commitAuthorAttr.IsOptional()) + + commitEmailAttr, ok := resp.Schema.Attributes["commit_email"] + assert.True(t, ok) + assert.True(t, commitEmailAttr.IsOptional()) + + overwriteOnCreateAttr, ok := resp.Schema.Attributes["overwrite_on_create"] + assert.True(t, ok) + assert.True(t, overwriteOnCreateAttr.IsOptional()) + assert.True(t, overwriteOnCreateAttr.IsComputed()) + + autocreateBranchAttr, ok := resp.Schema.Attributes["autocreate_branch"] + assert.True(t, ok) + assert.True(t, autocreateBranchAttr.IsOptional()) + assert.True(t, autocreateBranchAttr.IsComputed()) + + autocreateBranchSourceAttr, ok := resp.Schema.Attributes["autocreate_branch_source_branch"] + assert.True(t, ok) + assert.True(t, autocreateBranchSourceAttr.IsOptional()) + assert.True(t, autocreateBranchSourceAttr.IsComputed()) + + autocreateBranchSourceSHAAttr, ok := resp.Schema.Attributes["autocreate_branch_source_sha"] + assert.True(t, ok) + assert.True(t, autocreateBranchSourceSHAAttr.IsOptional()) + assert.True(t, autocreateBranchSourceSHAAttr.IsComputed()) + + // Check computed attributes + idAttr, ok := resp.Schema.Attributes["id"] + assert.True(t, ok) + assert.True(t, idAttr.IsComputed()) + + refAttr, ok := resp.Schema.Attributes["ref"] + assert.True(t, ok) + assert.True(t, refAttr.IsComputed()) + + shaAttr, ok := resp.Schema.Attributes["sha"] + assert.True(t, ok) + assert.True(t, shaAttr.IsComputed()) + + commitSHAAttr, ok := resp.Schema.Attributes["commit_sha"] + assert.True(t, ok) + assert.True(t, commitSHAAttr.IsComputed()) +} + +func TestRepositoryFileResource_Configure(t *testing.T) { + tests := []struct { + name string + providerData interface{} + expectError bool + errorContains string + }{ + { + name: "valid githubxClientData", + providerData: githubxClientData{ + Client: github.NewClient(nil), + Owner: "test-owner", + }, + expectError: false, + }, + { + name: "invalid provider data type", + providerData: "invalid", + expectError: true, + errorContains: "Unexpected Resource Configure Type", + }, + { + name: "nil provider data", + providerData: nil, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rs := &repositoryFileResource{} + req := resource.ConfigureRequest{ + ProviderData: tt.providerData, + } + resp := &resource.ConfigureResponse{} + + rs.Configure(t.Context(), req, resp) + + if tt.expectError { + assert.True(t, resp.Diagnostics.HasError()) + if tt.errorContains != "" { + assert.Contains(t, resp.Diagnostics.Errors()[0].Summary(), tt.errorContains) + } + } else { + assert.False(t, resp.Diagnostics.HasError()) + // If provider data is valid, verify client and owner are set + if tt.providerData != nil { + clientData, ok := tt.providerData.(githubxClientData) + if ok { + assert.Equal(t, clientData.Client, rs.client) + assert.Equal(t, clientData.Owner, rs.owner) + } + } + } + }) + } +} + +// Note: Tests for Create(), Read(), Update(), and Delete() methods that require GitHub API calls +// should be implemented as acceptance tests with TF_ACC=1 environment variable set. +// These unit tests verify the schema, metadata, and configuration validation +// without making API calls. diff --git a/internal/provider/resource_repository_pull_request.go b/internal/provider/resource_repository_pull_request.go new file mode 100644 index 0000000..c615a04 --- /dev/null +++ b/internal/provider/resource_repository_pull_request.go @@ -0,0 +1,922 @@ +package provider + +import ( + "context" + "errors" + "fmt" + "log" + "net/http" + "strconv" + "strings" + "time" + + "github.com/google/go-github/v60/github" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ resource.Resource = &repositoryPullRequestResource{} + _ resource.ResourceWithConfigure = &repositoryPullRequestResource{} + _ resource.ResourceWithImportState = &repositoryPullRequestResource{} +) + +// NewRepositoryPullRequestResource is a helper function to simplify the provider implementation. +func NewRepositoryPullRequestResource() resource.Resource { + return &repositoryPullRequestResource{} +} + +// repositoryPullRequestResource is the resource implementation. +type repositoryPullRequestResource struct { + client *github.Client + owner string +} + +// repositoryPullRequestResourceModel maps the resource schema data. +type repositoryPullRequestResourceModel struct { + Repository types.String `tfsdk:"repository"` + BaseRef types.String `tfsdk:"base_ref"` + HeadRef types.String `tfsdk:"head_ref"` + Title types.String `tfsdk:"title"` + Body types.String `tfsdk:"body"` + MergeWhenReady types.Bool `tfsdk:"merge_when_ready"` + MergeMethod types.String `tfsdk:"merge_method"` + WaitForChecks types.Bool `tfsdk:"wait_for_checks"` + AutoDeleteBranch types.Bool `tfsdk:"auto_delete_branch"` + MaintainerCanModify types.Bool `tfsdk:"maintainer_can_modify"` + BaseSHA types.String `tfsdk:"base_sha"` + HeadSHA types.String `tfsdk:"head_sha"` + Number types.Int64 `tfsdk:"number"` + State types.String `tfsdk:"state"` + Merged types.Bool `tfsdk:"merged"` + MergedAt types.String `tfsdk:"merged_at"` + MergeCommitSHA types.String `tfsdk:"merge_commit_sha"` + ID types.String `tfsdk:"id"` +} + +// Metadata returns the resource type name. +func (r *repositoryPullRequestResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_repository_pull_request_auto_merge" +} + +// Schema defines the schema for the resource. +func (r *repositoryPullRequestResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Creates and manages a GitHub pull request with optional auto-merge capabilities. Supports multiple files through branch-based commits.", + Attributes: map[string]schema.Attribute{ + "repository": schema.StringAttribute{ + Description: "The GitHub repository name.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "base_ref": schema.StringAttribute{ + Description: "The base branch name (e.g., 'main', 'develop').", + Required: true, + }, + "head_ref": schema.StringAttribute{ + Description: "The head branch name (e.g., 'feature-branch').", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "title": schema.StringAttribute{ + Description: "The title of the pull request.", + Required: true, + }, + "body": schema.StringAttribute{ + Description: "The body/description of the pull request.", + Optional: true, + }, + "merge_when_ready": schema.BoolAttribute{ + Description: "Wait for all checks and approvals to pass, then automatically merge.", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + }, + "merge_method": schema.StringAttribute{ + Description: "The merge method to use when auto-merging. Options: 'merge', 'squash', 'rebase'. Defaults to 'merge'.", + Optional: true, + Computed: true, + Default: stringdefault.StaticString("merge"), + }, + "wait_for_checks": schema.BoolAttribute{ + Description: "Wait for CI checks to pass before merging. Only applies when 'merge_when_ready' is true.", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(true), + }, + "auto_delete_branch": schema.BoolAttribute{ + Description: "Automatically delete the head branch after merge.", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + }, + "maintainer_can_modify": schema.BoolAttribute{ + Description: "Allow maintainers to modify the pull request.", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + }, + "base_sha": schema.StringAttribute{ + Description: "The SHA of the base branch.", + Computed: true, + }, + "head_sha": schema.StringAttribute{ + Description: "The SHA of the head branch.", + Computed: true, + }, + "number": schema.Int64Attribute{ + Description: "The pull request number.", + Computed: true, + }, + "state": schema.StringAttribute{ + Description: "The state of the pull request (open, closed, merged).", + Computed: true, + }, + "merged": schema.BoolAttribute{ + Description: "Whether the pull request has been merged.", + Computed: true, + }, + "merged_at": schema.StringAttribute{ + Description: "The timestamp when the pull request was merged.", + Computed: true, + }, + "merge_commit_sha": schema.StringAttribute{ + Description: "The SHA of the merge commit.", + Computed: true, + }, + "id": schema.StringAttribute{ + Description: "The Terraform state ID (repository:number).", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + }, + } +} + +// Configure enables provider-level data or clients to be set in the +// provider-defined resource type. +func (r *repositoryPullRequestResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + clientData, ok := req.ProviderData.(githubxClientData) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected githubxClientData, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + return + } + + r.client = clientData.Client + r.owner = clientData.Owner +} + +// Create creates the resource and sets the initial Terraform state. +func (r *repositoryPullRequestResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan repositoryPullRequestResourceModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + if r.client == nil { + resp.Diagnostics.AddError( + "Client Error", + "GitHub client is not configured. Please ensure a token is provided.", + ) + return + } + + owner, err := r.getOwner(ctx) + if err != nil { + resp.Diagnostics.AddError( + "Missing Owner", + fmt.Sprintf("Unable to determine owner: %v. Please set provider-level `owner` configuration or ensure authentication is working.", err), + ) + return + } + + repoName := plan.Repository.ValueString() + baseRef := plan.BaseRef.ValueString() + headRef := plan.HeadRef.ValueString() + title := plan.Title.ValueString() + + if baseRef == headRef { + resp.Diagnostics.AddError( + "Invalid Configuration", + fmt.Sprintf("Base branch '%s' and head branch '%s' cannot be the same. There must be a difference to create a pull request.", baseRef, headRef), + ) + return + } + + existingPR, err := r.findExistingPR(ctx, owner, repoName, baseRef, headRef) + if err != nil { + resp.Diagnostics.AddError( + "Error checking for existing pull request", + fmt.Sprintf("Unable to check for existing pull request in repository %s/%s: %v", owner, repoName, err), + ) + return + } + + var pr *github.PullRequest + if existingPR != nil { + if existingPR.GetState() == "closed" && !existingPR.GetMerged() { + // Reopen the closed PR + log.Printf("[INFO] Reopening closed pull request #%d from '%s' to '%s' in repository %s/%s", existingPR.GetNumber(), headRef, baseRef, owner, repoName) + update := &github.PullRequest{ + State: github.String("open"), + } + reopenedPR, _, err := r.client.PullRequests.Edit(ctx, owner, repoName, existingPR.GetNumber(), update) + if err != nil { + resp.Diagnostics.AddError( + "Error reopening pull request", + fmt.Sprintf("Unable to reopen pull request #%d in repository %s/%s: %v", existingPR.GetNumber(), owner, repoName, err), + ) + return + } + pr = reopenedPR + } else { + // Adopt the existing PR - this handles the case where PR was created outside Terraform + // or if terraform apply is run again after the PR was already created + log.Printf("[INFO] Adopting existing pull request #%d from '%s' to '%s' in repository %s/%s", existingPR.GetNumber(), headRef, baseRef, owner, repoName) + pr = existingPR + } + plan.ID = types.StringValue(fmt.Sprintf("%s:%d", repoName, pr.GetNumber())) + plan.Number = types.Int64Value(int64(pr.GetNumber())) + } else { + // No existing PR found, create a new one + baseSHA, headSHA, err := r.getBranchSHAs(ctx, owner, repoName, baseRef, headRef) + if err != nil { + resp.Diagnostics.AddError( + "Error checking branch differences", + fmt.Sprintf("Unable to get branch information: %v", err), + ) + return + } + + if baseSHA == headSHA { + resp.Diagnostics.AddError( + "No Differences", + fmt.Sprintf("Branches '%s' and '%s' are at the same commit (SHA: %s). There are no changes to create a pull request.", headRef, baseRef, headSHA), + ) + return + } + + newPR := &github.NewPullRequest{ + Title: github.String(title), + Head: github.String(headRef), + Base: github.String(baseRef), + MaintainerCanModify: github.Bool(plan.MaintainerCanModify.ValueBool()), + } + + if !plan.Body.IsNull() && !plan.Body.IsUnknown() { + newPR.Body = github.String(plan.Body.ValueString()) + } + + pr, _, err = r.client.PullRequests.Create(ctx, owner, repoName, newPR) + if err != nil { + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) && ghErr.Response != nil && ghErr.Response.StatusCode == http.StatusUnprocessableEntity { + if strings.Contains(err.Error(), "already exists") || strings.Contains(err.Error(), "No commits between") { + resp.Diagnostics.AddError( + "Pull Request Already Exists or No Changes", + fmt.Sprintf("Unable to create pull request: %v. A pull request may already exist, or there are no commits between '%s' and '%s'.", err, headRef, baseRef), + ) + return + } + } + resp.Diagnostics.AddError( + "Error creating pull request", + fmt.Sprintf("Unable to create pull request in repository %s/%s: %v", owner, repoName, err), + ) + return + } + + plan.ID = types.StringValue(fmt.Sprintf("%s:%d", repoName, pr.GetNumber())) + plan.Number = types.Int64Value(int64(pr.GetNumber())) + } + + // Only attempt auto-merge if PR is open and not already merged + if pr.GetState() == "open" && !pr.GetMerged() { + if plan.MergeWhenReady.ValueBool() { + if err := r.handleAutoMerge(ctx, owner, repoName, pr.GetNumber(), &plan, &resp.Diagnostics); err != nil { + resp.Diagnostics.AddError( + "Error setting up auto-merge", + fmt.Sprintf("Unable to set up auto-merge for pull request #%d: %v", pr.GetNumber(), err), + ) + return + } + } + } + + r.readPullRequest(ctx, owner, repoName, pr.GetNumber(), &plan, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +// Read refreshes the Terraform state with the latest data. +func (r *repositoryPullRequestResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state repositoryPullRequestResourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + if r.client == nil { + resp.Diagnostics.AddError( + "Client Error", + "GitHub client is not configured. Please ensure a token is provided.", + ) + return + } + + owner, err := r.getOwner(ctx) + if err != nil { + resp.Diagnostics.AddError( + "Missing Owner", + fmt.Sprintf("Unable to determine owner: %v. Please set provider-level `owner` configuration or ensure authentication is working.", err), + ) + return + } + + id := state.ID.ValueString() + parts := strings.SplitN(id, ":", 2) + if len(parts) != 2 { + resp.Diagnostics.AddError( + "Invalid ID", + fmt.Sprintf("Invalid ID format: %s. Expected 'repository:number'.", id), + ) + return + } + + repoName := parts[0] + numberStr := parts[1] + number, err := strconv.Atoi(numberStr) + if err != nil { + resp.Diagnostics.AddError( + "Invalid PR Number", + fmt.Sprintf("Invalid PR number in ID: %s", numberStr), + ) + return + } + + r.readPullRequest(ctx, owner, repoName, number, &state, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + if state.State.ValueString() == "closed" && !state.Merged.ValueBool() { + log.Printf("[INFO] Pull request #%d is closed but not merged, removing from state", number) + state.ID = types.StringValue("") + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +// Update updates the resource and sets the updated Terraform state on success. +func (r *repositoryPullRequestResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, state repositoryPullRequestResourceModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + if r.client == nil { + resp.Diagnostics.AddError( + "Client Error", + "GitHub client is not configured. Please ensure a token is provided.", + ) + return + } + + owner, err := r.getOwner(ctx) + if err != nil { + resp.Diagnostics.AddError( + "Missing Owner", + fmt.Sprintf("Unable to determine owner: %v. Please set provider-level `owner` configuration or ensure authentication is working.", err), + ) + return + } + + id := state.ID.ValueString() + parts := strings.SplitN(id, ":", 2) + if len(parts) != 2 { + resp.Diagnostics.AddError( + "Invalid ID", + fmt.Sprintf("Invalid ID format: %s. Expected 'repository:number'.", id), + ) + return + } + + repoName := parts[0] + numberStr := parts[1] + number, err := strconv.Atoi(numberStr) + if err != nil { + resp.Diagnostics.AddError( + "Invalid PR Number", + fmt.Sprintf("Invalid PR number in ID: %s", numberStr), + ) + return + } + + update := &github.PullRequest{ + Title: github.String(plan.Title.ValueString()), + MaintainerCanModify: github.Bool(plan.MaintainerCanModify.ValueBool()), + } + + if !plan.Body.IsNull() && !plan.Body.IsUnknown() { + update.Body = github.String(plan.Body.ValueString()) + } + + if !plan.BaseRef.Equal(state.BaseRef) { + update.Base = &github.PullRequestBranch{ + Ref: github.String(plan.BaseRef.ValueString()), + } + } + + _, _, err = r.client.PullRequests.Edit(ctx, owner, repoName, number, update) + if err != nil { + resp.Diagnostics.AddError( + "Error updating pull request", + fmt.Sprintf("Unable to update pull request #%d in repository %s/%s: %v", number, owner, repoName, err), + ) + return + } + + if !plan.MergeWhenReady.Equal(state.MergeWhenReady) && plan.MergeWhenReady.ValueBool() { + if err := r.handleAutoMerge(ctx, owner, repoName, number, &plan, &resp.Diagnostics); err != nil { + resp.Diagnostics.AddError( + "Error setting up auto-merge", + fmt.Sprintf("Unable to set up auto-merge for pull request #%d: %v", number, err), + ) + return + } + } + + r.readPullRequest(ctx, owner, repoName, number, &plan, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +// Delete deletes the resource and removes the Terraform state on success. +func (r *repositoryPullRequestResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state repositoryPullRequestResourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + if r.client == nil { + resp.Diagnostics.AddError( + "Client Error", + "GitHub client is not configured. Please ensure a token is provided.", + ) + return + } + + owner, err := r.getOwner(ctx) + if err != nil { + resp.Diagnostics.AddError( + "Missing Owner", + fmt.Sprintf("Unable to determine owner: %v. Please set provider-level `owner` configuration or ensure authentication is working.", err), + ) + return + } + + id := state.ID.ValueString() + parts := strings.SplitN(id, ":", 2) + if len(parts) != 2 { + resp.Diagnostics.AddError( + "Invalid ID", + fmt.Sprintf("Invalid ID format: %s. Expected 'repository:number'.", id), + ) + return + } + + repoName := parts[0] + numberStr := parts[1] + number, err := strconv.Atoi(numberStr) + if err != nil { + resp.Diagnostics.AddError( + "Invalid PR Number", + fmt.Sprintf("Invalid PR number in ID: %s", numberStr), + ) + return + } + + pr, _, err := r.client.PullRequests.Get(ctx, owner, repoName, number) + if err != nil { + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) && ghErr.Response != nil && ghErr.Response.StatusCode == http.StatusNotFound { + log.Printf("[INFO] Pull request #%d not found, assuming already deleted", number) + return + } + resp.Diagnostics.AddError( + "Error reading pull request", + fmt.Sprintf("Unable to read pull request #%d from repository %s/%s: %v", number, owner, repoName, err), + ) + return + } + + // If PR is already merged, don't close it - just cleanup + if pr.GetMerged() { + log.Printf("[INFO] Pull request #%d is already merged, skipping close operation", number) + return + } + + // Close the PR if it's still open + if pr.GetState() == "open" { + update := &github.PullRequest{State: github.String("closed")} + _, _, err = r.client.PullRequests.Edit(ctx, owner, repoName, number, update) + if err != nil { + resp.Diagnostics.AddError( + "Error closing pull request", + fmt.Sprintf("Unable to close pull request #%d in repository %s/%s: %v", number, owner, repoName, err), + ) + return + } + log.Printf("[INFO] Closed pull request #%d", number) + } else { + log.Printf("[INFO] Pull request #%d is already closed", number) + } +} + +// ImportState imports the resource. +func (r *repositoryPullRequestResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + parts := strings.SplitN(req.ID, ":", 2) + if len(parts) != 2 { + resp.Diagnostics.AddError( + "Invalid Import ID", + "Import ID must be in format 'repository:number'.", + ) + return + } + + repoName := parts[0] + numberStr := parts[1] + number, err := strconv.Atoi(numberStr) + if err != nil { + resp.Diagnostics.AddError( + "Invalid PR Number", + fmt.Sprintf("Invalid PR number: %s", numberStr), + ) + return + } + + owner, err := r.getOwner(ctx) + if err != nil { + resp.Diagnostics.AddError( + "Missing Owner", + fmt.Sprintf("Unable to determine owner: %v", err), + ) + return + } + + pr, _, err := r.client.PullRequests.Get(ctx, owner, repoName, number) + if err != nil { + resp.Diagnostics.AddError( + "Error importing pull request", + fmt.Sprintf("Unable to read pull request #%d from repository %s/%s: %v", number, owner, repoName, err), + ) + return + } + + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), fmt.Sprintf("%s:%d", repoName, pr.GetNumber()))...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("repository"), repoName)...) +} + +func (r *repositoryPullRequestResource) getOwner(ctx context.Context) (string, error) { + if r.owner != "" { + return r.owner, nil + } + + user, _, err := r.client.Users.Get(ctx, "") + if err != nil { + return "", fmt.Errorf("unable to get authenticated user: %w", err) + } + return user.GetLogin(), nil +} + +func (r *repositoryPullRequestResource) readPullRequest(ctx context.Context, owner, repoName string, number int, model *repositoryPullRequestResourceModel, diags *diag.Diagnostics) { + pr, _, err := r.client.PullRequests.Get(ctx, owner, repoName, number) + if err != nil { + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) && ghErr.Response != nil && ghErr.Response.StatusCode == http.StatusNotFound { + log.Printf("[INFO] Pull request #%d not found, removing from state", number) + model.ID = types.StringValue("") + return + } + diags.AddError( + "Error reading pull request", + fmt.Sprintf("Unable to read pull request #%d from repository %s/%s: %v", number, owner, repoName, err), + ) + return + } + + model.Repository = types.StringValue(repoName) + model.Number = types.Int64Value(int64(pr.GetNumber())) + model.Title = types.StringValue(pr.GetTitle()) + model.Body = types.StringValue(pr.GetBody()) + model.State = types.StringValue(pr.GetState()) + model.Merged = types.BoolValue(pr.GetMerged()) + model.MaintainerCanModify = types.BoolValue(pr.GetMaintainerCanModify()) + + mergedAt := pr.GetMergedAt() + if !mergedAt.IsZero() && pr.GetMerged() { + model.MergedAt = types.StringValue(mergedAt.Format(time.RFC3339)) + } else { + model.MergedAt = types.StringNull() + } + + mergeCommitSHA := pr.GetMergeCommitSHA() + if mergeCommitSHA != "" && pr.GetMerged() { + model.MergeCommitSHA = types.StringValue(mergeCommitSHA) + } else { + model.MergeCommitSHA = types.StringNull() + } + + if head := pr.GetHead(); head != nil { + model.HeadRef = types.StringValue(head.GetRef()) + model.HeadSHA = types.StringValue(head.GetSHA()) + } + + if base := pr.GetBase(); base != nil { + model.BaseRef = types.StringValue(base.GetRef()) + model.BaseSHA = types.StringValue(base.GetSHA()) + } +} + +func (r *repositoryPullRequestResource) handleAutoMerge(ctx context.Context, owner, repoName string, number int, plan *repositoryPullRequestResourceModel, diags *diag.Diagnostics) error { + if plan.MergeWhenReady.ValueBool() { + return r.mergeWhenReady(ctx, owner, repoName, number, plan, diags) + } + + return nil +} + +func (r *repositoryPullRequestResource) mergeWhenReady(ctx context.Context, owner, repoName string, number int, plan *repositoryPullRequestResourceModel, _ *diag.Diagnostics) error { + maxAttempts := 30 + attempt := 0 + + for attempt < maxAttempts { + pr, _, err := r.client.PullRequests.Get(ctx, owner, repoName, number) + if err != nil { + return fmt.Errorf("unable to get pull request: %w", err) + } + + if pr.GetState() != "open" { + if pr.GetMerged() { + return nil + } + return fmt.Errorf("pull request is not open") + } + + // Check mergeability - GitHub API returns *bool (nil = not computed yet, false = not mergeable, true = mergeable) + mergeablePtr := pr.Mergeable + if mergeablePtr == nil { + log.Printf("[DEBUG] PR mergeability not yet computed, waiting... (attempt %d/%d)", attempt+1, maxAttempts) + attempt++ + time.Sleep(5 * time.Second) + continue + } + + if !*mergeablePtr { + log.Printf("[DEBUG] PR is not mergeable (conflicts or other issues), waiting... (attempt %d/%d)", attempt+1, maxAttempts) + attempt++ + time.Sleep(5 * time.Second) + continue + } + + // PR is mergeable, proceed with merge + if plan.WaitForChecks.ValueBool() { + if err := r.waitForChecks(ctx, owner, repoName, number); err != nil { + log.Printf("[DEBUG] Checks not ready, retrying... (attempt %d/%d)", attempt+1, maxAttempts) + attempt++ + time.Sleep(5 * time.Second) + continue + } + } + + _, _, err = r.client.PullRequests.CreateReview(ctx, owner, repoName, number, &github.PullRequestReviewRequest{ + Event: github.String("APPROVE"), + Body: github.String("Auto-approved by Terraform"), + }) + if err != nil { + log.Printf("[WARN] Failed to approve PR: %v", err) + } + + mergeMethod := plan.MergeMethod.ValueString() + if mergeMethod == "" { + mergeMethod = "merge" + } + + // Check repository merge settings to validate merge method + repo, _, err := r.client.Repositories.Get(ctx, owner, repoName) + if err == nil && repo != nil { + // Validate merge method against repository settings + switch mergeMethod { + case "squash": + if !repo.GetAllowSquashMerge() { + // Fall back to merge if squash not allowed + if repo.GetAllowMergeCommit() { + log.Printf("[WARN] Squash merge not allowed, falling back to merge commit") + mergeMethod = "merge" + } else if repo.GetAllowRebaseMerge() { + log.Printf("[WARN] Squash merge not allowed, falling back to rebase") + mergeMethod = "rebase" + } else { + return fmt.Errorf("squash merge is not allowed on this repository and no alternative merge methods are enabled") + } + } + case "rebase": + if !repo.GetAllowRebaseMerge() { + // Fall back to merge if rebase not allowed + if repo.GetAllowMergeCommit() { + log.Printf("[WARN] Rebase merge not allowed, falling back to merge commit") + mergeMethod = "merge" + } else if repo.GetAllowSquashMerge() { + log.Printf("[WARN] Rebase merge not allowed, falling back to squash") + mergeMethod = "squash" + } else { + return fmt.Errorf("rebase merge is not allowed on this repository and no alternative merge methods are enabled") + } + } + case "merge": + if !repo.GetAllowMergeCommit() { + // Fall back to squash if merge not allowed + if repo.GetAllowSquashMerge() { + log.Printf("[WARN] Merge commit not allowed, falling back to squash") + mergeMethod = "squash" + } else if repo.GetAllowRebaseMerge() { + log.Printf("[WARN] Merge commit not allowed, falling back to rebase") + mergeMethod = "rebase" + } else { + return fmt.Errorf("merge commit is not allowed on this repository and no alternative merge methods are enabled") + } + } + } + } + + _, _, err = r.client.PullRequests.Merge(ctx, owner, repoName, number, "", &github.PullRequestOptions{ + MergeMethod: mergeMethod, + }) + if err != nil { + // If merge fails due to method not allowed, try to find an allowed method + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) && ghErr.Response != nil && ghErr.Response.StatusCode == http.StatusMethodNotAllowed { + if repo != nil { + // Try alternative methods + if mergeMethod != "merge" && repo.GetAllowMergeCommit() { + log.Printf("[WARN] %s merge failed, trying merge commit", mergeMethod) + _, _, err = r.client.PullRequests.Merge(ctx, owner, repoName, number, "", &github.PullRequestOptions{ + MergeMethod: "merge", + }) + } else if mergeMethod != "squash" && repo.GetAllowSquashMerge() { + log.Printf("[WARN] %s merge failed, trying squash", mergeMethod) + _, _, err = r.client.PullRequests.Merge(ctx, owner, repoName, number, "", &github.PullRequestOptions{ + MergeMethod: "squash", + }) + } else if mergeMethod != "rebase" && repo.GetAllowRebaseMerge() { + log.Printf("[WARN] %s merge failed, trying rebase", mergeMethod) + _, _, err = r.client.PullRequests.Merge(ctx, owner, repoName, number, "", &github.PullRequestOptions{ + MergeMethod: "rebase", + }) + } + } + } + if err != nil { + return fmt.Errorf("unable to merge pull request: %w", err) + } + } + + log.Printf("[INFO] Successfully merged pull request #%d", number) + + if plan.AutoDeleteBranch.ValueBool() { + headRef := plan.HeadRef.ValueString() + ref := fmt.Sprintf("refs/heads/%s", headRef) + _, err = r.client.Git.DeleteRef(ctx, owner, repoName, ref) + if err != nil { + log.Printf("[WARN] Failed to delete branch %s: %v", headRef, err) + } else { + log.Printf("[INFO] Deleted branch %s", headRef) + } + } + + return nil + } + + return fmt.Errorf("pull request not ready to merge after %d attempts", maxAttempts) +} + +func (r *repositoryPullRequestResource) findExistingPR(ctx context.Context, owner, repoName, baseRef, headRef string) (*github.PullRequest, error) { + opts := &github.PullRequestListOptions{ + State: "all", + Head: fmt.Sprintf("%s:%s", owner, headRef), + Base: baseRef, + ListOptions: github.ListOptions{PerPage: 100}, + } + + prs, _, err := r.client.PullRequests.List(ctx, owner, repoName, opts) + if err != nil { + return nil, err + } + + for _, pr := range prs { + if pr.GetBase().GetRef() == baseRef && pr.GetHead().GetRef() == headRef { + return pr, nil + } + } + + return nil, nil +} + +func (r *repositoryPullRequestResource) getBranchSHAs(ctx context.Context, owner, repoName, baseRef, headRef string) (string, string, error) { + baseRefFull := fmt.Sprintf("refs/heads/%s", baseRef) + headRefFull := fmt.Sprintf("refs/heads/%s", headRef) + + baseRefObj, _, err := r.client.Git.GetRef(ctx, owner, repoName, baseRefFull) + if err != nil { + return "", "", fmt.Errorf("unable to get base branch %s: %w", baseRef, err) + } + + headRefObj, _, err := r.client.Git.GetRef(ctx, owner, repoName, headRefFull) + if err != nil { + return "", "", fmt.Errorf("unable to get head branch %s: %w", headRef, err) + } + + baseSHA := baseRefObj.GetObject().GetSHA() + headSHA := headRefObj.GetObject().GetSHA() + + return baseSHA, headSHA, nil +} + +func (r *repositoryPullRequestResource) waitForChecks(ctx context.Context, owner, repoName string, number int) error { + maxAttempts := 60 + attempt := 0 + + for attempt < maxAttempts { + pr, _, err := r.client.PullRequests.Get(ctx, owner, repoName, number) + if err != nil { + return fmt.Errorf("unable to get pull request: %w", err) + } + + headSHA := pr.GetHead().GetSHA() + statuses, _, err := r.client.Repositories.ListStatuses(ctx, owner, repoName, headSHA, nil) + if err != nil { + return fmt.Errorf("unable to get status checks: %w", err) + } + + allPassed := true + for _, status := range statuses { + state := status.GetState() + if state == "pending" { + allPassed = false + break + } + if state == "error" || state == "failure" { + return fmt.Errorf("status check %s failed", status.GetContext()) + } + } + + if allPassed && len(statuses) > 0 { + return nil + } + + if len(statuses) == 0 { + log.Printf("[DEBUG] No status checks found, proceeding") + return nil + } + + attempt++ + time.Sleep(5 * time.Second) + } + + return fmt.Errorf("status checks did not complete after %d attempts", maxAttempts) +} diff --git a/internal/provider/resource_repository_pull_request_auto_merge.go b/internal/provider/resource_repository_pull_request_auto_merge.go new file mode 100644 index 0000000..181f992 --- /dev/null +++ b/internal/provider/resource_repository_pull_request_auto_merge.go @@ -0,0 +1,922 @@ +package provider + +import ( + "context" + "errors" + "fmt" + "log" + "net/http" + "strconv" + "strings" + "time" + + "github.com/google/go-github/v60/github" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ resource.Resource = &repositoryPullRequestAutoMergeResource{} + _ resource.ResourceWithConfigure = &repositoryPullRequestAutoMergeResource{} + _ resource.ResourceWithImportState = &repositoryPullRequestAutoMergeResource{} +) + +// NewRepositoryPullRequestAutoMergeResource is a helper function to simplify the provider implementation. +func NewRepositoryPullRequestAutoMergeResource() resource.Resource { + return &repositoryPullRequestAutoMergeResource{} +} + +// repositoryPullRequestAutoMergeResource is the resource implementation. +type repositoryPullRequestAutoMergeResource struct { + client *github.Client + owner string +} + +// repositoryPullRequestAutoMergeResourceModel maps the resource schema data. +type repositoryPullRequestAutoMergeResourceModel struct { + Repository types.String `tfsdk:"repository"` + BaseRef types.String `tfsdk:"base_ref"` + HeadRef types.String `tfsdk:"head_ref"` + Title types.String `tfsdk:"title"` + Body types.String `tfsdk:"body"` + MergeWhenReady types.Bool `tfsdk:"merge_when_ready"` + MergeMethod types.String `tfsdk:"merge_method"` + WaitForChecks types.Bool `tfsdk:"wait_for_checks"` + AutoDeleteBranch types.Bool `tfsdk:"auto_delete_branch"` + MaintainerCanModify types.Bool `tfsdk:"maintainer_can_modify"` + BaseSHA types.String `tfsdk:"base_sha"` + HeadSHA types.String `tfsdk:"head_sha"` + Number types.Int64 `tfsdk:"number"` + State types.String `tfsdk:"state"` + Merged types.Bool `tfsdk:"merged"` + MergedAt types.String `tfsdk:"merged_at"` + MergeCommitSHA types.String `tfsdk:"merge_commit_sha"` + ID types.String `tfsdk:"id"` +} + +// Metadata returns the resource type name. +func (r *repositoryPullRequestAutoMergeResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_repository_pull_request_auto_merge" +} + +// Schema defines the schema for the resource. +func (r *repositoryPullRequestAutoMergeResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Creates and manages a GitHub pull request with optional auto-merge capabilities. Supports multiple files through branch-based commits.", + Attributes: map[string]schema.Attribute{ + "repository": schema.StringAttribute{ + Description: "The GitHub repository name.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "base_ref": schema.StringAttribute{ + Description: "The base branch name (e.g., 'main', 'develop').", + Required: true, + }, + "head_ref": schema.StringAttribute{ + Description: "The head branch name (e.g., 'feature-branch').", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "title": schema.StringAttribute{ + Description: "The title of the pull request.", + Required: true, + }, + "body": schema.StringAttribute{ + Description: "The body/description of the pull request.", + Optional: true, + }, + "merge_when_ready": schema.BoolAttribute{ + Description: "Wait for all checks and approvals to pass, then automatically merge.", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + }, + "merge_method": schema.StringAttribute{ + Description: "The merge method to use when auto-merging. Options: 'merge', 'squash', 'rebase'. Defaults to 'merge'.", + Optional: true, + Computed: true, + Default: stringdefault.StaticString("merge"), + }, + "wait_for_checks": schema.BoolAttribute{ + Description: "Wait for CI checks to pass before merging. Only applies when 'merge_when_ready' is true.", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(true), + }, + "auto_delete_branch": schema.BoolAttribute{ + Description: "Automatically delete the head branch after merge.", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + }, + "maintainer_can_modify": schema.BoolAttribute{ + Description: "Allow maintainers to modify the pull request.", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + }, + "base_sha": schema.StringAttribute{ + Description: "The SHA of the base branch.", + Computed: true, + }, + "head_sha": schema.StringAttribute{ + Description: "The SHA of the head branch.", + Computed: true, + }, + "number": schema.Int64Attribute{ + Description: "The pull request number.", + Computed: true, + }, + "state": schema.StringAttribute{ + Description: "The state of the pull request (open, closed, merged).", + Computed: true, + }, + "merged": schema.BoolAttribute{ + Description: "Whether the pull request has been merged.", + Computed: true, + }, + "merged_at": schema.StringAttribute{ + Description: "The timestamp when the pull request was merged.", + Computed: true, + }, + "merge_commit_sha": schema.StringAttribute{ + Description: "The SHA of the merge commit.", + Computed: true, + }, + "id": schema.StringAttribute{ + Description: "The Terraform state ID (repository:number).", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + }, + } +} + +// Configure enables provider-level data or clients to be set in the +// provider-defined resource type. +func (r *repositoryPullRequestAutoMergeResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + clientData, ok := req.ProviderData.(githubxClientData) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected githubxClientData, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + return + } + + r.client = clientData.Client + r.owner = clientData.Owner +} + +// Create creates the resource and sets the initial Terraform state. +func (r *repositoryPullRequestAutoMergeResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan repositoryPullRequestAutoMergeResourceModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + if r.client == nil { + resp.Diagnostics.AddError( + "Client Error", + "GitHub client is not configured. Please ensure a token is provided.", + ) + return + } + + owner, err := r.getOwner(ctx) + if err != nil { + resp.Diagnostics.AddError( + "Missing Owner", + fmt.Sprintf("Unable to determine owner: %v. Please set provider-level `owner` configuration or ensure authentication is working.", err), + ) + return + } + + repoName := plan.Repository.ValueString() + baseRef := plan.BaseRef.ValueString() + headRef := plan.HeadRef.ValueString() + title := plan.Title.ValueString() + + if baseRef == headRef { + resp.Diagnostics.AddError( + "Invalid Configuration", + fmt.Sprintf("Base branch '%s' and head branch '%s' cannot be the same. There must be a difference to create a pull request.", baseRef, headRef), + ) + return + } + + existingPR, err := r.findExistingPR(ctx, owner, repoName, baseRef, headRef) + if err != nil { + resp.Diagnostics.AddError( + "Error checking for existing pull request", + fmt.Sprintf("Unable to check for existing pull request in repository %s/%s: %v", owner, repoName, err), + ) + return + } + + var pr *github.PullRequest + if existingPR != nil { + if existingPR.GetState() == "closed" && !existingPR.GetMerged() { + // Reopen the closed PR + log.Printf("[INFO] Reopening closed pull request #%d from '%s' to '%s' in repository %s/%s", existingPR.GetNumber(), headRef, baseRef, owner, repoName) + update := &github.PullRequest{ + State: github.String("open"), + } + reopenedPR, _, err := r.client.PullRequests.Edit(ctx, owner, repoName, existingPR.GetNumber(), update) + if err != nil { + resp.Diagnostics.AddError( + "Error reopening pull request", + fmt.Sprintf("Unable to reopen pull request #%d in repository %s/%s: %v", existingPR.GetNumber(), owner, repoName, err), + ) + return + } + pr = reopenedPR + } else { + // Adopt the existing PR - this handles the case where PR was created outside Terraform + // or if terraform apply is run again after the PR was already created + log.Printf("[INFO] Adopting existing pull request #%d from '%s' to '%s' in repository %s/%s", existingPR.GetNumber(), headRef, baseRef, owner, repoName) + pr = existingPR + } + plan.ID = types.StringValue(fmt.Sprintf("%s:%d", repoName, pr.GetNumber())) + plan.Number = types.Int64Value(int64(pr.GetNumber())) + } else { + // No existing PR found, create a new one + baseSHA, headSHA, err := r.getBranchSHAs(ctx, owner, repoName, baseRef, headRef) + if err != nil { + resp.Diagnostics.AddError( + "Error checking branch differences", + fmt.Sprintf("Unable to get branch information: %v", err), + ) + return + } + + if baseSHA == headSHA { + resp.Diagnostics.AddError( + "No Differences", + fmt.Sprintf("Branches '%s' and '%s' are at the same commit (SHA: %s). There are no changes to create a pull request.", headRef, baseRef, headSHA), + ) + return + } + + newPR := &github.NewPullRequest{ + Title: github.String(title), + Head: github.String(headRef), + Base: github.String(baseRef), + MaintainerCanModify: github.Bool(plan.MaintainerCanModify.ValueBool()), + } + + if !plan.Body.IsNull() && !plan.Body.IsUnknown() { + newPR.Body = github.String(plan.Body.ValueString()) + } + + pr, _, err = r.client.PullRequests.Create(ctx, owner, repoName, newPR) + if err != nil { + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) && ghErr.Response != nil && ghErr.Response.StatusCode == http.StatusUnprocessableEntity { + if strings.Contains(err.Error(), "already exists") || strings.Contains(err.Error(), "No commits between") { + resp.Diagnostics.AddError( + "Pull Request Already Exists or No Changes", + fmt.Sprintf("Unable to create pull request: %v. A pull request may already exist, or there are no commits between '%s' and '%s'.", err, headRef, baseRef), + ) + return + } + } + resp.Diagnostics.AddError( + "Error creating pull request", + fmt.Sprintf("Unable to create pull request in repository %s/%s: %v", owner, repoName, err), + ) + return + } + + plan.ID = types.StringValue(fmt.Sprintf("%s:%d", repoName, pr.GetNumber())) + plan.Number = types.Int64Value(int64(pr.GetNumber())) + } + + // Only attempt auto-merge if PR is open and not already merged + if pr.GetState() == "open" && !pr.GetMerged() { + if plan.MergeWhenReady.ValueBool() { + if err := r.handleAutoMerge(ctx, owner, repoName, pr.GetNumber(), &plan, &resp.Diagnostics); err != nil { + resp.Diagnostics.AddError( + "Error setting up auto-merge", + fmt.Sprintf("Unable to set up auto-merge for pull request #%d: %v", pr.GetNumber(), err), + ) + return + } + } + } + + r.readPullRequest(ctx, owner, repoName, pr.GetNumber(), &plan, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +// Read refreshes the Terraform state with the latest data. +func (r *repositoryPullRequestAutoMergeResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state repositoryPullRequestAutoMergeResourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + if r.client == nil { + resp.Diagnostics.AddError( + "Client Error", + "GitHub client is not configured. Please ensure a token is provided.", + ) + return + } + + owner, err := r.getOwner(ctx) + if err != nil { + resp.Diagnostics.AddError( + "Missing Owner", + fmt.Sprintf("Unable to determine owner: %v. Please set provider-level `owner` configuration or ensure authentication is working.", err), + ) + return + } + + id := state.ID.ValueString() + parts := strings.SplitN(id, ":", 2) + if len(parts) != 2 { + resp.Diagnostics.AddError( + "Invalid ID", + fmt.Sprintf("Invalid ID format: %s. Expected 'repository:number'.", id), + ) + return + } + + repoName := parts[0] + numberStr := parts[1] + number, err := strconv.Atoi(numberStr) + if err != nil { + resp.Diagnostics.AddError( + "Invalid PR Number", + fmt.Sprintf("Invalid PR number in ID: %s", numberStr), + ) + return + } + + r.readPullRequest(ctx, owner, repoName, number, &state, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + if state.State.ValueString() == "closed" && !state.Merged.ValueBool() { + log.Printf("[INFO] Pull request #%d is closed but not merged, removing from state", number) + state.ID = types.StringValue("") + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +// Update updates the resource and sets the updated Terraform state on success. +func (r *repositoryPullRequestAutoMergeResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, state repositoryPullRequestAutoMergeResourceModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + if r.client == nil { + resp.Diagnostics.AddError( + "Client Error", + "GitHub client is not configured. Please ensure a token is provided.", + ) + return + } + + owner, err := r.getOwner(ctx) + if err != nil { + resp.Diagnostics.AddError( + "Missing Owner", + fmt.Sprintf("Unable to determine owner: %v. Please set provider-level `owner` configuration or ensure authentication is working.", err), + ) + return + } + + id := state.ID.ValueString() + parts := strings.SplitN(id, ":", 2) + if len(parts) != 2 { + resp.Diagnostics.AddError( + "Invalid ID", + fmt.Sprintf("Invalid ID format: %s. Expected 'repository:number'.", id), + ) + return + } + + repoName := parts[0] + numberStr := parts[1] + number, err := strconv.Atoi(numberStr) + if err != nil { + resp.Diagnostics.AddError( + "Invalid PR Number", + fmt.Sprintf("Invalid PR number in ID: %s", numberStr), + ) + return + } + + update := &github.PullRequest{ + Title: github.String(plan.Title.ValueString()), + MaintainerCanModify: github.Bool(plan.MaintainerCanModify.ValueBool()), + } + + if !plan.Body.IsNull() && !plan.Body.IsUnknown() { + update.Body = github.String(plan.Body.ValueString()) + } + + if !plan.BaseRef.Equal(state.BaseRef) { + update.Base = &github.PullRequestBranch{ + Ref: github.String(plan.BaseRef.ValueString()), + } + } + + _, _, err = r.client.PullRequests.Edit(ctx, owner, repoName, number, update) + if err != nil { + resp.Diagnostics.AddError( + "Error updating pull request", + fmt.Sprintf("Unable to update pull request #%d in repository %s/%s: %v", number, owner, repoName, err), + ) + return + } + + if !plan.MergeWhenReady.Equal(state.MergeWhenReady) && plan.MergeWhenReady.ValueBool() { + if err := r.handleAutoMerge(ctx, owner, repoName, number, &plan, &resp.Diagnostics); err != nil { + resp.Diagnostics.AddError( + "Error setting up auto-merge", + fmt.Sprintf("Unable to set up auto-merge for pull request #%d: %v", number, err), + ) + return + } + } + + r.readPullRequest(ctx, owner, repoName, number, &plan, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +// Delete deletes the resource and removes the Terraform state on success. +func (r *repositoryPullRequestAutoMergeResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state repositoryPullRequestAutoMergeResourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + if r.client == nil { + resp.Diagnostics.AddError( + "Client Error", + "GitHub client is not configured. Please ensure a token is provided.", + ) + return + } + + owner, err := r.getOwner(ctx) + if err != nil { + resp.Diagnostics.AddError( + "Missing Owner", + fmt.Sprintf("Unable to determine owner: %v. Please set provider-level `owner` configuration or ensure authentication is working.", err), + ) + return + } + + id := state.ID.ValueString() + parts := strings.SplitN(id, ":", 2) + if len(parts) != 2 { + resp.Diagnostics.AddError( + "Invalid ID", + fmt.Sprintf("Invalid ID format: %s. Expected 'repository:number'.", id), + ) + return + } + + repoName := parts[0] + numberStr := parts[1] + number, err := strconv.Atoi(numberStr) + if err != nil { + resp.Diagnostics.AddError( + "Invalid PR Number", + fmt.Sprintf("Invalid PR number in ID: %s", numberStr), + ) + return + } + + pr, _, err := r.client.PullRequests.Get(ctx, owner, repoName, number) + if err != nil { + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) && ghErr.Response != nil && ghErr.Response.StatusCode == http.StatusNotFound { + log.Printf("[INFO] Pull request #%d not found, assuming already deleted", number) + return + } + resp.Diagnostics.AddError( + "Error reading pull request", + fmt.Sprintf("Unable to read pull request #%d from repository %s/%s: %v", number, owner, repoName, err), + ) + return + } + + // If PR is already merged, don't close it - just cleanup + if pr.GetMerged() { + log.Printf("[INFO] Pull request #%d is already merged, skipping close operation", number) + return + } + + // Close the PR if it's still open + if pr.GetState() == "open" { + update := &github.PullRequest{State: github.String("closed")} + _, _, err = r.client.PullRequests.Edit(ctx, owner, repoName, number, update) + if err != nil { + resp.Diagnostics.AddError( + "Error closing pull request", + fmt.Sprintf("Unable to close pull request #%d in repository %s/%s: %v", number, owner, repoName, err), + ) + return + } + log.Printf("[INFO] Closed pull request #%d", number) + } else { + log.Printf("[INFO] Pull request #%d is already closed", number) + } +} + +// ImportState imports the resource. +func (r *repositoryPullRequestAutoMergeResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + parts := strings.SplitN(req.ID, ":", 2) + if len(parts) != 2 { + resp.Diagnostics.AddError( + "Invalid Import ID", + "Import ID must be in format 'repository:number'.", + ) + return + } + + repoName := parts[0] + numberStr := parts[1] + number, err := strconv.Atoi(numberStr) + if err != nil { + resp.Diagnostics.AddError( + "Invalid PR Number", + fmt.Sprintf("Invalid PR number: %s", numberStr), + ) + return + } + + owner, err := r.getOwner(ctx) + if err != nil { + resp.Diagnostics.AddError( + "Missing Owner", + fmt.Sprintf("Unable to determine owner: %v", err), + ) + return + } + + pr, _, err := r.client.PullRequests.Get(ctx, owner, repoName, number) + if err != nil { + resp.Diagnostics.AddError( + "Error importing pull request", + fmt.Sprintf("Unable to read pull request #%d from repository %s/%s: %v", number, owner, repoName, err), + ) + return + } + + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), fmt.Sprintf("%s:%d", repoName, pr.GetNumber()))...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("repository"), repoName)...) +} + +func (r *repositoryPullRequestAutoMergeResource) getOwner(ctx context.Context) (string, error) { + if r.owner != "" { + return r.owner, nil + } + + user, _, err := r.client.Users.Get(ctx, "") + if err != nil { + return "", fmt.Errorf("unable to get authenticated user: %w", err) + } + return user.GetLogin(), nil +} + +func (r *repositoryPullRequestAutoMergeResource) readPullRequest(ctx context.Context, owner, repoName string, number int, model *repositoryPullRequestAutoMergeResourceModel, diags *diag.Diagnostics) { + pr, _, err := r.client.PullRequests.Get(ctx, owner, repoName, number) + if err != nil { + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) && ghErr.Response != nil && ghErr.Response.StatusCode == http.StatusNotFound { + log.Printf("[INFO] Pull request #%d not found, removing from state", number) + model.ID = types.StringValue("") + return + } + diags.AddError( + "Error reading pull request", + fmt.Sprintf("Unable to read pull request #%d from repository %s/%s: %v", number, owner, repoName, err), + ) + return + } + + model.Repository = types.StringValue(repoName) + model.Number = types.Int64Value(int64(pr.GetNumber())) + model.Title = types.StringValue(pr.GetTitle()) + model.Body = types.StringValue(pr.GetBody()) + model.State = types.StringValue(pr.GetState()) + model.Merged = types.BoolValue(pr.GetMerged()) + model.MaintainerCanModify = types.BoolValue(pr.GetMaintainerCanModify()) + + mergedAt := pr.GetMergedAt() + if !mergedAt.IsZero() && pr.GetMerged() { + model.MergedAt = types.StringValue(mergedAt.Format(time.RFC3339)) + } else { + model.MergedAt = types.StringNull() + } + + mergeCommitSHA := pr.GetMergeCommitSHA() + if mergeCommitSHA != "" && pr.GetMerged() { + model.MergeCommitSHA = types.StringValue(mergeCommitSHA) + } else { + model.MergeCommitSHA = types.StringNull() + } + + if head := pr.GetHead(); head != nil { + model.HeadRef = types.StringValue(head.GetRef()) + model.HeadSHA = types.StringValue(head.GetSHA()) + } + + if base := pr.GetBase(); base != nil { + model.BaseRef = types.StringValue(base.GetRef()) + model.BaseSHA = types.StringValue(base.GetSHA()) + } +} + +func (r *repositoryPullRequestAutoMergeResource) handleAutoMerge(ctx context.Context, owner, repoName string, number int, plan *repositoryPullRequestAutoMergeResourceModel, diags *diag.Diagnostics) error { + if plan.MergeWhenReady.ValueBool() { + return r.mergeWhenReady(ctx, owner, repoName, number, plan, diags) + } + + return nil +} + +func (r *repositoryPullRequestAutoMergeResource) mergeWhenReady(ctx context.Context, owner, repoName string, number int, plan *repositoryPullRequestAutoMergeResourceModel, _ *diag.Diagnostics) error { + maxAttempts := 30 + attempt := 0 + + for attempt < maxAttempts { + pr, _, err := r.client.PullRequests.Get(ctx, owner, repoName, number) + if err != nil { + return fmt.Errorf("unable to get pull request: %w", err) + } + + if pr.GetState() != "open" { + if pr.GetMerged() { + return nil + } + return fmt.Errorf("pull request is not open") + } + + // Check mergeability - GitHub API returns *bool (nil = not computed yet, false = not mergeable, true = mergeable) + mergeablePtr := pr.Mergeable + if mergeablePtr == nil { + log.Printf("[DEBUG] PR mergeability not yet computed, waiting... (attempt %d/%d)", attempt+1, maxAttempts) + attempt++ + time.Sleep(5 * time.Second) + continue + } + + if !*mergeablePtr { + log.Printf("[DEBUG] PR is not mergeable (conflicts or other issues), waiting... (attempt %d/%d)", attempt+1, maxAttempts) + attempt++ + time.Sleep(5 * time.Second) + continue + } + + // PR is mergeable, proceed with merge + if plan.WaitForChecks.ValueBool() { + if err := r.waitForChecks(ctx, owner, repoName, number); err != nil { + log.Printf("[DEBUG] Checks not ready, retrying... (attempt %d/%d)", attempt+1, maxAttempts) + attempt++ + time.Sleep(5 * time.Second) + continue + } + } + + _, _, err = r.client.PullRequests.CreateReview(ctx, owner, repoName, number, &github.PullRequestReviewRequest{ + Event: github.String("APPROVE"), + Body: github.String("Auto-approved by Terraform"), + }) + if err != nil { + log.Printf("[WARN] Failed to approve PR: %v", err) + } + + mergeMethod := plan.MergeMethod.ValueString() + if mergeMethod == "" { + mergeMethod = "merge" + } + + // Check repository merge settings to validate merge method + repo, _, err := r.client.Repositories.Get(ctx, owner, repoName) + if err == nil && repo != nil { + // Validate merge method against repository settings + switch mergeMethod { + case "squash": + if !repo.GetAllowSquashMerge() { + // Fall back to merge if squash not allowed + if repo.GetAllowMergeCommit() { + log.Printf("[WARN] Squash merge not allowed, falling back to merge commit") + mergeMethod = "merge" + } else if repo.GetAllowRebaseMerge() { + log.Printf("[WARN] Squash merge not allowed, falling back to rebase") + mergeMethod = "rebase" + } else { + return fmt.Errorf("squash merge is not allowed on this repository and no alternative merge methods are enabled") + } + } + case "rebase": + if !repo.GetAllowRebaseMerge() { + // Fall back to merge if rebase not allowed + if repo.GetAllowMergeCommit() { + log.Printf("[WARN] Rebase merge not allowed, falling back to merge commit") + mergeMethod = "merge" + } else if repo.GetAllowSquashMerge() { + log.Printf("[WARN] Rebase merge not allowed, falling back to squash") + mergeMethod = "squash" + } else { + return fmt.Errorf("rebase merge is not allowed on this repository and no alternative merge methods are enabled") + } + } + case "merge": + if !repo.GetAllowMergeCommit() { + // Fall back to squash if merge not allowed + if repo.GetAllowSquashMerge() { + log.Printf("[WARN] Merge commit not allowed, falling back to squash") + mergeMethod = "squash" + } else if repo.GetAllowRebaseMerge() { + log.Printf("[WARN] Merge commit not allowed, falling back to rebase") + mergeMethod = "rebase" + } else { + return fmt.Errorf("merge commit is not allowed on this repository and no alternative merge methods are enabled") + } + } + } + } + + _, _, err = r.client.PullRequests.Merge(ctx, owner, repoName, number, "", &github.PullRequestOptions{ + MergeMethod: mergeMethod, + }) + if err != nil { + // If merge fails due to method not allowed, try to find an allowed method + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) && ghErr.Response != nil && ghErr.Response.StatusCode == http.StatusMethodNotAllowed { + if repo != nil { + // Try alternative methods + if mergeMethod != "merge" && repo.GetAllowMergeCommit() { + log.Printf("[WARN] %s merge failed, trying merge commit", mergeMethod) + _, _, err = r.client.PullRequests.Merge(ctx, owner, repoName, number, "", &github.PullRequestOptions{ + MergeMethod: "merge", + }) + } else if mergeMethod != "squash" && repo.GetAllowSquashMerge() { + log.Printf("[WARN] %s merge failed, trying squash", mergeMethod) + _, _, err = r.client.PullRequests.Merge(ctx, owner, repoName, number, "", &github.PullRequestOptions{ + MergeMethod: "squash", + }) + } else if mergeMethod != "rebase" && repo.GetAllowRebaseMerge() { + log.Printf("[WARN] %s merge failed, trying rebase", mergeMethod) + _, _, err = r.client.PullRequests.Merge(ctx, owner, repoName, number, "", &github.PullRequestOptions{ + MergeMethod: "rebase", + }) + } + } + } + if err != nil { + return fmt.Errorf("unable to merge pull request: %w", err) + } + } + + log.Printf("[INFO] Successfully merged pull request #%d", number) + + if plan.AutoDeleteBranch.ValueBool() { + headRef := plan.HeadRef.ValueString() + ref := fmt.Sprintf("refs/heads/%s", headRef) + _, err = r.client.Git.DeleteRef(ctx, owner, repoName, ref) + if err != nil { + log.Printf("[WARN] Failed to delete branch %s: %v", headRef, err) + } else { + log.Printf("[INFO] Deleted branch %s", headRef) + } + } + + return nil + } + + return fmt.Errorf("pull request not ready to merge after %d attempts", maxAttempts) +} + +func (r *repositoryPullRequestAutoMergeResource) findExistingPR(ctx context.Context, owner, repoName, baseRef, headRef string) (*github.PullRequest, error) { + opts := &github.PullRequestListOptions{ + State: "all", + Head: fmt.Sprintf("%s:%s", owner, headRef), + Base: baseRef, + ListOptions: github.ListOptions{PerPage: 100}, + } + + prs, _, err := r.client.PullRequests.List(ctx, owner, repoName, opts) + if err != nil { + return nil, err + } + + for _, pr := range prs { + if pr.GetBase().GetRef() == baseRef && pr.GetHead().GetRef() == headRef { + return pr, nil + } + } + + return nil, nil +} + +func (r *repositoryPullRequestAutoMergeResource) getBranchSHAs(ctx context.Context, owner, repoName, baseRef, headRef string) (string, string, error) { + baseRefFull := fmt.Sprintf("refs/heads/%s", baseRef) + headRefFull := fmt.Sprintf("refs/heads/%s", headRef) + + baseRefObj, _, err := r.client.Git.GetRef(ctx, owner, repoName, baseRefFull) + if err != nil { + return "", "", fmt.Errorf("unable to get base branch %s: %w", baseRef, err) + } + + headRefObj, _, err := r.client.Git.GetRef(ctx, owner, repoName, headRefFull) + if err != nil { + return "", "", fmt.Errorf("unable to get head branch %s: %w", headRef, err) + } + + baseSHA := baseRefObj.GetObject().GetSHA() + headSHA := headRefObj.GetObject().GetSHA() + + return baseSHA, headSHA, nil +} + +func (r *repositoryPullRequestAutoMergeResource) waitForChecks(ctx context.Context, owner, repoName string, number int) error { + maxAttempts := 60 + attempt := 0 + + for attempt < maxAttempts { + pr, _, err := r.client.PullRequests.Get(ctx, owner, repoName, number) + if err != nil { + return fmt.Errorf("unable to get pull request: %w", err) + } + + headSHA := pr.GetHead().GetSHA() + statuses, _, err := r.client.Repositories.ListStatuses(ctx, owner, repoName, headSHA, nil) + if err != nil { + return fmt.Errorf("unable to get status checks: %w", err) + } + + allPassed := true + for _, status := range statuses { + state := status.GetState() + if state == "pending" { + allPassed = false + break + } + if state == "error" || state == "failure" { + return fmt.Errorf("status check %s failed", status.GetContext()) + } + } + + if allPassed && len(statuses) > 0 { + return nil + } + + if len(statuses) == 0 { + log.Printf("[DEBUG] No status checks found, proceeding") + return nil + } + + attempt++ + time.Sleep(5 * time.Second) + } + + return fmt.Errorf("status checks did not complete after %d attempts", maxAttempts) +} diff --git a/internal/provider/resource_repository_pull_request_auto_merge_test.go b/internal/provider/resource_repository_pull_request_auto_merge_test.go new file mode 100644 index 0000000..e32bfa4 --- /dev/null +++ b/internal/provider/resource_repository_pull_request_auto_merge_test.go @@ -0,0 +1,175 @@ +package provider + +import ( + "testing" + + "github.com/google/go-github/v60/github" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/stretchr/testify/assert" +) + +func TestRepositoryPullRequestAutoMergeResource_Metadata(t *testing.T) { + r := NewRepositoryPullRequestAutoMergeResource() + req := resource.MetadataRequest{ + ProviderTypeName: "githubx", + } + resp := &resource.MetadataResponse{} + + r.Metadata(t.Context(), req, resp) + + assert.Equal(t, "githubx_repository_pull_request_auto_merge", resp.TypeName) +} + +func TestRepositoryPullRequestAutoMergeResource_Schema(t *testing.T) { + r := NewRepositoryPullRequestAutoMergeResource() + req := resource.SchemaRequest{} + resp := &resource.SchemaResponse{} + + r.Schema(t.Context(), req, resp) + + assert.NotNil(t, resp.Schema) + assert.Contains(t, resp.Schema.Description, "Creates and manages a GitHub pull request") + + // Check required attributes + repositoryAttr, ok := resp.Schema.Attributes["repository"] + assert.True(t, ok) + assert.True(t, repositoryAttr.IsRequired()) + + baseRefAttr, ok := resp.Schema.Attributes["base_ref"] + assert.True(t, ok) + assert.True(t, baseRefAttr.IsRequired()) + + headRefAttr, ok := resp.Schema.Attributes["head_ref"] + assert.True(t, ok) + assert.True(t, headRefAttr.IsRequired()) + + titleAttr, ok := resp.Schema.Attributes["title"] + assert.True(t, ok) + assert.True(t, titleAttr.IsRequired()) + + // Check optional attributes + bodyAttr, ok := resp.Schema.Attributes["body"] + assert.True(t, ok) + assert.True(t, bodyAttr.IsOptional()) + + mergeWhenReadyAttr, ok := resp.Schema.Attributes["merge_when_ready"] + assert.True(t, ok) + assert.True(t, mergeWhenReadyAttr.IsOptional()) + assert.True(t, mergeWhenReadyAttr.IsComputed()) + + mergeMethodAttr, ok := resp.Schema.Attributes["merge_method"] + assert.True(t, ok) + assert.True(t, mergeMethodAttr.IsOptional()) + assert.True(t, mergeMethodAttr.IsComputed()) + + waitForChecksAttr, ok := resp.Schema.Attributes["wait_for_checks"] + assert.True(t, ok) + assert.True(t, waitForChecksAttr.IsOptional()) + assert.True(t, waitForChecksAttr.IsComputed()) + + autoDeleteBranchAttr, ok := resp.Schema.Attributes["auto_delete_branch"] + assert.True(t, ok) + assert.True(t, autoDeleteBranchAttr.IsOptional()) + assert.True(t, autoDeleteBranchAttr.IsComputed()) + + maintainerCanModifyAttr, ok := resp.Schema.Attributes["maintainer_can_modify"] + assert.True(t, ok) + assert.True(t, maintainerCanModifyAttr.IsOptional()) + assert.True(t, maintainerCanModifyAttr.IsComputed()) + + // Check computed attributes + idAttr, ok := resp.Schema.Attributes["id"] + assert.True(t, ok) + assert.True(t, idAttr.IsComputed()) + + numberAttr, ok := resp.Schema.Attributes["number"] + assert.True(t, ok) + assert.True(t, numberAttr.IsComputed()) + + stateAttr, ok := resp.Schema.Attributes["state"] + assert.True(t, ok) + assert.True(t, stateAttr.IsComputed()) + + mergedAttr, ok := resp.Schema.Attributes["merged"] + assert.True(t, ok) + assert.True(t, mergedAttr.IsComputed()) + + mergedAtAttr, ok := resp.Schema.Attributes["merged_at"] + assert.True(t, ok) + assert.True(t, mergedAtAttr.IsComputed()) + + mergeCommitSHAAttr, ok := resp.Schema.Attributes["merge_commit_sha"] + assert.True(t, ok) + assert.True(t, mergeCommitSHAAttr.IsComputed()) + + baseSHAAttr, ok := resp.Schema.Attributes["base_sha"] + assert.True(t, ok) + assert.True(t, baseSHAAttr.IsComputed()) + + headSHAAttr, ok := resp.Schema.Attributes["head_sha"] + assert.True(t, ok) + assert.True(t, headSHAAttr.IsComputed()) +} + +func TestRepositoryPullRequestAutoMergeResource_Configure(t *testing.T) { + tests := []struct { + name string + providerData interface{} + expectError bool + errorContains string + }{ + { + name: "valid githubxClientData", + providerData: githubxClientData{ + Client: github.NewClient(nil), + Owner: "test-owner", + }, + expectError: false, + }, + { + name: "invalid provider data type", + providerData: "invalid", + expectError: true, + errorContains: "Unexpected Resource Configure Type", + }, + { + name: "nil provider data", + providerData: nil, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rs := &repositoryPullRequestAutoMergeResource{} + req := resource.ConfigureRequest{ + ProviderData: tt.providerData, + } + resp := &resource.ConfigureResponse{} + + rs.Configure(t.Context(), req, resp) + + if tt.expectError { + assert.True(t, resp.Diagnostics.HasError()) + if tt.errorContains != "" { + assert.Contains(t, resp.Diagnostics.Errors()[0].Summary(), tt.errorContains) + } + } else { + assert.False(t, resp.Diagnostics.HasError()) + // If provider data is valid, verify client and owner are set + if tt.providerData != nil { + clientData, ok := tt.providerData.(githubxClientData) + if ok { + assert.Equal(t, clientData.Client, rs.client) + assert.Equal(t, clientData.Owner, rs.owner) + } + } + } + }) + } +} + +// Note: Tests for Create(), Read(), Update(), and Delete() methods that require GitHub API calls +// should be implemented as acceptance tests with TF_ACC=1 environment variable set. +// These unit tests verify the schema, metadata, and configuration validation +// without making API calls. diff --git a/internal/provider/resource_repository_test.go b/internal/provider/resource_repository_test.go new file mode 100644 index 0000000..68bab6f --- /dev/null +++ b/internal/provider/resource_repository_test.go @@ -0,0 +1,246 @@ +package provider + +import ( + "testing" + + "github.com/google/go-github/v60/github" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/stretchr/testify/assert" +) + +func TestRepositoryResource_Metadata(t *testing.T) { + r := NewRepositoryResource() + req := resource.MetadataRequest{ + ProviderTypeName: "githubx", + } + resp := &resource.MetadataResponse{} + + r.Metadata(t.Context(), req, resp) + + assert.Equal(t, "githubx_repository", resp.TypeName) +} + +func TestRepositoryResource_Schema(t *testing.T) { + r := NewRepositoryResource() + req := resource.SchemaRequest{} + resp := &resource.SchemaResponse{} + + r.Schema(t.Context(), req, resp) + + assert.NotNil(t, resp.Schema) + assert.Contains(t, resp.Schema.Description, "Creates and manages a GitHub repository") + + // Check required attribute + nameAttr, ok := resp.Schema.Attributes["name"] + assert.True(t, ok) + assert.True(t, nameAttr.IsRequired()) + + // Check optional attributes + descriptionAttr, ok := resp.Schema.Attributes["description"] + assert.True(t, ok) + assert.True(t, descriptionAttr.IsOptional()) + + homepageURLAttr, ok := resp.Schema.Attributes["homepage_url"] + assert.True(t, ok) + assert.True(t, homepageURLAttr.IsOptional()) + + visibilityAttr, ok := resp.Schema.Attributes["visibility"] + assert.True(t, ok) + assert.True(t, visibilityAttr.IsOptional()) + assert.True(t, visibilityAttr.IsComputed()) + + hasIssuesAttr, ok := resp.Schema.Attributes["has_issues"] + assert.True(t, ok) + assert.True(t, hasIssuesAttr.IsOptional()) + assert.True(t, hasIssuesAttr.IsComputed()) + + hasDiscussionsAttr, ok := resp.Schema.Attributes["has_discussions"] + assert.True(t, ok) + assert.True(t, hasDiscussionsAttr.IsOptional()) + assert.True(t, hasDiscussionsAttr.IsComputed()) + + hasProjectsAttr, ok := resp.Schema.Attributes["has_projects"] + assert.True(t, ok) + assert.True(t, hasProjectsAttr.IsOptional()) + assert.True(t, hasProjectsAttr.IsComputed()) + + hasDownloadsAttr, ok := resp.Schema.Attributes["has_downloads"] + assert.True(t, ok) + assert.True(t, hasDownloadsAttr.IsOptional()) + assert.True(t, hasDownloadsAttr.IsComputed()) + + hasWikiAttr, ok := resp.Schema.Attributes["has_wiki"] + assert.True(t, ok) + assert.True(t, hasWikiAttr.IsOptional()) + assert.True(t, hasWikiAttr.IsComputed()) + + isTemplateAttr, ok := resp.Schema.Attributes["is_template"] + assert.True(t, ok) + assert.True(t, isTemplateAttr.IsOptional()) + assert.True(t, isTemplateAttr.IsComputed()) + + allowMergeCommitAttr, ok := resp.Schema.Attributes["allow_merge_commit"] + assert.True(t, ok) + assert.True(t, allowMergeCommitAttr.IsOptional()) + assert.True(t, allowMergeCommitAttr.IsComputed()) + + allowSquashMergeAttr, ok := resp.Schema.Attributes["allow_squash_merge"] + assert.True(t, ok) + assert.True(t, allowSquashMergeAttr.IsOptional()) + assert.True(t, allowSquashMergeAttr.IsComputed()) + + allowRebaseMergeAttr, ok := resp.Schema.Attributes["allow_rebase_merge"] + assert.True(t, ok) + assert.True(t, allowRebaseMergeAttr.IsOptional()) + assert.True(t, allowRebaseMergeAttr.IsComputed()) + + allowAutoMergeAttr, ok := resp.Schema.Attributes["allow_auto_merge"] + assert.True(t, ok) + assert.True(t, allowAutoMergeAttr.IsOptional()) + assert.True(t, allowAutoMergeAttr.IsComputed()) + + allowUpdateBranchAttr, ok := resp.Schema.Attributes["allow_update_branch"] + assert.True(t, ok) + assert.True(t, allowUpdateBranchAttr.IsOptional()) + assert.True(t, allowUpdateBranchAttr.IsComputed()) + + squashMergeCommitTitleAttr, ok := resp.Schema.Attributes["squash_merge_commit_title"] + assert.True(t, ok) + assert.True(t, squashMergeCommitTitleAttr.IsOptional()) + assert.True(t, squashMergeCommitTitleAttr.IsComputed()) + + squashMergeCommitMessageAttr, ok := resp.Schema.Attributes["squash_merge_commit_message"] + assert.True(t, ok) + assert.True(t, squashMergeCommitMessageAttr.IsOptional()) + assert.True(t, squashMergeCommitMessageAttr.IsComputed()) + + mergeCommitTitleAttr, ok := resp.Schema.Attributes["merge_commit_title"] + assert.True(t, ok) + assert.True(t, mergeCommitTitleAttr.IsOptional()) + assert.True(t, mergeCommitTitleAttr.IsComputed()) + + mergeCommitMessageAttr, ok := resp.Schema.Attributes["merge_commit_message"] + assert.True(t, ok) + assert.True(t, mergeCommitMessageAttr.IsOptional()) + assert.True(t, mergeCommitMessageAttr.IsComputed()) + + deleteBranchOnMergeAttr, ok := resp.Schema.Attributes["delete_branch_on_merge"] + assert.True(t, ok) + assert.True(t, deleteBranchOnMergeAttr.IsOptional()) + assert.True(t, deleteBranchOnMergeAttr.IsComputed()) + + archiveOnDestroyAttr, ok := resp.Schema.Attributes["archive_on_destroy"] + assert.True(t, ok) + assert.True(t, archiveOnDestroyAttr.IsOptional()) + + autoInitAttr, ok := resp.Schema.Attributes["auto_init"] + assert.True(t, ok) + assert.True(t, autoInitAttr.IsOptional()) + assert.True(t, autoInitAttr.IsComputed()) + + topicsAttr, ok := resp.Schema.Attributes["topics"] + assert.True(t, ok) + assert.True(t, topicsAttr.IsOptional()) + + vulnerabilityAlertsAttr, ok := resp.Schema.Attributes["vulnerability_alerts"] + assert.True(t, ok) + assert.True(t, vulnerabilityAlertsAttr.IsOptional()) + assert.True(t, vulnerabilityAlertsAttr.IsComputed()) + + pagesAttr, ok := resp.Schema.Attributes["pages"] + assert.True(t, ok) + assert.True(t, pagesAttr.IsOptional()) + assert.True(t, pagesAttr.IsComputed()) + + // Check computed attributes + idAttr, ok := resp.Schema.Attributes["id"] + assert.True(t, ok) + assert.True(t, idAttr.IsComputed()) + + fullNameAttr, ok := resp.Schema.Attributes["full_name"] + assert.True(t, ok) + assert.True(t, fullNameAttr.IsComputed()) + + defaultBranchAttr, ok := resp.Schema.Attributes["default_branch"] + assert.True(t, ok) + assert.True(t, defaultBranchAttr.IsComputed()) + + htmlURLAttr, ok := resp.Schema.Attributes["html_url"] + assert.True(t, ok) + assert.True(t, htmlURLAttr.IsComputed()) + + nodeIDAttr, ok := resp.Schema.Attributes["node_id"] + assert.True(t, ok) + assert.True(t, nodeIDAttr.IsComputed()) + + repoIDAttr, ok := resp.Schema.Attributes["repo_id"] + assert.True(t, ok) + assert.True(t, repoIDAttr.IsComputed()) + + archivedAttr, ok := resp.Schema.Attributes["archived"] + assert.True(t, ok) + assert.True(t, archivedAttr.IsComputed()) +} + +func TestRepositoryResource_Configure(t *testing.T) { + tests := []struct { + name string + providerData interface{} + expectError bool + errorContains string + }{ + { + name: "valid githubxClientData", + providerData: githubxClientData{ + Client: github.NewClient(nil), + Owner: "test-owner", + }, + expectError: false, + }, + { + name: "invalid provider data type", + providerData: "invalid", + expectError: true, + errorContains: "Unexpected Resource Configure Type", + }, + { + name: "nil provider data", + providerData: nil, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rs := &repositoryResource{} + req := resource.ConfigureRequest{ + ProviderData: tt.providerData, + } + resp := &resource.ConfigureResponse{} + + rs.Configure(t.Context(), req, resp) + + if tt.expectError { + assert.True(t, resp.Diagnostics.HasError()) + if tt.errorContains != "" { + assert.Contains(t, resp.Diagnostics.Errors()[0].Summary(), tt.errorContains) + } + } else { + assert.False(t, resp.Diagnostics.HasError()) + // If provider data is valid, verify client and owner are set + if tt.providerData != nil { + clientData, ok := tt.providerData.(githubxClientData) + if ok { + assert.Equal(t, clientData.Client, rs.client) + assert.Equal(t, clientData.Owner, rs.owner) + } + } + } + }) + } +} + +// Note: Tests for Create(), Read(), Update(), and Delete() methods that require GitHub API calls +// should be implemented as acceptance tests with TF_ACC=1 environment variable set. +// These unit tests verify the schema, metadata, and configuration validation +// without making API calls. diff --git a/main.go b/main.go new file mode 100644 index 0000000..fead607 --- /dev/null +++ b/main.go @@ -0,0 +1,44 @@ +package main + +import ( + "context" + "flag" + "log" + + "github.com/hashicorp/terraform-plugin-framework/providerserver" + "github.com/tfstack/terraform-provider-githubx/internal/provider" +) + +// Run "go generate" to format example terraform files and generate the docs for the registry/website + +// If you do not have terraform installed, you can remove the formatting command, but its suggested to +// ensure the documentation is formatted properly. +//go:generate terraform fmt -recursive ./examples/ + +// Run the docs generation tool, check its repository for more information on how it works and how docs +// can be customized. +//go:generate go run -buildvcs=false github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs generate --provider-name githubx + +var ( + // these will be set by the goreleaser configuration + // to appropriate values for the compiled binary. + version string = "dev" +) + +func main() { + var debug bool + + flag.BoolVar(&debug, "debug", false, "set to true to run the provider with support for debuggers like delve") + flag.Parse() + + opts := providerserver.ServeOpts{ + Address: "registry.terraform.io/tfstack/githubx", + Debug: debug, + } + + err := providerserver.Serve(context.Background(), provider.New(version), opts) + + if err != nil { + log.Fatal(err.Error()) + } +} diff --git a/terraform-registry-manifest.json b/terraform-registry-manifest.json new file mode 100644 index 0000000..fec2a56 --- /dev/null +++ b/terraform-registry-manifest.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "metadata": { + "protocol_versions": ["6.0"] + } +} diff --git a/tools.go b/tools.go new file mode 100644 index 0000000..704890a --- /dev/null +++ b/tools.go @@ -0,0 +1,17 @@ +//go:build tools +// +build tools + +// Package tools tracks build-time dependencies for this module. +// This file is used to ensure that tools like terraform-plugin-docs +// are included in go.mod even though they're only used during build/generate. +package tools + +import ( + _ "github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs" +) + + + + + +