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