From 537ccb089b3a69bcaf487d582f3ee4afd34e3838 Mon Sep 17 00:00:00 2001 From: Phil Vendola Date: Mon, 6 Apr 2026 12:35:25 -0400 Subject: [PATCH 1/5] feat: Trunk.io Terraform provider for merge queues Adds full provider implementation for managing Trunk merge queues via Terraform, including GitHub Actions CI/release workflows and Terraform Registry manifest for publishing. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 34 ++ .github/workflows/test.yml | 38 ++ .gitignore | 10 + .goreleaser.yml | 50 +++ README.md | 84 +++- docs/index.md | 20 + docs/resources/merge_queue.md | 55 +++ examples/.gitignore | 3 + examples/manual-test/main.tf | 22 ++ examples/provider/main.tf | 11 + examples/resources/trunk_merge_queue/main.tf | 11 + examples/staging/main.tf | 30 ++ go.mod | 34 ++ go.sum | 99 +++++ internal/client/CLAUDE.md | 14 + internal/client/client.go | 72 ++++ internal/client/client_test.go | 127 ++++++ internal/client/merge_queue.go | 37 ++ internal/client/merge_queue_test.go | 333 ++++++++++++++++ internal/client/types.go | 189 +++++++++ internal/provider/CLAUDE.md | 21 + internal/provider/merge_queue_resource.go | 366 ++++++++++++++++++ .../provider/merge_queue_resource_model.go | 256 ++++++++++++ internal/provider/provider.go | 93 +++++ main.go | 29 ++ terraform-registry-manifest.json | 6 + 26 files changed, 2042 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/test.yml create mode 100644 .gitignore create mode 100644 .goreleaser.yml create mode 100644 docs/index.md create mode 100644 docs/resources/merge_queue.md create mode 100644 examples/.gitignore create mode 100644 examples/manual-test/main.tf create mode 100644 examples/provider/main.tf create mode 100644 examples/resources/trunk_merge_queue/main.tf create mode 100644 examples/staging/main.tf create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/client/CLAUDE.md create mode 100644 internal/client/client.go create mode 100644 internal/client/client_test.go create mode 100644 internal/client/merge_queue.go create mode 100644 internal/client/merge_queue_test.go create mode 100644 internal/client/types.go create mode 100644 internal/provider/CLAUDE.md create mode 100644 internal/provider/merge_queue_resource.go create mode 100644 internal/provider/merge_queue_resource_model.go create mode 100644 internal/provider/provider.go create mode 100644 main.go create mode 100644 terraform-registry-manifest.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..1949531 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,34 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - name: Import GPG key + uses: crazy-max/ghaction-import-gpg@v6 + id: import_gpg + with: + gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} + passphrase: ${{ secrets.GPG_PASSPHRASE }} + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..b0b5e38 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,38 @@ +name: Tests + +on: + pull_request: + branches: [main] + push: + branches: [main] + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - run: go build ./... + - run: go test ./internal/client/ -v + + acceptance: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - run: go test ./internal/provider/ -v -timeout 10m + env: + TF_ACC: "1" + TRUNK_API_KEY: ${{ secrets.TRUNK_API_KEY }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eed506f --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Build artifacts +terraform-provider-trunk +coverage.out +dist/ + +# Terraform state +*.tfstate +*.tfstate.* +.terraform/ +.terraform.lock.hcl diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..2085eec --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,50 @@ +version: 2 + +builds: + - env: + - CGO_ENABLED=0 + mod_timestamp: "{{ .CommitTimestamp }}" + flags: + - -trimpath + ldflags: + - -s -w -X main.version={{ .Version }} + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + ignore: + - goos: windows + goarch: arm64 + +archives: + - format: zip + name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + +checksum: + name_template: "{{ .ProjectName }}_{{ .Version }}_SHA256SUMS" + algorithm: sha256 + +signs: + - artifacts: checksum + args: + - --batch + - --local-user + - "{{ .Env.GPG_FINGERPRINT }}" + - --output + - ${signature} + - --detach-sign + - ${artifact} + +release: + draft: false + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" + - "^chore:" diff --git a/README.md b/README.md index b74ea79..8421af2 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,82 @@ -# terraform-provider-trunk -A terraform provider for setting up Trunk managed resources through Terraform. Currently, this supports managing Merge Queues +# Terraform Provider for Trunk.io + +The Trunk provider lets you manage [Trunk.io](https://trunk.io) services with Terraform. Currently supports merge queue configuration. + +## Requirements + +- [Terraform](https://www.terraform.io/downloads) >= 1.0 +- [Go](https://golang.org/dl/) >= 1.24 (to build the provider from source) + +## Usage + +```hcl +terraform { + required_providers { + trunk = { + source = "trunk-io/trunk" + version = "~> 0.1" + } + } +} + +provider "trunk" { + # api_key can be set here or via the TRUNK_API_KEY environment variable +} + +resource "trunk_merge_queue" "example" { + repo = { + host = "github.com" + owner = "my-org" + name = "my-repo" + } + target_branch = "main" + mode = "parallel" + concurrency = 3 + merge_method = "SQUASH" +} +``` + +Full documentation is available on the [Terraform Registry](https://registry.terraform.io/providers/trunk-io/trunk/latest/docs). + +## Authentication + +Set your Trunk API key via the `TRUNK_API_KEY` environment variable or the `api_key` provider attribute. Org-level API tokens are required. + +## Development + +### Build + +```bash +go build ./... +``` + +### Test + +```bash +# Unit tests (no API key required) +go test ./internal/client/ -v + +# Acceptance tests (requires a real API key and a test repository) +TF_ACC=1 TRUNK_API_KEY= go test ./internal/provider/ -v -timeout 10m +``` + +### Local testing with Terraform + +Build the binary and configure `dev_overrides` to bypass the registry: + +```bash +go build -o terraform-provider-trunk . +``` + +Add to `~/.terraformrc`: + +```hcl +provider_installation { + dev_overrides { + "registry.terraform.io/trunk-io/trunk" = "/path/to/terraform-provider-trunk" + } + direct {} +} +``` + +Then run `terraform plan` in any example directory — no `terraform init` needed with `dev_overrides`. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..7a575fd --- /dev/null +++ b/docs/index.md @@ -0,0 +1,20 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "trunk Provider" +description: |- + Interact with Trunk.io services. +--- + +# trunk Provider + +Interact with Trunk.io services. + + + + +## Schema + +### Optional + +- `api_key` (String, Sensitive) API key for Trunk.io. Can also be set via the TRUNK_API_KEY environment variable. +- `base_url` (String) Base URL for the Trunk API. Defaults to https://api.trunk.io/v1. Can also be set via the TRUNK_BASE_URL environment variable. diff --git a/docs/resources/merge_queue.md b/docs/resources/merge_queue.md new file mode 100644 index 0000000..61e7ee7 --- /dev/null +++ b/docs/resources/merge_queue.md @@ -0,0 +1,55 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "trunk_merge_queue Resource - terraform-provider-trunk" +subcategory: "" +description: |- + Manages a Trunk merge queue. +--- + +# trunk_merge_queue (Resource) + +Manages a Trunk merge queue. + + + + +## Schema + +### Required + +- `repo` (Attributes) Repository this queue is associated with. (see [below for nested schema](#nestedatt--repo)) +- `target_branch` (String) Target branch for the merge queue. + +### Optional + +- `batch` (Boolean) Enable batching. +- `batching_max_wait_time_minutes` (Number) Maximum minutes to wait for a batch to fill. +- `batching_min_size` (Number) Minimum number of PRs per batch. +- `bisection_concurrency` (Number) Number of concurrent tests during bisection. +- `can_optimistically_merge` (Boolean) Allow optimistic merge when a lower PR passes. +- `commands_enabled` (Boolean) Allow /trunk merge comments. +- `comments_enabled` (Boolean) Post GitHub comments on PRs. +- `concurrency` (Number) Number of concurrent test slots. +- `create_prs_for_testing_branches` (Boolean) Create PRs for testing branches. +- `direct_merge_mode` (String) Direct merge mode: "OFF" or "ALWAYS". +- `merge_method` (String) Merge method: "MERGE_COMMIT", "SQUASH", or "REBASE". +- `mode` (String) Queue mode: "single" or "parallel". +- `optimization_mode` (String) Optimization mode: "OFF" or "BISECTION_SKIP_REDUNDANT_TESTS". +- `pending_failure_depth` (Number) Number of PRs below a failure to wait for before eviction. +- `required_statuses` (List of String) Override required status checks. Set to null to revert to branch protection or trunk.yaml defaults; set to [] to explicitly require no statuses. +- `state` (String) Queue state: "RUNNING", "PAUSED", or "DRAINING". +- `status_check_enabled` (Boolean) Post GitHub status checks. +- `testing_timeout_minutes` (Number) Maximum minutes to wait for tests. + +### Read-Only + +- `id` (String) Unique identifier for this queue in the format {host}/{owner}/{name}/{target_branch}. + + +### Nested Schema for `repo` + +Required: + +- `host` (String) Repository host (e.g., "github.com"). +- `name` (String) Repository name. +- `owner` (String) Repository owner (organization or user). diff --git a/examples/.gitignore b/examples/.gitignore new file mode 100644 index 0000000..be9415a --- /dev/null +++ b/examples/.gitignore @@ -0,0 +1,3 @@ +terraform.tfstate* +.terraform/ +.terraform.lock.hcl diff --git a/examples/manual-test/main.tf b/examples/manual-test/main.tf new file mode 100644 index 0000000..c00a262 --- /dev/null +++ b/examples/manual-test/main.tf @@ -0,0 +1,22 @@ +terraform { + required_providers { + trunk = { + source = "registry.terraform.io/trunk-io/trunk" + } + } +} + +# api_key is read from TRUNK_API_KEY environment variable. +# trunk2 targets staging; base_url can also be set via TRUNK_BASE_URL. +provider "trunk" { + base_url = "https://api.trunk-staging.io/v1" +} + +resource "trunk_merge_queue" "trunk2" { + repo = { + host = "github.com" + owner = "trunk-io" + name = "trunk2" + } + target_branch = "main" +} diff --git a/examples/provider/main.tf b/examples/provider/main.tf new file mode 100644 index 0000000..46a0495 --- /dev/null +++ b/examples/provider/main.tf @@ -0,0 +1,11 @@ +terraform { + required_providers { + trunk = { + source = "registry.terraform.io/trunk-io/trunk" + } + } +} + +# Configure the Trunk provider. +# api_key can be set here or via the TRUNK_API_KEY environment variable. +provider "trunk" {} diff --git a/examples/resources/trunk_merge_queue/main.tf b/examples/resources/trunk_merge_queue/main.tf new file mode 100644 index 0000000..bbfbdbb --- /dev/null +++ b/examples/resources/trunk_merge_queue/main.tf @@ -0,0 +1,11 @@ +resource "trunk_merge_queue" "example" { + repo = { + host = "github.com" + owner = "my-org" + name = "my-repo" + } + target_branch = "main" + mode = "parallel" + concurrency = 3 + merge_method = "SQUASH" +} diff --git a/examples/staging/main.tf b/examples/staging/main.tf new file mode 100644 index 0000000..f6dbd67 --- /dev/null +++ b/examples/staging/main.tf @@ -0,0 +1,30 @@ +terraform { + required_providers { + trunk = { + source = "registry.terraform.io/trunk-io/trunk" + } + } + + backend "s3" { + bucket = "trunk-terraform-state-staging" + key = "merge-queues/terraform.tfstate" + region = "us-west-2" + dynamodb_table = "terraform-state-lock" + encrypt = true + } +} + +# api_key is read from TRUNK_API_KEY environment variable. +# base_url is read from TRUNK_BASE_URL environment variable. +provider "trunk" { + base_url = "https://api.trunk-staging.io/v1" +} + +resource "trunk_merge_queue" "trunk2" { + repo = { + host = "github.com" + owner = "trunk-io" + name = "trunk2" + } + target_branch = "main" +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..955b066 --- /dev/null +++ b/go.mod @@ -0,0 +1,34 @@ +module github.com/trunk-io/terraform-provider-trunk + +go 1.24.0 + +toolchain go1.25.8 + +require ( + github.com/hashicorp/terraform-plugin-framework v1.13.0 + github.com/hashicorp/terraform-plugin-go v0.25.0 // indirect + github.com/hashicorp/terraform-plugin-log v0.9.0 // indirect +) + +require ( + github.com/fatih/color v1.16.0 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/hashicorp/go-hclog v1.6.3 // indirect + github.com/hashicorp/go-plugin v1.6.2 // indirect + github.com/hashicorp/go-uuid v1.0.3 // indirect + github.com/hashicorp/terraform-registry-address v0.2.3 // indirect + github.com/hashicorp/terraform-svchost v0.1.1 // indirect + github.com/hashicorp/yamux v0.1.1 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/go-testing-interface v1.14.1 // indirect + github.com/oklog/run v1.0.0 // indirect + github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/grpc v1.79.3 // indirect + google.golang.org/protobuf v1.36.10 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9af33a6 --- /dev/null +++ b/go.sum @@ -0,0 +1,99 @@ +github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA= +github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/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/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/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +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/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/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-plugin v1.6.2 h1:zdGAEd0V1lCaU0u+MxWQhtSDQmahpkwOun8U8EiRVog= +github.com/hashicorp/go-plugin v1.6.2/go.mod h1:CkgLQ5CZqNmdL9U9JzM532t8ZiYQ35+pj3b1FD37R0Q= +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/terraform-plugin-framework v1.13.0 h1:8OTG4+oZUfKgnfTdPTJwZ532Bh2BobF4H+yBiYJ/scw= +github.com/hashicorp/terraform-plugin-framework v1.13.0/go.mod h1:j64rwMGpgM3NYXTKuxrCnyubQb/4VKldEKlcG8cvmjU= +github.com/hashicorp/terraform-plugin-go v0.25.0 h1:oi13cx7xXA6QciMcpcFi/rwA974rdTxjqEhXJjbAyks= +github.com/hashicorp/terraform-plugin-go v0.25.0/go.mod h1:+SYagMYadJP86Kvn+TGeV+ofr/R3g4/If0O5sO96MVw= +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.2.3 h1:2TAiKJ1A3MAkZlH1YI/aTVcLZRu7JseiXNRHbOAyoTI= +github.com/hashicorp/terraform-registry-address v0.2.3/go.mod h1:lFHA76T8jfQteVfT7caREqguFrW3c4MFSPhZB7HHgUM= +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.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE= +github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ= +github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c= +github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gavG9e0Q693nKo= +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.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +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.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= +github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= +github.com/oklog/run v1.0.0 h1:Ru7dDtJNOyC66gQ5dQmaCa0qIsAUFY3sFpK1Xk8igrw= +github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= +github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +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= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +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-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-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +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-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +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/internal/client/CLAUDE.md b/internal/client/CLAUDE.md new file mode 100644 index 0000000..64f09ce --- /dev/null +++ b/internal/client/CLAUDE.md @@ -0,0 +1,14 @@ +# internal/client + +Standalone HTTP client for the Trunk API. Intentionally free of Terraform types — this package can be imported and tested without any Terraform dependency. + +## Conventions + +- Keep this package free of `terraform-plugin-framework` imports +- All API methods take a typed request struct and return a typed response (or `*Queue`) plus `error` +- `APIError` is the error type for non-2xx responses; callers in `internal/provider/` should type-assert it to detect specific status codes (e.g. 404 for "queue not found, remove from state") +- Optional fields use pointer types (`*string`, `*int`, `*bool`) with `omitempty` so nil values are omitted from the JSON body + +## References + +- TRD: `docs/trd/terraform-provider-trunk.md` diff --git a/internal/client/client.go b/internal/client/client.go new file mode 100644 index 0000000..8f1a97b --- /dev/null +++ b/internal/client/client.go @@ -0,0 +1,72 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +const defaultBaseURL = "https://api.trunk.io/v1" + +// Client communicates with the Trunk API. +type Client struct { + baseURL string + apiKey string + httpClient *http.Client +} + +// NewClient creates a new Client. If baseURL is empty, it defaults to the production Trunk API. +func NewClient(apiKey, baseURL string) *Client { + if baseURL == "" { + baseURL = defaultBaseURL + } + return &Client{ + baseURL: strings.TrimRight(baseURL, "/"), + apiKey: apiKey, + httpClient: &http.Client{Timeout: 30 * time.Second}, + } +} + +// doRequest sends a POST request to the given endpoint, marshaling reqBody as JSON and +// unmarshaling the response into respBody. Returns an *APIError for non-2xx responses. +func (c *Client) doRequest(ctx context.Context, endpoint string, reqBody any, respBody any) error { + bodyBytes, err := json.Marshal(reqBody) + if err != nil { + return fmt.Errorf("marshaling request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/"+strings.TrimPrefix(endpoint, "/"), bytes.NewReader(bodyBytes)) + if err != nil { + return fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-api-token", c.apiKey) + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("executing request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + respBytes, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("reading response: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return &APIError{StatusCode: resp.StatusCode, Body: string(respBytes)} + } + + if respBody != nil { + if err := json.Unmarshal(respBytes, respBody); err != nil { + return fmt.Errorf("unmarshaling response: %w", err) + } + } + + return nil +} diff --git a/internal/client/client_test.go b/internal/client/client_test.go new file mode 100644 index 0000000..0ecce58 --- /dev/null +++ b/internal/client/client_test.go @@ -0,0 +1,127 @@ +package client + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func TestDoRequest_SetsAuthAndContentTypeHeaders(t *testing.T) { + var gotAPIToken, gotContentType, gotMethod string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAPIToken = r.Header.Get("x-api-token") + gotContentType = r.Header.Get("Content-Type") + gotMethod = r.Method + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("{}")) + })) + defer server.Close() + + c := NewClient("secret-key", server.URL) + err := c.doRequest(context.Background(), "test", struct{}{}, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotAPIToken != "secret-key" { + t.Errorf("x-api-token = %q, want %q", gotAPIToken, "secret-key") + } + if gotContentType != "application/json" { + t.Errorf("Content-Type = %q, want %q", gotContentType, "application/json") + } + if gotMethod != http.MethodPost { + t.Errorf("method = %q, want %q", gotMethod, http.MethodPost) + } +} + +func TestDoRequest_Returns4xxAsAPIError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) + })) + defer server.Close() + + c := NewClient("bad-key", server.URL) + err := c.doRequest(context.Background(), "test", struct{}{}, nil) + + apiErr, ok := err.(*APIError) + if !ok { + t.Fatalf("expected *APIError, got %T: %v", err, err) + } + if apiErr.StatusCode != http.StatusUnauthorized { + t.Errorf("StatusCode = %d, want %d", apiErr.StatusCode, http.StatusUnauthorized) + } + if apiErr.Body != `{"error":"unauthorized"}` { + t.Errorf("Body = %q, want %q", apiErr.Body, `{"error":"unauthorized"}`) + } +} + +func TestDoRequest_Returns5xxAsAPIError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("internal server error")) + })) + defer server.Close() + + c := NewClient("key", server.URL) + err := c.doRequest(context.Background(), "test", struct{}{}, nil) + + apiErr, ok := err.(*APIError) + if !ok { + t.Fatalf("expected *APIError, got %T: %v", err, err) + } + if apiErr.StatusCode != http.StatusInternalServerError { + t.Errorf("StatusCode = %d, want %d", apiErr.StatusCode, http.StatusInternalServerError) + } + if apiErr.Body != "internal server error" { + t.Errorf("Body = %q, want %q", apiErr.Body, "internal server error") + } +} + +func TestDoRequest_ReturnsErrorOnMalformedJSON(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("not-json")) + })) + defer server.Close() + + c := NewClient("key", server.URL) + var out struct{ Field string } + err := c.doRequest(context.Background(), "test", struct{}{}, &out) + if err == nil { + t.Fatal("expected error for malformed JSON response, got nil") + } +} + +func TestAPIError_Error(t *testing.T) { + err := &APIError{StatusCode: 404, Body: "not found"} + want := "API error 404: not found" + if err.Error() != want { + t.Errorf("Error() = %q, want %q", err.Error(), want) + } +} + +func TestNewClient_DefaultBaseURL(t *testing.T) { + c := NewClient("key", "") + if c.baseURL != defaultBaseURL { + t.Errorf("baseURL = %q, want %q", c.baseURL, defaultBaseURL) + } +} + +func TestNewClient_TrimsTrailingSlash(t *testing.T) { + c := NewClient("key", "https://example.com/v1/") + if c.baseURL != "https://example.com/v1" { + t.Errorf("baseURL = %q, want %q", c.baseURL, "https://example.com/v1") + } +} + +func TestDoRequest_ReturnsErrorOnNetworkFailure(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + server.Close() // close before the request so Do() returns a network error + + c := NewClient("key", server.URL) + err := c.doRequest(context.Background(), "test", struct{}{}, nil) + if err == nil { + t.Fatal("expected error on network failure, got nil") + } +} diff --git a/internal/client/merge_queue.go b/internal/client/merge_queue.go new file mode 100644 index 0000000..0957d3f --- /dev/null +++ b/internal/client/merge_queue.go @@ -0,0 +1,37 @@ +package client + +import "context" + +// CreateQueue creates a new merge queue. Per the API contract, only repo, targetBranch, +// mode, and concurrency are accepted; remaining configuration must be applied via UpdateQueue. +func (c *Client) CreateQueue(ctx context.Context, req CreateQueueRequest) (*Queue, error) { + var resp CreateQueueResponse + if err := c.doRequest(ctx, "createQueue", req, &resp); err != nil { + return nil, err + } + return &resp.Queue, nil +} + +// GetQueue retrieves the current state of a merge queue. +func (c *Client) GetQueue(ctx context.Context, req GetQueueRequest) (*Queue, error) { + var resp getQueueAPIResponse + if err := c.doRequest(ctx, "getQueue", req, &resp); err != nil { + return nil, err + } + return resp.toQueue(req.Repo, req.TargetBranch), nil +} + +// UpdateQueue updates configuration on an existing merge queue. Only non-nil pointer fields +// in the request are sent to the API, leaving unspecified fields unchanged. +func (c *Client) UpdateQueue(ctx context.Context, req UpdateQueueRequest) (*Queue, error) { + var resp UpdateQueueResponse + if err := c.doRequest(ctx, "updateQueue", req, &resp); err != nil { + return nil, err + } + return &resp.Queue, nil +} + +// DeleteQueue deletes a merge queue. +func (c *Client) DeleteQueue(ctx context.Context, req DeleteQueueRequest) error { + return c.doRequest(ctx, "deleteQueue", req, nil) +} diff --git a/internal/client/merge_queue_test.go b/internal/client/merge_queue_test.go new file mode 100644 index 0000000..94753c8 --- /dev/null +++ b/internal/client/merge_queue_test.go @@ -0,0 +1,333 @@ +package client + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +// testQueue returns a minimal Queue for use in handler responses. +func testQueue() Queue { + return Queue{ + Repo: Repo{Host: "github.com", Owner: "my-org", Name: "my-repo"}, + TargetBranch: "main", + Mode: "single", + Concurrency: 1, + State: "RUNNING", + } +} + +func TestCreateQueue(t *testing.T) { + var gotReq CreateQueueRequest + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil { + t.Errorf("decoding request body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(CreateQueueResponse{Queue: testQueue()}) + })) + defer server.Close() + + c := NewClient("key", server.URL) + req := CreateQueueRequest{ + Repo: Repo{Host: "github.com", Owner: "my-org", Name: "my-repo"}, + TargetBranch: "main", + Mode: "single", + Concurrency: 1, + } + queue, err := c.CreateQueue(context.Background(), req) + if err != nil { + t.Fatalf("CreateQueue error: %v", err) + } + if queue.TargetBranch != "main" { + t.Errorf("TargetBranch = %q, want %q", queue.TargetBranch, "main") + } + if queue.Mode != "single" { + t.Errorf("Mode = %q, want %q", queue.Mode, "single") + } + if gotReq.Repo.Host != "github.com" { + t.Errorf("request repo.host = %q, want %q", gotReq.Repo.Host, "github.com") + } + if gotReq.TargetBranch != "main" { + t.Errorf("request targetBranch = %q, want %q", gotReq.TargetBranch, "main") + } + if gotReq.Mode != "single" { + t.Errorf("request mode = %q, want %q", gotReq.Mode, "single") + } + if gotReq.Concurrency != 1 { + t.Errorf("request concurrency = %d, want 1", gotReq.Concurrency) + } +} + +func TestCreateQueue_ReturnsErrorOnAPIFailure(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("server error")) + })) + defer server.Close() + + c := NewClient("key", server.URL) + _, err := c.CreateQueue(context.Background(), CreateQueueRequest{ + Repo: Repo{Host: "github.com", Owner: "my-org", Name: "my-repo"}, + TargetBranch: "main", + }) + if _, ok := err.(*APIError); !ok { + t.Fatalf("expected *APIError, got %T: %v", err, err) + } +} + +func TestGetQueue(t *testing.T) { + var gotReq GetQueueRequest + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil { + t.Errorf("decoding request body: %v", err) + } + // Respond with the actual flat API format (no queue wrapper, uppercase mode, branch field). + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(getQueueAPIResponse{ + Branch: "main", + Mode: "SINGLE", + Concurrency: 1, + State: "RUNNING", + }) + })) + defer server.Close() + + c := NewClient("key", server.URL) + req := GetQueueRequest{ + Repo: Repo{Host: "github.com", Owner: "my-org", Name: "my-repo"}, + TargetBranch: "main", + } + queue, err := c.GetQueue(context.Background(), req) + if err != nil { + t.Fatalf("GetQueue error: %v", err) + } + if queue.State != "RUNNING" { + t.Errorf("State = %q, want %q", queue.State, "RUNNING") + } + // Mode should be normalized from API uppercase to schema lowercase. + if queue.Mode != "single" { + t.Errorf("Mode = %q, want %q", queue.Mode, "single") + } + // Identity fields should be populated from the request, not the response body. + if queue.TargetBranch != "main" { + t.Errorf("TargetBranch = %q, want %q", queue.TargetBranch, "main") + } + if queue.Repo.Owner != "my-org" { + t.Errorf("Repo.Owner = %q, want %q", queue.Repo.Owner, "my-org") + } + if gotReq.Repo.Owner != "my-org" { + t.Errorf("request repo.owner = %q, want %q", gotReq.Repo.Owner, "my-org") + } + if gotReq.TargetBranch != "main" { + t.Errorf("request targetBranch = %q, want %q", gotReq.TargetBranch, "main") + } +} + +func TestGetQueue_ReturnsErrorOnAPIFailure(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte("not found")) + })) + defer server.Close() + + c := NewClient("key", server.URL) + _, err := c.GetQueue(context.Background(), GetQueueRequest{ + Repo: Repo{Host: "github.com", Owner: "my-org", Name: "my-repo"}, + TargetBranch: "main", + }) + apiErr, ok := err.(*APIError) + if !ok { + t.Fatalf("expected *APIError, got %T: %v", err, err) + } + if apiErr.StatusCode != http.StatusNotFound { + t.Errorf("StatusCode = %d, want %d", apiErr.StatusCode, http.StatusNotFound) + } +} + +func TestUpdateQueue_OmitsNilFields(t *testing.T) { + var rawBody map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&rawBody); err != nil { + t.Errorf("decoding request body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(UpdateQueueResponse{Queue: testQueue()}) + })) + defer server.Close() + + c := NewClient("key", server.URL) + _, err := c.UpdateQueue(context.Background(), UpdateQueueRequest{ + Repo: Repo{Host: "github.com", Owner: "my-org", Name: "my-repo"}, + TargetBranch: "main", + // All optional fields left nil. + }) + if err != nil { + t.Fatalf("UpdateQueue error: %v", err) + } + for _, field := range []string{ + "mode", "concurrency", "state", "mergeMethod", "batch", + "deleteRequiredStatuses", "testingTimeoutMinutes", "requiredStatuses", + } { + if _, present := rawBody[field]; present { + t.Errorf("field %q should be absent when nil, but was present in request body", field) + } + } +} + +func TestUpdateQueue_IncludesNonNilFields(t *testing.T) { + var rawBody map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewDecoder(r.Body).Decode(&rawBody) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(UpdateQueueResponse{Queue: testQueue()}) + })) + defer server.Close() + + mode := "parallel" + concurrency := 3 + mergeMethod := "SQUASH" + batch := true + c := NewClient("key", server.URL) + _, err := c.UpdateQueue(context.Background(), UpdateQueueRequest{ + Repo: Repo{Host: "github.com", Owner: "my-org", Name: "my-repo"}, + TargetBranch: "main", + Mode: &mode, + Concurrency: &concurrency, + MergeMethod: &mergeMethod, + Batch: &batch, + }) + if err != nil { + t.Fatalf("UpdateQueue error: %v", err) + } + if rawBody["mode"] != "parallel" { + t.Errorf("mode = %v, want %q", rawBody["mode"], "parallel") + } + if rawBody["concurrency"] != float64(3) { + t.Errorf("concurrency = %v, want 3", rawBody["concurrency"]) + } + if rawBody["mergeMethod"] != "SQUASH" { + t.Errorf("mergeMethod = %v, want %q", rawBody["mergeMethod"], "SQUASH") + } + if rawBody["batch"] != true { + t.Errorf("batch = %v, want true", rawBody["batch"]) + } +} + +func TestUpdateQueue_DeleteRequiredStatuses(t *testing.T) { + var rawBody map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewDecoder(r.Body).Decode(&rawBody) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(UpdateQueueResponse{Queue: testQueue()}) + })) + defer server.Close() + + deleteStatuses := true + c := NewClient("key", server.URL) + _, err := c.UpdateQueue(context.Background(), UpdateQueueRequest{ + Repo: Repo{Host: "github.com", Owner: "my-org", Name: "my-repo"}, + TargetBranch: "main", + DeleteRequiredStatuses: &deleteStatuses, + }) + if err != nil { + t.Fatalf("UpdateQueue error: %v", err) + } + if rawBody["deleteRequiredStatuses"] != true { + t.Errorf("deleteRequiredStatuses = %v, want true", rawBody["deleteRequiredStatuses"]) + } +} + +func TestUpdateQueue_SendsEmptyRequiredStatuses(t *testing.T) { + var rawBody map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewDecoder(r.Body).Decode(&rawBody) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(UpdateQueueResponse{Queue: testQueue()}) + })) + defer server.Close() + + empty := []string{} + c := NewClient("key", server.URL) + _, err := c.UpdateQueue(context.Background(), UpdateQueueRequest{ + Repo: Repo{Host: "github.com", Owner: "my-org", Name: "my-repo"}, + TargetBranch: "main", + RequiredStatuses: &empty, + }) + if err != nil { + t.Fatalf("UpdateQueue error: %v", err) + } + val, present := rawBody["requiredStatuses"] + if !present { + t.Fatal("requiredStatuses should be present in request body when set to empty slice") + } + statuses, ok := val.([]any) + if !ok || len(statuses) != 0 { + t.Errorf("requiredStatuses = %v, want []", val) + } +} + +func TestUpdateQueue_ReturnsErrorOnAPIFailure(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte("bad request")) + })) + defer server.Close() + + c := NewClient("key", server.URL) + _, err := c.UpdateQueue(context.Background(), UpdateQueueRequest{ + Repo: Repo{Host: "github.com", Owner: "my-org", Name: "my-repo"}, + TargetBranch: "main", + }) + if _, ok := err.(*APIError); !ok { + t.Fatalf("expected *APIError, got %T: %v", err, err) + } +} + +func TestDeleteQueue(t *testing.T) { + var gotReq DeleteQueueRequest + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewDecoder(r.Body).Decode(&gotReq) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("{}")) + })) + defer server.Close() + + c := NewClient("key", server.URL) + err := c.DeleteQueue(context.Background(), DeleteQueueRequest{ + Repo: Repo{Host: "github.com", Owner: "my-org", Name: "my-repo"}, + TargetBranch: "main", + }) + if err != nil { + t.Fatalf("DeleteQueue error: %v", err) + } + if gotReq.Repo.Name != "my-repo" { + t.Errorf("request repo.name = %q, want %q", gotReq.Repo.Name, "my-repo") + } + if gotReq.TargetBranch != "main" { + t.Errorf("request targetBranch = %q, want %q", gotReq.TargetBranch, "main") + } +} + +func TestDeleteQueue_ReturnsAPIErrorOnFailure(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte("queue not found")) + })) + defer server.Close() + + c := NewClient("key", server.URL) + err := c.DeleteQueue(context.Background(), DeleteQueueRequest{ + Repo: Repo{Host: "github.com", Owner: "my-org", Name: "my-repo"}, + TargetBranch: "main", + }) + apiErr, ok := err.(*APIError) + if !ok { + t.Fatalf("expected *APIError, got %T: %v", err, err) + } + if apiErr.StatusCode != http.StatusNotFound { + t.Errorf("StatusCode = %d, want %d", apiErr.StatusCode, http.StatusNotFound) + } +} diff --git a/internal/client/types.go b/internal/client/types.go new file mode 100644 index 0000000..131d705 --- /dev/null +++ b/internal/client/types.go @@ -0,0 +1,189 @@ +package client + +import "fmt" + +// Repo identifies a repository in the Trunk API. +type Repo struct { + Host string `json:"host"` + Owner string `json:"owner"` + Name string `json:"name"` +} + +// Queue represents the full state of a Trunk merge queue as returned by the API. +type Queue struct { + Repo Repo `json:"repo"` + TargetBranch string `json:"targetBranch"` + Mode string `json:"mode"` + Concurrency int `json:"concurrency"` + State string `json:"state"` + + // Optional configuration fields. + TestingTimeoutMinutes *int `json:"testingTimeoutMinutes,omitempty"` + PendingFailureDepth *int `json:"pendingFailureDepth,omitempty"` + CanOptimisticallyMerge *bool `json:"canOptimisticallyMerge,omitempty"` + Batch *bool `json:"batch,omitempty"` + BatchingMaxWaitTimeMinutes *int `json:"batchingMaxWaitTimeMinutes,omitempty"` + BatchingMinSize *int `json:"batchingMinSize,omitempty"` + MergeMethod *string `json:"mergeMethod,omitempty"` + CommentsEnabled *bool `json:"commentsEnabled,omitempty"` + CommandsEnabled *bool `json:"commandsEnabled,omitempty"` + CreatePrsForTestingBranches *bool `json:"createPrsForTestingBranches,omitempty"` + StatusCheckEnabled *bool `json:"statusCheckEnabled,omitempty"` + DirectMergeMode *string `json:"directMergeMode,omitempty"` + OptimizationMode *string `json:"optimizationMode,omitempty"` + BisectionConcurrency *int `json:"bisectionConcurrency,omitempty"` + RequiredStatuses []string `json:"requiredStatuses,omitempty"` +} + +// APIError represents a non-2xx response from the Trunk API. +type APIError struct { + StatusCode int + Body string +} + +func (e *APIError) Error() string { + return fmt.Sprintf("API error %d: %s", e.StatusCode, e.Body) +} + +// CreateQueueRequest contains the fields accepted by the createQueue endpoint. +// Per the API contract, only identity fields, mode, and concurrency are accepted at +// create time; all other configuration must be applied via UpdateQueue. +// +// Mode and Concurrency use value types (not pointers) because: +// - The Create method in the Terraform resource always supplies explicit values for both fields. +// - The API treats omitted fields as defaults (mode="single", concurrency=1), so dropping a +// zero value via omitempty is safe — a zero concurrency is invalid for a queue anyway. +// +// This is intentionally different from UpdateQueueRequest, where pointer fields are required +// to distinguish "leave unchanged" (nil) from "set to default" (non-nil zero). +type CreateQueueRequest struct { + Repo Repo `json:"repo"` + TargetBranch string `json:"targetBranch"` + Mode string `json:"mode,omitempty"` + Concurrency int `json:"concurrency,omitempty"` +} + +// CreateQueueResponse is returned by the createQueue endpoint. +type CreateQueueResponse struct { + Queue Queue `json:"queue"` +} + +// GetQueueRequest identifies the queue to retrieve. +type GetQueueRequest struct { + Repo Repo `json:"repo"` + TargetBranch string `json:"targetBranch"` +} + +// getQueueAPIResponse matches the flat JSON structure returned by the getQueue endpoint. +// Several field names differ from the internal Queue type: +// - branch → TargetBranch +// - testingTimeoutMins → TestingTimeoutMinutes +// - isBatching → Batch +// - batchingMaxWaitTimeMins → BatchingMaxWaitTimeMinutes +// - mode is uppercase ("SINGLE"/"PARALLEL") and is normalized to lowercase by toQueue +type getQueueAPIResponse struct { + Branch string `json:"branch"` + Concurrency int `json:"concurrency"` + Mode string `json:"mode"` + State string `json:"state"` + + TestingTimeoutMins *int `json:"testingTimeoutMins,omitempty"` + PendingFailureDepth *int `json:"pendingFailureDepth,omitempty"` + CanOptimisticallyMerge *bool `json:"canOptimisticallyMerge,omitempty"` + IsBatching *bool `json:"isBatching,omitempty"` + BatchingMaxWaitTimeMins *int `json:"batchingMaxWaitTimeMins,omitempty"` + BatchingMinSize *int `json:"batchingMinSize,omitempty"` + MergeMethod *string `json:"mergeMethod,omitempty"` + CommentsEnabled *bool `json:"commentsEnabled,omitempty"` + CommandsEnabled *bool `json:"commandsEnabled,omitempty"` + CreatePrsForTestingBranches *bool `json:"createPrsForTestingBranches,omitempty"` + StatusCheckEnabled *bool `json:"statusCheckEnabled,omitempty"` + DirectMergeMode *string `json:"directMergeMode,omitempty"` + OptimizationMode *string `json:"optimizationMode,omitempty"` + BisectionConcurrency *int `json:"bisectionConcurrency,omitempty"` + RequiredStatuses []string `json:"requiredStatuses,omitempty"` +} + +// toQueue maps the flat API response to the internal Queue type, normalizing field +// names and mode casing. +func (r *getQueueAPIResponse) toQueue(repo Repo, targetBranch string) *Queue { + q := &Queue{ + Repo: repo, + TargetBranch: targetBranch, + Concurrency: r.Concurrency, + State: r.State, + + PendingFailureDepth: r.PendingFailureDepth, + CanOptimisticallyMerge: r.CanOptimisticallyMerge, + BatchingMinSize: r.BatchingMinSize, + MergeMethod: r.MergeMethod, + CommentsEnabled: r.CommentsEnabled, + CommandsEnabled: r.CommandsEnabled, + CreatePrsForTestingBranches: r.CreatePrsForTestingBranches, + StatusCheckEnabled: r.StatusCheckEnabled, + DirectMergeMode: r.DirectMergeMode, + OptimizationMode: r.OptimizationMode, + BisectionConcurrency: r.BisectionConcurrency, + RequiredStatuses: r.RequiredStatuses, + + // Renamed fields. + TestingTimeoutMinutes: r.TestingTimeoutMins, + Batch: r.IsBatching, + BatchingMaxWaitTimeMinutes: r.BatchingMaxWaitTimeMins, + } + // The API returns uppercase mode values; normalize to lowercase for the schema. + switch r.Mode { + case "SINGLE": + q.Mode = "single" + case "PARALLEL": + q.Mode = "parallel" + default: + q.Mode = r.Mode + } + return q +} + +// UpdateQueueRequest contains all fields that can be changed on an existing queue. +// Nil pointer fields are omitted from the JSON body, leaving them unchanged on the API side. +type UpdateQueueRequest struct { + Repo Repo `json:"repo"` + TargetBranch string `json:"targetBranch"` + + Mode *string `json:"mode,omitempty"` + Concurrency *int `json:"concurrency,omitempty"` + State *string `json:"state,omitempty"` + + TestingTimeoutMinutes *int `json:"testingTimeoutMinutes,omitempty"` + PendingFailureDepth *int `json:"pendingFailureDepth,omitempty"` + CanOptimisticallyMerge *bool `json:"canOptimisticallyMerge,omitempty"` + Batch *bool `json:"batch,omitempty"` + BatchingMaxWaitTimeMinutes *int `json:"batchingMaxWaitTimeMinutes,omitempty"` + BatchingMinSize *int `json:"batchingMinSize,omitempty"` + MergeMethod *string `json:"mergeMethod,omitempty"` + CommentsEnabled *bool `json:"commentsEnabled,omitempty"` + CommandsEnabled *bool `json:"commandsEnabled,omitempty"` + CreatePrsForTestingBranches *bool `json:"createPrsForTestingBranches,omitempty"` + StatusCheckEnabled *bool `json:"statusCheckEnabled,omitempty"` + DirectMergeMode *string `json:"directMergeMode,omitempty"` + OptimizationMode *string `json:"optimizationMode,omitempty"` + BisectionConcurrency *int `json:"bisectionConcurrency,omitempty"` + RequiredStatuses *[]string `json:"requiredStatuses,omitempty"` + + // DeleteRequiredStatuses reverts required statuses to branch protection / trunk.yaml defaults + // when set to true. + DeleteRequiredStatuses *bool `json:"deleteRequiredStatuses,omitempty"` +} + +// UpdateQueueResponse is returned by the updateQueue endpoint. +type UpdateQueueResponse struct { + Queue Queue `json:"queue"` +} + +// DeleteQueueRequest identifies the queue to delete. +type DeleteQueueRequest struct { + Repo Repo `json:"repo"` + TargetBranch string `json:"targetBranch"` +} + +// DeleteQueueResponse is the empty response from the deleteQueue endpoint. +type DeleteQueueResponse struct{} diff --git a/internal/provider/CLAUDE.md b/internal/provider/CLAUDE.md new file mode 100644 index 0000000..67382c7 --- /dev/null +++ b/internal/provider/CLAUDE.md @@ -0,0 +1,21 @@ +# internal/provider + +Terraform provider and resource implementations. Bridges the `internal/client` package to the Terraform Plugin Framework. + +## Files + +- `provider.go` — Provider definition, auth schema, `Configure` (creates `*client.Client` from `api_key`/`TRUNK_API_KEY`), and resource registration +- `merge_queue_resource.go` — `trunk_merge_queue` resource: schema, CRUD operations, and `ImportState` +- `merge_queue_resource_model.go` — Terraform state model (`mergeQueueResourceModel`) and conversion helpers between Terraform types and `internal/client` request/response structs + +## Conventions + +- Keep all Terraform Plugin Framework imports here; `internal/client` must remain free of them +- `mergeQueueResourceModel.fromQueue` populates state from the API; `toCreateRequest`/`toUpdateRequest` build API request structs +- Computed + Optional fields (`mode`, `concurrency`, `state`, `direct_merge_mode`, `optimization_mode`) have API defaults the server fills in +- `null` `required_statuses` sends `deleteRequiredStatuses: true` to revert to branch protection / trunk.yaml defaults +- Import ID format: `{host}/{owner}/{name}/{target_branch}` (uses `strings.SplitN(..., 4)` to support branch names with slashes) + +## References + +- TRD: `docs/trd/terraform-provider-trunk.md` diff --git a/internal/provider/merge_queue_resource.go b/internal/provider/merge_queue_resource.go new file mode 100644 index 0000000..634d1d9 --- /dev/null +++ b/internal/provider/merge_queue_resource.go @@ -0,0 +1,366 @@ +package provider + +import ( + "context" + "errors" + "fmt" + "strings" + + "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/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/trunk-io/terraform-provider-trunk/internal/client" +) + +// Compile-time interface assertions. +var ( + _ resource.Resource = &mergeQueueResource{} + _ resource.ResourceWithConfigure = &mergeQueueResource{} + _ resource.ResourceWithImportState = &mergeQueueResource{} +) + +type mergeQueueResource struct { + client *client.Client +} + +// NewMergeQueueResource returns a new mergeQueueResource. +func NewMergeQueueResource() resource.Resource { + return &mergeQueueResource{} +} + +func (r *mergeQueueResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_merge_queue" +} + +func (r *mergeQueueResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Manages a Trunk merge queue.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "Unique identifier for this queue in the format {host}/{owner}/{name}/{target_branch}.", + Computed: true, + }, + "repo": schema.SingleNestedAttribute{ + Description: "Repository this queue is associated with.", + Required: true, + Attributes: map[string]schema.Attribute{ + "host": schema.StringAttribute{ + Description: "Repository host (e.g., \"github.com\").", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "owner": schema.StringAttribute{ + Description: "Repository owner (organization or user).", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "name": schema.StringAttribute{ + Description: "Repository name.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + }, + }, + "target_branch": schema.StringAttribute{ + Description: "Target branch for the merge queue.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "mode": schema.StringAttribute{ + Description: "Queue mode: \"single\" or \"parallel\".", + Optional: true, + Computed: true, + }, + "concurrency": schema.Int64Attribute{ + Description: "Number of concurrent test slots.", + Optional: true, + Computed: true, + }, + "state": schema.StringAttribute{ + Description: "Queue state: \"RUNNING\", \"PAUSED\", or \"DRAINING\".", + Optional: true, + Computed: true, + }, + "testing_timeout_minutes": schema.Int64Attribute{ + Description: "Maximum minutes to wait for tests.", + Optional: true, + Computed: true, + }, + "pending_failure_depth": schema.Int64Attribute{ + Description: "Number of PRs below a failure to wait for before eviction.", + Optional: true, + Computed: true, + }, + "can_optimistically_merge": schema.BoolAttribute{ + Description: "Allow optimistic merge when a lower PR passes.", + Optional: true, + Computed: true, + }, + "batch": schema.BoolAttribute{ + Description: "Enable batching.", + Optional: true, + Computed: true, + }, + "batching_max_wait_time_minutes": schema.Int64Attribute{ + Description: "Maximum minutes to wait for a batch to fill.", + Optional: true, + Computed: true, + }, + "batching_min_size": schema.Int64Attribute{ + Description: "Minimum number of PRs per batch.", + Optional: true, + Computed: true, + }, + "merge_method": schema.StringAttribute{ + Description: "Merge method: \"MERGE_COMMIT\", \"SQUASH\", or \"REBASE\".", + Optional: true, + Computed: true, + }, + "comments_enabled": schema.BoolAttribute{ + Description: "Post GitHub comments on PRs.", + Optional: true, + Computed: true, + }, + "commands_enabled": schema.BoolAttribute{ + Description: "Allow /trunk merge comments.", + Optional: true, + Computed: true, + }, + "create_prs_for_testing_branches": schema.BoolAttribute{ + Description: "Create PRs for testing branches.", + Optional: true, + Computed: true, + }, + "status_check_enabled": schema.BoolAttribute{ + Description: "Post GitHub status checks.", + Optional: true, + Computed: true, + }, + "direct_merge_mode": schema.StringAttribute{ + Description: "Direct merge mode: \"OFF\" or \"ALWAYS\".", + Optional: true, + Computed: true, + }, + "optimization_mode": schema.StringAttribute{ + Description: "Optimization mode: \"OFF\" or \"BISECTION_SKIP_REDUNDANT_TESTS\".", + Optional: true, + Computed: true, + }, + "bisection_concurrency": schema.Int64Attribute{ + Description: "Number of concurrent tests during bisection.", + Optional: true, + Computed: true, + }, + "required_statuses": schema.ListAttribute{ + Description: "Override required status checks. Set to null to revert to branch protection or trunk.yaml defaults; set to [] to explicitly require no statuses.", + Optional: true, + Computed: true, + ElementType: types.StringType, + }, + }, + } +} + +func (r *mergeQueueResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + c, ok := req.ProviderData.(*client.Client) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *client.Client, got: %T. Report this issue to the provider developers.", req.ProviderData), + ) + return + } + r.client = c +} + +func (r *mergeQueueResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var model mergeQueueResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &model)...) + if resp.Diagnostics.HasError() { + return + } + + // Step 1: create the queue with identity fields, mode, and concurrency. + _, err := r.client.CreateQueue(ctx, model.toCreateRequest()) + if err != nil { + var apiErr *client.APIError + if errors.As(err, &apiErr) && apiErr.StatusCode == 409 { + resp.Diagnostics.AddError( + "Merge queue already exists", + fmt.Sprintf( + "A merge queue for %s/%s/%s already exists on branch %q. "+ + "Import it into Terraform state with:\n\n"+ + " terraform import trunk_merge_queue. %q", + model.Repo.Host.ValueString(), + model.Repo.Owner.ValueString(), + model.Repo.Name.ValueString(), + model.TargetBranch.ValueString(), + model.Repo.Host.ValueString()+"/"+ + model.Repo.Owner.ValueString()+"/"+ + model.Repo.Name.ValueString()+"/"+ + model.TargetBranch.ValueString(), + ), + ) + return + } + resp.Diagnostics.AddError("Error creating merge queue", err.Error()) + return + } + + // Step 2: apply all remaining optional attributes. If this fails, the queue exists but + // is partially configured. Set partial state so a subsequent apply triggers Update. + _, err = r.client.UpdateQueue(ctx, model.toUpdateRequest()) + if err != nil { + resp.Diagnostics.Append(resp.State.Set(ctx, &model)...) + resp.Diagnostics.AddError("Error configuring merge queue after creation", err.Error()) + return + } + + // Step 3: read back the full authoritative state from the API. + queue, err := r.client.GetQueue(ctx, client.GetQueueRequest{ + Repo: client.Repo{Host: model.Repo.Host.ValueString(), Owner: model.Repo.Owner.ValueString(), Name: model.Repo.Name.ValueString()}, + TargetBranch: model.TargetBranch.ValueString(), + }) + if err != nil { + resp.Diagnostics.AddError("Error reading merge queue after creation", err.Error()) + return + } + + model.fromQueue(queue) + model.setID() + resp.Diagnostics.Append(resp.State.Set(ctx, &model)...) +} + +func (r *mergeQueueResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var model mergeQueueResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &model)...) + if resp.Diagnostics.HasError() { + return + } + + queue, err := r.client.GetQueue(ctx, client.GetQueueRequest{ + Repo: client.Repo{Host: model.Repo.Host.ValueString(), Owner: model.Repo.Owner.ValueString(), Name: model.Repo.Name.ValueString()}, + TargetBranch: model.TargetBranch.ValueString(), + }) + if err != nil { + var apiErr *client.APIError + if errors.As(err, &apiErr) && apiErr.StatusCode == 404 { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("Error reading merge queue", err.Error()) + return + } + + model.fromQueue(queue) + model.setID() + resp.Diagnostics.Append(resp.State.Set(ctx, &model)...) +} + +func (r *mergeQueueResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var model mergeQueueResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &model)...) + if resp.Diagnostics.HasError() { + return + } + + _, err := r.client.UpdateQueue(ctx, model.toUpdateRequest()) + if err != nil { + resp.Diagnostics.AddError("Error updating merge queue", err.Error()) + return + } + + queue, err := r.client.GetQueue(ctx, client.GetQueueRequest{ + Repo: client.Repo{Host: model.Repo.Host.ValueString(), Owner: model.Repo.Owner.ValueString(), Name: model.Repo.Name.ValueString()}, + TargetBranch: model.TargetBranch.ValueString(), + }) + if err != nil { + resp.Diagnostics.AddError("Error reading merge queue after update", err.Error()) + return + } + + model.fromQueue(queue) + model.setID() + resp.Diagnostics.Append(resp.State.Set(ctx, &model)...) +} + +func (r *mergeQueueResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var model mergeQueueResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &model)...) + if resp.Diagnostics.HasError() { + return + } + + err := r.client.DeleteQueue(ctx, client.DeleteQueueRequest{ + Repo: client.Repo{Host: model.Repo.Host.ValueString(), Owner: model.Repo.Owner.ValueString(), Name: model.Repo.Name.ValueString()}, + TargetBranch: model.TargetBranch.ValueString(), + }) + if err != nil { + var apiErr *client.APIError + if errors.As(err, &apiErr) && apiErr.StatusCode == 400 { + resp.Diagnostics.AddError( + "Cannot delete merge queue: queue is not empty", + "The merge queue still has PRs in it. Set state = \"DRAINING\" and wait for the "+ + "queue to empty before running terraform destroy again.", + ) + return + } + resp.Diagnostics.AddError("Error deleting merge queue", err.Error()) + } +} + +func (r *mergeQueueResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + // Format: {host}/{owner}/{name}/{target_branch} + // SplitN with n=4 handles branch names that contain slashes (e.g., "feature/foo"). + parts := strings.SplitN(req.ID, "/", 4) + if len(parts) != 4 || parts[0] == "" || parts[1] == "" || parts[2] == "" || parts[3] == "" { + resp.Diagnostics.AddError( + "Invalid Import ID", + fmt.Sprintf("Expected format {host}/{owner}/{name}/{target_branch}, got: %q", req.ID), + ) + return + } + + model := mergeQueueResourceModel{ + ID: types.StringValue(parts[0] + "/" + parts[1] + "/" + parts[2] + "/" + parts[3]), + Repo: repoModel{ + Host: types.StringValue(parts[0]), + Owner: types.StringValue(parts[1]), + Name: types.StringValue(parts[2]), + }, + TargetBranch: types.StringValue(parts[3]), + Mode: types.StringNull(), + Concurrency: types.Int64Null(), + State: types.StringNull(), + TestingTimeoutMinutes: types.Int64Null(), + PendingFailureDepth: types.Int64Null(), + CanOptimisticallyMerge: types.BoolNull(), + Batch: types.BoolNull(), + BatchingMaxWaitTimeMinutes: types.Int64Null(), + BatchingMinSize: types.Int64Null(), + MergeMethod: types.StringNull(), + CommentsEnabled: types.BoolNull(), + CommandsEnabled: types.BoolNull(), + CreatePrsForTestingBranches: types.BoolNull(), + StatusCheckEnabled: types.BoolNull(), + DirectMergeMode: types.StringNull(), + OptimizationMode: types.StringNull(), + BisectionConcurrency: types.Int64Null(), + RequiredStatuses: types.ListNull(types.StringType), + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &model)...) +} diff --git a/internal/provider/merge_queue_resource_model.go b/internal/provider/merge_queue_resource_model.go new file mode 100644 index 0000000..dc52d01 --- /dev/null +++ b/internal/provider/merge_queue_resource_model.go @@ -0,0 +1,256 @@ +package provider + +import ( + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/trunk-io/terraform-provider-trunk/internal/client" +) + +// repoModel is the Terraform schema model for the repo nested attribute. +type repoModel struct { + Host types.String `tfsdk:"host"` + Owner types.String `tfsdk:"owner"` + Name types.String `tfsdk:"name"` +} + +// mergeQueueResourceModel is the Terraform schema model for the trunk_merge_queue resource. +type mergeQueueResourceModel struct { + ID types.String `tfsdk:"id"` + Repo repoModel `tfsdk:"repo"` + TargetBranch types.String `tfsdk:"target_branch"` + + // Fields with API defaults (Computed + Optional in schema). + Mode types.String `tfsdk:"mode"` + Concurrency types.Int64 `tfsdk:"concurrency"` + State types.String `tfsdk:"state"` + + // Optional fields without API defaults. + TestingTimeoutMinutes types.Int64 `tfsdk:"testing_timeout_minutes"` + PendingFailureDepth types.Int64 `tfsdk:"pending_failure_depth"` + CanOptimisticallyMerge types.Bool `tfsdk:"can_optimistically_merge"` + Batch types.Bool `tfsdk:"batch"` + BatchingMaxWaitTimeMinutes types.Int64 `tfsdk:"batching_max_wait_time_minutes"` + BatchingMinSize types.Int64 `tfsdk:"batching_min_size"` + MergeMethod types.String `tfsdk:"merge_method"` + CommentsEnabled types.Bool `tfsdk:"comments_enabled"` + CommandsEnabled types.Bool `tfsdk:"commands_enabled"` + CreatePrsForTestingBranches types.Bool `tfsdk:"create_prs_for_testing_branches"` + StatusCheckEnabled types.Bool `tfsdk:"status_check_enabled"` + DirectMergeMode types.String `tfsdk:"direct_merge_mode"` + OptimizationMode types.String `tfsdk:"optimization_mode"` + BisectionConcurrency types.Int64 `tfsdk:"bisection_concurrency"` + RequiredStatuses types.List `tfsdk:"required_statuses"` +} + +// toCreateRequest builds a CreateQueueRequest from the model's identity and mode/concurrency fields. +func (m *mergeQueueResourceModel) toCreateRequest() client.CreateQueueRequest { + return client.CreateQueueRequest{ + Repo: client.Repo{ + Host: m.Repo.Host.ValueString(), + Owner: m.Repo.Owner.ValueString(), + Name: m.Repo.Name.ValueString(), + }, + TargetBranch: m.TargetBranch.ValueString(), + Mode: m.Mode.ValueString(), + Concurrency: int(m.Concurrency.ValueInt64()), + } +} + +// toUpdateRequest builds an UpdateQueueRequest from all non-identity fields in the model. +// Null/unknown Terraform values produce nil pointer fields, leaving them unchanged on the API side. +// A null required_statuses field sends deleteRequiredStatuses=true to revert to defaults. +func (m *mergeQueueResourceModel) toUpdateRequest() client.UpdateQueueRequest { + req := client.UpdateQueueRequest{ + Repo: client.Repo{ + Host: m.Repo.Host.ValueString(), + Owner: m.Repo.Owner.ValueString(), + Name: m.Repo.Name.ValueString(), + }, + TargetBranch: m.TargetBranch.ValueString(), + } + + if !m.Mode.IsNull() && !m.Mode.IsUnknown() { + v := m.Mode.ValueString() + req.Mode = &v + } + if !m.Concurrency.IsNull() && !m.Concurrency.IsUnknown() { + v := int(m.Concurrency.ValueInt64()) + req.Concurrency = &v + } + if !m.State.IsNull() && !m.State.IsUnknown() { + v := m.State.ValueString() + req.State = &v + } + if !m.TestingTimeoutMinutes.IsNull() && !m.TestingTimeoutMinutes.IsUnknown() { + v := int(m.TestingTimeoutMinutes.ValueInt64()) + req.TestingTimeoutMinutes = &v + } + if !m.PendingFailureDepth.IsNull() && !m.PendingFailureDepth.IsUnknown() { + v := int(m.PendingFailureDepth.ValueInt64()) + req.PendingFailureDepth = &v + } + if !m.CanOptimisticallyMerge.IsNull() && !m.CanOptimisticallyMerge.IsUnknown() { + v := m.CanOptimisticallyMerge.ValueBool() + req.CanOptimisticallyMerge = &v + } + if !m.Batch.IsNull() && !m.Batch.IsUnknown() { + v := m.Batch.ValueBool() + req.Batch = &v + } + if !m.BatchingMaxWaitTimeMinutes.IsNull() && !m.BatchingMaxWaitTimeMinutes.IsUnknown() { + v := int(m.BatchingMaxWaitTimeMinutes.ValueInt64()) + req.BatchingMaxWaitTimeMinutes = &v + } + if !m.BatchingMinSize.IsNull() && !m.BatchingMinSize.IsUnknown() { + v := int(m.BatchingMinSize.ValueInt64()) + req.BatchingMinSize = &v + } + if !m.MergeMethod.IsNull() && !m.MergeMethod.IsUnknown() { + v := m.MergeMethod.ValueString() + req.MergeMethod = &v + } + if !m.CommentsEnabled.IsNull() && !m.CommentsEnabled.IsUnknown() { + v := m.CommentsEnabled.ValueBool() + req.CommentsEnabled = &v + } + if !m.CommandsEnabled.IsNull() && !m.CommandsEnabled.IsUnknown() { + v := m.CommandsEnabled.ValueBool() + req.CommandsEnabled = &v + } + if !m.CreatePrsForTestingBranches.IsNull() && !m.CreatePrsForTestingBranches.IsUnknown() { + v := m.CreatePrsForTestingBranches.ValueBool() + req.CreatePrsForTestingBranches = &v + } + if !m.StatusCheckEnabled.IsNull() && !m.StatusCheckEnabled.IsUnknown() { + v := m.StatusCheckEnabled.ValueBool() + req.StatusCheckEnabled = &v + } + if !m.DirectMergeMode.IsNull() && !m.DirectMergeMode.IsUnknown() { + v := m.DirectMergeMode.ValueString() + req.DirectMergeMode = &v + } + if !m.OptimizationMode.IsNull() && !m.OptimizationMode.IsUnknown() { + v := m.OptimizationMode.ValueString() + req.OptimizationMode = &v + } + if !m.BisectionConcurrency.IsNull() && !m.BisectionConcurrency.IsUnknown() { + v := int(m.BisectionConcurrency.ValueInt64()) + req.BisectionConcurrency = &v + } + + // required_statuses: null means revert to branch protection / trunk.yaml defaults. + if m.RequiredStatuses.IsNull() || m.RequiredStatuses.IsUnknown() { + t := true + req.DeleteRequiredStatuses = &t + } else { + statuses := make([]string, 0, len(m.RequiredStatuses.Elements())) + for _, elem := range m.RequiredStatuses.Elements() { + statuses = append(statuses, elem.(types.String).ValueString()) + } + req.RequiredStatuses = &statuses + } + + return req +} + +// setID computes and sets the id attribute from the model's identity fields. +// Must be called after identity fields (Repo, TargetBranch) are already set. +func (m *mergeQueueResourceModel) setID() { + m.ID = types.StringValue( + m.Repo.Host.ValueString() + "/" + + m.Repo.Owner.ValueString() + "/" + + m.Repo.Name.ValueString() + "/" + + m.TargetBranch.ValueString(), + ) +} + +// fromQueue populates the model from the API queue response. +// Identity fields (ID, Repo, TargetBranch) are intentionally not set here because the API does +// not return them in getQueue responses. Callers must set ID from the model's own Repo/TargetBranch +// values after calling fromQueue. +func (m *mergeQueueResourceModel) fromQueue(q *client.Queue) { + m.Mode = types.StringValue(q.Mode) + m.Concurrency = types.Int64Value(int64(q.Concurrency)) + m.State = types.StringValue(q.State) + + if q.TestingTimeoutMinutes != nil { + m.TestingTimeoutMinutes = types.Int64Value(int64(*q.TestingTimeoutMinutes)) + } else { + m.TestingTimeoutMinutes = types.Int64Null() + } + if q.PendingFailureDepth != nil { + m.PendingFailureDepth = types.Int64Value(int64(*q.PendingFailureDepth)) + } else { + m.PendingFailureDepth = types.Int64Null() + } + if q.CanOptimisticallyMerge != nil { + m.CanOptimisticallyMerge = types.BoolValue(*q.CanOptimisticallyMerge) + } else { + m.CanOptimisticallyMerge = types.BoolNull() + } + if q.Batch != nil { + m.Batch = types.BoolValue(*q.Batch) + } else { + m.Batch = types.BoolNull() + } + if q.BatchingMaxWaitTimeMinutes != nil { + m.BatchingMaxWaitTimeMinutes = types.Int64Value(int64(*q.BatchingMaxWaitTimeMinutes)) + } else { + m.BatchingMaxWaitTimeMinutes = types.Int64Null() + } + if q.BatchingMinSize != nil { + m.BatchingMinSize = types.Int64Value(int64(*q.BatchingMinSize)) + } else { + m.BatchingMinSize = types.Int64Null() + } + if q.MergeMethod != nil { + m.MergeMethod = types.StringValue(*q.MergeMethod) + } else { + m.MergeMethod = types.StringNull() + } + if q.CommentsEnabled != nil { + m.CommentsEnabled = types.BoolValue(*q.CommentsEnabled) + } else { + m.CommentsEnabled = types.BoolNull() + } + if q.CommandsEnabled != nil { + m.CommandsEnabled = types.BoolValue(*q.CommandsEnabled) + } else { + m.CommandsEnabled = types.BoolNull() + } + if q.CreatePrsForTestingBranches != nil { + m.CreatePrsForTestingBranches = types.BoolValue(*q.CreatePrsForTestingBranches) + } else { + m.CreatePrsForTestingBranches = types.BoolNull() + } + if q.StatusCheckEnabled != nil { + m.StatusCheckEnabled = types.BoolValue(*q.StatusCheckEnabled) + } else { + m.StatusCheckEnabled = types.BoolNull() + } + if q.DirectMergeMode != nil { + m.DirectMergeMode = types.StringValue(*q.DirectMergeMode) + } else { + m.DirectMergeMode = types.StringNull() + } + if q.OptimizationMode != nil { + m.OptimizationMode = types.StringValue(*q.OptimizationMode) + } else { + m.OptimizationMode = types.StringNull() + } + if q.BisectionConcurrency != nil { + m.BisectionConcurrency = types.Int64Value(int64(*q.BisectionConcurrency)) + } else { + m.BisectionConcurrency = types.Int64Null() + } + + if len(q.RequiredStatuses) > 0 { + elems := make([]attr.Value, len(q.RequiredStatuses)) + for i, s := range q.RequiredStatuses { + elems[i] = types.StringValue(s) + } + m.RequiredStatuses, _ = types.ListValue(types.StringType, elems) + } else { + m.RequiredStatuses = types.ListNull(types.StringType) + } +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go new file mode 100644 index 0000000..5fdceb4 --- /dev/null +++ b/internal/provider/provider.go @@ -0,0 +1,93 @@ +package provider + +import ( + "context" + "os" + + "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" + "github.com/trunk-io/terraform-provider-trunk/internal/client" +) + +type trunkProvider struct { + version string +} + +func New(version string) func() provider.Provider { + return func() provider.Provider { + return &trunkProvider{ + version: version, + } + } +} + +func (p *trunkProvider) Metadata(_ context.Context, _ provider.MetadataRequest, resp *provider.MetadataResponse) { + resp.TypeName = "trunk" + resp.Version = p.version +} + +func (p *trunkProvider) Schema(_ context.Context, _ provider.SchemaRequest, resp *provider.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Interact with Trunk.io services.", + Attributes: map[string]schema.Attribute{ + "api_key": schema.StringAttribute{ + Description: "API key for Trunk.io. Can also be set via the TRUNK_API_KEY environment variable.", + Optional: true, + Sensitive: true, + }, + "base_url": schema.StringAttribute{ + Description: "Base URL for the Trunk API. Defaults to https://api.trunk.io/v1. Can also be set via the TRUNK_BASE_URL environment variable.", + Optional: true, + }, + }, + } +} + +func (p *trunkProvider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) { + var config struct { + APIKey types.String `tfsdk:"api_key"` + BaseURL types.String `tfsdk:"base_url"` + } + + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + if resp.Diagnostics.HasError() { + return + } + + apiKey := config.APIKey.ValueString() + if apiKey == "" { + apiKey = os.Getenv("TRUNK_API_KEY") + } + if apiKey == "" { + resp.Diagnostics.AddError( + "Missing API Key", + "The provider cannot create the Trunk API client because there is no API key. "+ + "Set the api_key value in the provider configuration or use the TRUNK_API_KEY environment variable.", + ) + return + } + + baseURL := config.BaseURL.ValueString() + if baseURL == "" { + baseURL = os.Getenv("TRUNK_BASE_URL") + } + if baseURL == "" { + baseURL = "https://api.trunk.io/v1" + } + c := client.NewClient(apiKey, baseURL) + resp.ResourceData = c + resp.DataSourceData = c +} + +func (p *trunkProvider) Resources(_ context.Context) []func() resource.Resource { + return []func() resource.Resource{ + NewMergeQueueResource, + } +} + +func (p *trunkProvider) DataSources(_ context.Context) []func() datasource.DataSource { + return []func() datasource.DataSource{} +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..9486bf7 --- /dev/null +++ b/main.go @@ -0,0 +1,29 @@ +package main + +import ( + "context" + "flag" + "log" + + "github.com/hashicorp/terraform-plugin-framework/providerserver" + "github.com/trunk-io/terraform-provider-trunk/internal/provider" +) + +var version = "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/trunk-io/trunk", + 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..295001a --- /dev/null +++ b/terraform-registry-manifest.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "metadata": { + "protocol_versions": ["6.0"] + } +} From 5cbe2c669c2dd1b992e3b7c05553e04514c16552 Mon Sep 17 00:00:00 2001 From: Phil Vendola Date: Mon, 6 Apr 2026 12:46:47 -0400 Subject: [PATCH 2/5] fix: resolve trunk check lint issues - Remove redundant quotes on v* tag pattern in release workflow (yamllint) - Wrap API URL in backticks in provider schema description to avoid bare URL in generated docs (markdownlint MD034) - Disable MD033 in markdownlint config for tfplugindocs-generated HTML anchors - Add required_version and version constraints to example configs (tflint) - Change example source to short form trunk-io/trunk - Delete staging and manual-test examples (internal configs, not for public consumption) Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 2 +- .trunk/.gitignore | 9 +++++ .trunk/configs/.markdownlint.yaml | 4 +++ .trunk/configs/.yamllint.yaml | 7 ++++ .trunk/trunk.yaml | 37 ++++++++++++++++++++ docs/index.md | 5 ++- docs/resources/merge_queue.md | 4 +-- examples/manual-test/main.tf | 22 ------------ examples/provider/main.tf | 4 ++- examples/resources/trunk_merge_queue/main.tf | 10 ++++++ examples/staging/main.tf | 30 ---------------- internal/provider/provider.go | 2 +- 12 files changed, 76 insertions(+), 60 deletions(-) create mode 100644 .trunk/.gitignore create mode 100644 .trunk/configs/.markdownlint.yaml create mode 100644 .trunk/configs/.yamllint.yaml create mode 100644 .trunk/trunk.yaml delete mode 100644 examples/manual-test/main.tf delete mode 100644 examples/staging/main.tf diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1949531..d24d892 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,7 +3,7 @@ name: Release on: push: tags: - - "v*" + - v* permissions: contents: write diff --git a/.trunk/.gitignore b/.trunk/.gitignore new file mode 100644 index 0000000..15966d0 --- /dev/null +++ b/.trunk/.gitignore @@ -0,0 +1,9 @@ +*out +*logs +*actions +*notifications +*tools +plugins +user_trunk.yaml +user.yaml +tmp diff --git a/.trunk/configs/.markdownlint.yaml b/.trunk/configs/.markdownlint.yaml new file mode 100644 index 0000000..2101870 --- /dev/null +++ b/.trunk/configs/.markdownlint.yaml @@ -0,0 +1,4 @@ +# Prettier friendly markdownlint config (all formatting rules disabled) +extends: markdownlint/style/prettier +# MD033: tfplugindocs generates anchors in docs/ — unavoidable +MD033: false diff --git a/.trunk/configs/.yamllint.yaml b/.trunk/configs/.yamllint.yaml new file mode 100644 index 0000000..184e251 --- /dev/null +++ b/.trunk/configs/.yamllint.yaml @@ -0,0 +1,7 @@ +rules: + quoted-strings: + required: only-when-needed + extra-allowed: ["{|}"] + key-duplicates: {} + octal-values: + forbid-implicit-octal: true diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml new file mode 100644 index 0000000..95f729e --- /dev/null +++ b/.trunk/trunk.yaml @@ -0,0 +1,37 @@ +# This file controls the behavior of Trunk: https://docs.trunk.io/cli +# To learn more about the format of this file, see https://docs.trunk.io/reference/trunk-yaml +version: 0.1 +cli: + version: 1.25.0 +# Trunk provides extensibility via plugins. (https://docs.trunk.io/plugins) +plugins: + sources: + - id: trunk + ref: v1.7.6 + uri: https://github.com/trunk-io/plugins +# Many linters and tools depend on runtimes - configure them here. (https://docs.trunk.io/runtimes) +runtimes: + enabled: + - go@1.25.8 + - node@22.16.0 + - python@3.10.8 +# This is the section where you manage your linters. (https://docs.trunk.io/check/configuration) +lint: + enabled: + - actionlint@1.7.12 + - checkov@3.2.517 + - git-diff-check + - gofmt@1.20.4 + - golangci-lint2@2.11.4 + - markdownlint@0.48.0 + - osv-scanner@2.3.5 + - prettier@3.8.1 + - tflint@0.61.0 + - trufflehog@3.94.2 + - yamllint@1.38.0 +actions: + enabled: + - trunk-announce + - trunk-check-pre-push + - trunk-fmt-pre-commit + - trunk-upgrade-available diff --git a/docs/index.md b/docs/index.md index 7a575fd..b98c0e0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -9,12 +9,11 @@ description: |- Interact with Trunk.io services. - - + ## Schema ### Optional - `api_key` (String, Sensitive) API key for Trunk.io. Can also be set via the TRUNK_API_KEY environment variable. -- `base_url` (String) Base URL for the Trunk API. Defaults to https://api.trunk.io/v1. Can also be set via the TRUNK_BASE_URL environment variable. +- `base_url` (String) Base URL for the Trunk API. Defaults to `https://api.trunk.io/v1`. Can also be set via the TRUNK_BASE_URL environment variable. diff --git a/docs/resources/merge_queue.md b/docs/resources/merge_queue.md index 61e7ee7..79cfa7c 100644 --- a/docs/resources/merge_queue.md +++ b/docs/resources/merge_queue.md @@ -10,9 +10,8 @@ description: |- Manages a Trunk merge queue. - - + ## Schema ### Required @@ -46,6 +45,7 @@ Manages a Trunk merge queue. - `id` (String) Unique identifier for this queue in the format {host}/{owner}/{name}/{target_branch}. + ### Nested Schema for `repo` Required: diff --git a/examples/manual-test/main.tf b/examples/manual-test/main.tf deleted file mode 100644 index c00a262..0000000 --- a/examples/manual-test/main.tf +++ /dev/null @@ -1,22 +0,0 @@ -terraform { - required_providers { - trunk = { - source = "registry.terraform.io/trunk-io/trunk" - } - } -} - -# api_key is read from TRUNK_API_KEY environment variable. -# trunk2 targets staging; base_url can also be set via TRUNK_BASE_URL. -provider "trunk" { - base_url = "https://api.trunk-staging.io/v1" -} - -resource "trunk_merge_queue" "trunk2" { - repo = { - host = "github.com" - owner = "trunk-io" - name = "trunk2" - } - target_branch = "main" -} diff --git a/examples/provider/main.tf b/examples/provider/main.tf index 46a0495..805e59b 100644 --- a/examples/provider/main.tf +++ b/examples/provider/main.tf @@ -1,7 +1,9 @@ terraform { + required_version = ">= 1.0" required_providers { trunk = { - source = "registry.terraform.io/trunk-io/trunk" + source = "trunk-io/trunk" + version = "~> 0.1" } } } diff --git a/examples/resources/trunk_merge_queue/main.tf b/examples/resources/trunk_merge_queue/main.tf index bbfbdbb..9b5bba0 100644 --- a/examples/resources/trunk_merge_queue/main.tf +++ b/examples/resources/trunk_merge_queue/main.tf @@ -1,3 +1,13 @@ +terraform { + required_version = ">= 1.0" + required_providers { + trunk = { + source = "trunk-io/trunk" + version = "~> 0.1" + } + } +} + resource "trunk_merge_queue" "example" { repo = { host = "github.com" diff --git a/examples/staging/main.tf b/examples/staging/main.tf deleted file mode 100644 index f6dbd67..0000000 --- a/examples/staging/main.tf +++ /dev/null @@ -1,30 +0,0 @@ -terraform { - required_providers { - trunk = { - source = "registry.terraform.io/trunk-io/trunk" - } - } - - backend "s3" { - bucket = "trunk-terraform-state-staging" - key = "merge-queues/terraform.tfstate" - region = "us-west-2" - dynamodb_table = "terraform-state-lock" - encrypt = true - } -} - -# api_key is read from TRUNK_API_KEY environment variable. -# base_url is read from TRUNK_BASE_URL environment variable. -provider "trunk" { - base_url = "https://api.trunk-staging.io/v1" -} - -resource "trunk_merge_queue" "trunk2" { - repo = { - host = "github.com" - owner = "trunk-io" - name = "trunk2" - } - target_branch = "main" -} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 5fdceb4..5933fc6 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -39,7 +39,7 @@ func (p *trunkProvider) Schema(_ context.Context, _ provider.SchemaRequest, resp Sensitive: true, }, "base_url": schema.StringAttribute{ - Description: "Base URL for the Trunk API. Defaults to https://api.trunk.io/v1. Can also be set via the TRUNK_BASE_URL environment variable.", + Description: "Base URL for the Trunk API. Defaults to `https://api.trunk.io/v1`. Can also be set via the TRUNK_BASE_URL environment variable.", Optional: true, }, }, From aa84d0d18f3fc0a640eee69f3611e08796a1bb63 Mon Sep 17 00:00:00 2001 From: Phil Vendola Date: Mon, 6 Apr 2026 13:36:39 -0400 Subject: [PATCH 3/5] Further preparation for terraform release --- .gitignore | 4 + CHANGELOG.md | 18 ++ LICENSE | 376 ++++++++++++++++++++++ Makefile | 21 ++ go.mod | 24 +- go.sum | 62 ++-- internal/client/client.go | 43 ++- internal/client/client_test.go | 66 +++- internal/client/merge_queue_test.go | 22 +- internal/provider/merge_queue_resource.go | 64 +++- internal/provider/provider.go | 2 + 11 files changed, 635 insertions(+), 67 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 Makefile diff --git a/.gitignore b/.gitignore index eed506f..9eabe5b 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,7 @@ dist/ *.tfstate.* .terraform/ .terraform.lock.hcl + +# IDE +.vscode/ +.idea/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a52d446 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,18 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- `trunk_merge_queue` resource for managing Trunk merge queues + - Full CRUD operations (create, read, update, delete) + - Import support via `terraform import` + - Configurable queue mode (single/parallel), concurrency, merge method, batching, and more + - Override required status checks or revert to branch protection defaults +- Provider authentication via `api_key` attribute or `TRUNK_API_KEY` environment variable +- Configurable API base URL via `base_url` attribute or `TRUNK_BASE_URL` environment variable diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9582302 --- /dev/null +++ b/LICENSE @@ -0,0 +1,376 @@ +Copyright (c) 2024 Trunk Technologies, Inc. + +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under the name "LEGAL", and included in all Covered Software +distributions. Except to the extent prohibited by statute or regulation, +such description must be sufficiently detailed for a recipient of +ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at https://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9581074 --- /dev/null +++ b/Makefile @@ -0,0 +1,21 @@ +default: build + +build: + go build ./... + +fmt: + gofmt -w . + +test: + go test ./internal/client/ -v + +testacc: + TF_ACC=1 go test ./internal/provider/ -v -timeout 10m + +lint: + golangci-lint run ./... + +generate: + go generate ./... + +.PHONY: default build fmt test testacc lint generate diff --git a/go.mod b/go.mod index 955b066..6b5c954 100644 --- a/go.mod +++ b/go.mod @@ -1,28 +1,30 @@ module github.com/trunk-io/terraform-provider-trunk -go 1.24.0 +go 1.25.0 toolchain go1.25.8 require ( - github.com/hashicorp/terraform-plugin-framework v1.13.0 - github.com/hashicorp/terraform-plugin-go v0.25.0 // indirect - github.com/hashicorp/terraform-plugin-log v0.9.0 // indirect + github.com/hashicorp/terraform-plugin-framework v1.19.0 + github.com/hashicorp/terraform-plugin-go v0.31.0 // indirect + github.com/hashicorp/terraform-plugin-log v0.10.0 // indirect ) +require github.com/hashicorp/terraform-plugin-framework-validators v0.19.0 + require ( - github.com/fatih/color v1.16.0 // indirect + github.com/fatih/color v1.18.0 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/hashicorp/go-hclog v1.6.3 // indirect - github.com/hashicorp/go-plugin v1.6.2 // indirect + github.com/hashicorp/go-plugin v1.7.0 // indirect github.com/hashicorp/go-uuid v1.0.3 // indirect - github.com/hashicorp/terraform-registry-address v0.2.3 // 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.1 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect + github.com/hashicorp/yamux v0.1.2 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/go-testing-interface v1.14.1 // indirect - github.com/oklog/run v1.0.0 // indirect + github.com/oklog/run v1.1.0 // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect golang.org/x/net v0.48.0 // indirect @@ -30,5 +32,5 @@ require ( golang.org/x/text v0.32.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/grpc v1.79.3 // indirect - google.golang.org/protobuf v1.36.10 // indirect + google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/go.sum b/go.sum index 9af33a6..cf6e915 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +1,14 @@ -github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA= -github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8= +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/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/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/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/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= 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= @@ -20,43 +21,45 @@ 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/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-plugin v1.6.2 h1:zdGAEd0V1lCaU0u+MxWQhtSDQmahpkwOun8U8EiRVog= -github.com/hashicorp/go-plugin v1.6.2/go.mod h1:CkgLQ5CZqNmdL9U9JzM532t8ZiYQ35+pj3b1FD37R0Q= +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-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/terraform-plugin-framework v1.13.0 h1:8OTG4+oZUfKgnfTdPTJwZ532Bh2BobF4H+yBiYJ/scw= -github.com/hashicorp/terraform-plugin-framework v1.13.0/go.mod h1:j64rwMGpgM3NYXTKuxrCnyubQb/4VKldEKlcG8cvmjU= -github.com/hashicorp/terraform-plugin-go v0.25.0 h1:oi13cx7xXA6QciMcpcFi/rwA974rdTxjqEhXJjbAyks= -github.com/hashicorp/terraform-plugin-go v0.25.0/go.mod h1:+SYagMYadJP86Kvn+TGeV+ofr/R3g4/If0O5sO96MVw= -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.2.3 h1:2TAiKJ1A3MAkZlH1YI/aTVcLZRu7JseiXNRHbOAyoTI= -github.com/hashicorp/terraform-registry-address v0.2.3/go.mod h1:lFHA76T8jfQteVfT7caREqguFrW3c4MFSPhZB7HHgUM= +github.com/hashicorp/terraform-plugin-framework v1.19.0 h1:q0bwyhxAOR3vfdgbk9iplv3MlTv/dhBHTXjQOtQDoBA= +github.com/hashicorp/terraform-plugin-framework v1.19.0/go.mod h1:YRXOBu0jvs7xp4AThBbX4mAzYaMJ1JgtFH//oGKxwLc= +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.31.0 h1:0Fz2r9DQ+kNNl6bx8HRxFd1TfMKUvnrOtvJPmp3Z0q8= +github.com/hashicorp/terraform-plugin-go v0.31.0/go.mod h1:A88bDhd/cW7FnwqxQRz3slT+QY6yzbHKc6AOTtmdeS8= +github.com/hashicorp/terraform-plugin-log v0.10.0 h1:eu2kW6/QBVdN4P3Ju2WiB2W3ObjkAsyfBsL3Wh1fj3g= +github.com/hashicorp/terraform-plugin-log v0.10.0/go.mod h1:/9RR5Cv2aAbrqcTSdNmY1NRHP4E3ekrXRGjqORpXyB0= +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.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE= -github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ= -github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c= -github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gavG9e0Q693nKo= +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/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/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.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +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.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= -github.com/oklog/run v1.0.0 h1:Ru7dDtJNOyC66gQ5dQmaCa0qIsAUFY3sFpK1Xk8igrw= -github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +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/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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= -github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= -github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +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= @@ -80,7 +83,6 @@ golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7w 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-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= @@ -92,8 +94,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1: google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= -google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= -google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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/internal/client/client.go b/internal/client/client.go index 8f1a97b..3b9b87e 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "io" + "math" "net/http" "strings" "time" @@ -15,9 +16,11 @@ const defaultBaseURL = "https://api.trunk.io/v1" // Client communicates with the Trunk API. type Client struct { - baseURL string - apiKey string - httpClient *http.Client + baseURL string + apiKey string + httpClient *http.Client + maxRetries int + baseRetryDelay time.Duration } // NewClient creates a new Client. If baseURL is empty, it defaults to the production Trunk API. @@ -26,20 +29,48 @@ func NewClient(apiKey, baseURL string) *Client { baseURL = defaultBaseURL } return &Client{ - baseURL: strings.TrimRight(baseURL, "/"), - apiKey: apiKey, - httpClient: &http.Client{Timeout: 30 * time.Second}, + baseURL: strings.TrimRight(baseURL, "/"), + apiKey: apiKey, + httpClient: &http.Client{Timeout: 30 * time.Second}, + maxRetries: 3, + baseRetryDelay: 500 * time.Millisecond, } } // doRequest sends a POST request to the given endpoint, marshaling reqBody as JSON and // unmarshaling the response into respBody. Returns an *APIError for non-2xx responses. +// Retries up to maxRetries times with exponential backoff on 5xx errors and network failures. func (c *Client) doRequest(ctx context.Context, endpoint string, reqBody any, respBody any) error { bodyBytes, err := json.Marshal(reqBody) if err != nil { return fmt.Errorf("marshaling request: %w", err) } + var lastErr error + for attempt := 0; attempt <= c.maxRetries; attempt++ { + if attempt > 0 { + delay := time.Duration(math.Pow(2, float64(attempt-1))) * c.baseRetryDelay + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(delay): + } + } + + lastErr = c.doRequestOnce(ctx, endpoint, bodyBytes, respBody) + if lastErr == nil { + return nil + } + + // Only retry on 5xx errors or network failures; not on 4xx. + if apiErr, ok := lastErr.(*APIError); ok && apiErr.StatusCode < 500 { + return lastErr + } + } + return lastErr +} + +func (c *Client) doRequestOnce(ctx context.Context, endpoint string, bodyBytes []byte, respBody any) error { req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/"+strings.TrimPrefix(endpoint, "/"), bytes.NewReader(bodyBytes)) if err != nil { return fmt.Errorf("creating request: %w", err) diff --git a/internal/client/client_test.go b/internal/client/client_test.go index 0ecce58..e899d76 100644 --- a/internal/client/client_test.go +++ b/internal/client/client_test.go @@ -5,8 +5,17 @@ import ( "net/http" "net/http/httptest" "testing" + "time" ) +// newTestClient creates a client with retries disabled for fast unit tests. +func newTestClient(apiKey, baseURL string) *Client { + c := NewClient(apiKey, baseURL) + c.maxRetries = 0 + c.baseRetryDelay = 0 + return c +} + func TestDoRequest_SetsAuthAndContentTypeHeaders(t *testing.T) { var gotAPIToken, gotContentType, gotMethod string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -18,7 +27,7 @@ func TestDoRequest_SetsAuthAndContentTypeHeaders(t *testing.T) { })) defer server.Close() - c := NewClient("secret-key", server.URL) + c := newTestClient("secret-key", server.URL) err := c.doRequest(context.Background(), "test", struct{}{}, nil) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -41,7 +50,7 @@ func TestDoRequest_Returns4xxAsAPIError(t *testing.T) { })) defer server.Close() - c := NewClient("bad-key", server.URL) + c := newTestClient("bad-key", server.URL) err := c.doRequest(context.Background(), "test", struct{}{}, nil) apiErr, ok := err.(*APIError) @@ -63,7 +72,7 @@ func TestDoRequest_Returns5xxAsAPIError(t *testing.T) { })) defer server.Close() - c := NewClient("key", server.URL) + c := newTestClient("key", server.URL) err := c.doRequest(context.Background(), "test", struct{}{}, nil) apiErr, ok := err.(*APIError) @@ -85,7 +94,7 @@ func TestDoRequest_ReturnsErrorOnMalformedJSON(t *testing.T) { })) defer server.Close() - c := NewClient("key", server.URL) + c := newTestClient("key", server.URL) var out struct{ Field string } err := c.doRequest(context.Background(), "test", struct{}{}, &out) if err == nil { @@ -119,9 +128,56 @@ func TestDoRequest_ReturnsErrorOnNetworkFailure(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) server.Close() // close before the request so Do() returns a network error - c := NewClient("key", server.URL) + c := newTestClient("key", server.URL) err := c.doRequest(context.Background(), "test", struct{}{}, nil) if err == nil { t.Fatal("expected error on network failure, got nil") } } + +func TestDoRequest_Retries5xxThenSucceeds(t *testing.T) { + attempts := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempts++ + if attempts < 3 { + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte("unavailable")) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("{}")) + })) + defer server.Close() + + c := newTestClient("key", server.URL) + c.maxRetries = 3 + c.baseRetryDelay = time.Millisecond // keep test fast + err := c.doRequest(context.Background(), "test", struct{}{}, nil) + if err != nil { + t.Fatalf("expected success after retries, got: %v", err) + } + if attempts != 3 { + t.Errorf("expected 3 attempts, got %d", attempts) + } +} + +func TestDoRequest_DoesNotRetry4xx(t *testing.T) { + attempts := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempts++ + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte("bad request")) + })) + defer server.Close() + + c := newTestClient("key", server.URL) + c.maxRetries = 3 + c.baseRetryDelay = time.Millisecond + err := c.doRequest(context.Background(), "test", struct{}{}, nil) + if err == nil { + t.Fatal("expected error, got nil") + } + if attempts != 1 { + t.Errorf("expected 1 attempt (no retries for 4xx), got %d", attempts) + } +} diff --git a/internal/client/merge_queue_test.go b/internal/client/merge_queue_test.go index 94753c8..c7eb03b 100644 --- a/internal/client/merge_queue_test.go +++ b/internal/client/merge_queue_test.go @@ -30,7 +30,7 @@ func TestCreateQueue(t *testing.T) { })) defer server.Close() - c := NewClient("key", server.URL) + c := newTestClient("key", server.URL) req := CreateQueueRequest{ Repo: Repo{Host: "github.com", Owner: "my-org", Name: "my-repo"}, TargetBranch: "main", @@ -68,7 +68,7 @@ func TestCreateQueue_ReturnsErrorOnAPIFailure(t *testing.T) { })) defer server.Close() - c := NewClient("key", server.URL) + c := newTestClient("key", server.URL) _, err := c.CreateQueue(context.Background(), CreateQueueRequest{ Repo: Repo{Host: "github.com", Owner: "my-org", Name: "my-repo"}, TargetBranch: "main", @@ -95,7 +95,7 @@ func TestGetQueue(t *testing.T) { })) defer server.Close() - c := NewClient("key", server.URL) + c := newTestClient("key", server.URL) req := GetQueueRequest{ Repo: Repo{Host: "github.com", Owner: "my-org", Name: "my-repo"}, TargetBranch: "main", @@ -133,7 +133,7 @@ func TestGetQueue_ReturnsErrorOnAPIFailure(t *testing.T) { })) defer server.Close() - c := NewClient("key", server.URL) + c := newTestClient("key", server.URL) _, err := c.GetQueue(context.Background(), GetQueueRequest{ Repo: Repo{Host: "github.com", Owner: "my-org", Name: "my-repo"}, TargetBranch: "main", @@ -158,7 +158,7 @@ func TestUpdateQueue_OmitsNilFields(t *testing.T) { })) defer server.Close() - c := NewClient("key", server.URL) + c := newTestClient("key", server.URL) _, err := c.UpdateQueue(context.Background(), UpdateQueueRequest{ Repo: Repo{Host: "github.com", Owner: "my-org", Name: "my-repo"}, TargetBranch: "main", @@ -190,7 +190,7 @@ func TestUpdateQueue_IncludesNonNilFields(t *testing.T) { concurrency := 3 mergeMethod := "SQUASH" batch := true - c := NewClient("key", server.URL) + c := newTestClient("key", server.URL) _, err := c.UpdateQueue(context.Background(), UpdateQueueRequest{ Repo: Repo{Host: "github.com", Owner: "my-org", Name: "my-repo"}, TargetBranch: "main", @@ -226,7 +226,7 @@ func TestUpdateQueue_DeleteRequiredStatuses(t *testing.T) { defer server.Close() deleteStatuses := true - c := NewClient("key", server.URL) + c := newTestClient("key", server.URL) _, err := c.UpdateQueue(context.Background(), UpdateQueueRequest{ Repo: Repo{Host: "github.com", Owner: "my-org", Name: "my-repo"}, TargetBranch: "main", @@ -250,7 +250,7 @@ func TestUpdateQueue_SendsEmptyRequiredStatuses(t *testing.T) { defer server.Close() empty := []string{} - c := NewClient("key", server.URL) + c := newTestClient("key", server.URL) _, err := c.UpdateQueue(context.Background(), UpdateQueueRequest{ Repo: Repo{Host: "github.com", Owner: "my-org", Name: "my-repo"}, TargetBranch: "main", @@ -276,7 +276,7 @@ func TestUpdateQueue_ReturnsErrorOnAPIFailure(t *testing.T) { })) defer server.Close() - c := NewClient("key", server.URL) + c := newTestClient("key", server.URL) _, err := c.UpdateQueue(context.Background(), UpdateQueueRequest{ Repo: Repo{Host: "github.com", Owner: "my-org", Name: "my-repo"}, TargetBranch: "main", @@ -295,7 +295,7 @@ func TestDeleteQueue(t *testing.T) { })) defer server.Close() - c := NewClient("key", server.URL) + c := newTestClient("key", server.URL) err := c.DeleteQueue(context.Background(), DeleteQueueRequest{ Repo: Repo{Host: "github.com", Owner: "my-org", Name: "my-repo"}, TargetBranch: "main", @@ -318,7 +318,7 @@ func TestDeleteQueue_ReturnsAPIErrorOnFailure(t *testing.T) { })) defer server.Close() - c := NewClient("key", server.URL) + c := newTestClient("key", server.URL) err := c.DeleteQueue(context.Background(), DeleteQueueRequest{ Repo: Repo{Host: "github.com", Owner: "my-org", Name: "my-repo"}, TargetBranch: "main", diff --git a/internal/provider/merge_queue_resource.go b/internal/provider/merge_queue_resource.go index 634d1d9..d89d0d3 100644 --- a/internal/provider/merge_queue_resource.go +++ b/internal/provider/merge_queue_resource.go @@ -6,10 +6,13 @@ import ( "fmt" "strings" + "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "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/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/trunk-io/terraform-provider-trunk/internal/client" ) @@ -80,26 +83,41 @@ func (r *mergeQueueResource) Schema(_ context.Context, _ resource.SchemaRequest, Description: "Queue mode: \"single\" or \"parallel\".", Optional: true, Computed: true, + Validators: []validator.String{ + stringvalidator.OneOf("single", "parallel"), + }, }, "concurrency": schema.Int64Attribute{ Description: "Number of concurrent test slots.", Optional: true, Computed: true, + Validators: []validator.Int64{ + int64validator.AtLeast(1), + }, }, "state": schema.StringAttribute{ Description: "Queue state: \"RUNNING\", \"PAUSED\", or \"DRAINING\".", Optional: true, Computed: true, + Validators: []validator.String{ + stringvalidator.OneOf("RUNNING", "PAUSED", "DRAINING"), + }, }, "testing_timeout_minutes": schema.Int64Attribute{ Description: "Maximum minutes to wait for tests.", Optional: true, Computed: true, + Validators: []validator.Int64{ + int64validator.AtLeast(1), + }, }, "pending_failure_depth": schema.Int64Attribute{ Description: "Number of PRs below a failure to wait for before eviction.", Optional: true, Computed: true, + Validators: []validator.Int64{ + int64validator.AtLeast(0), + }, }, "can_optimistically_merge": schema.BoolAttribute{ Description: "Allow optimistic merge when a lower PR passes.", @@ -115,16 +133,25 @@ func (r *mergeQueueResource) Schema(_ context.Context, _ resource.SchemaRequest, Description: "Maximum minutes to wait for a batch to fill.", Optional: true, Computed: true, + Validators: []validator.Int64{ + int64validator.AtLeast(1), + }, }, "batching_min_size": schema.Int64Attribute{ Description: "Minimum number of PRs per batch.", Optional: true, Computed: true, + Validators: []validator.Int64{ + int64validator.AtLeast(1), + }, }, "merge_method": schema.StringAttribute{ Description: "Merge method: \"MERGE_COMMIT\", \"SQUASH\", or \"REBASE\".", Optional: true, Computed: true, + Validators: []validator.String{ + stringvalidator.OneOf("MERGE_COMMIT", "SQUASH", "REBASE"), + }, }, "comments_enabled": schema.BoolAttribute{ Description: "Post GitHub comments on PRs.", @@ -150,16 +177,25 @@ func (r *mergeQueueResource) Schema(_ context.Context, _ resource.SchemaRequest, Description: "Direct merge mode: \"OFF\" or \"ALWAYS\".", Optional: true, Computed: true, + Validators: []validator.String{ + stringvalidator.OneOf("OFF", "ALWAYS"), + }, }, "optimization_mode": schema.StringAttribute{ Description: "Optimization mode: \"OFF\" or \"BISECTION_SKIP_REDUNDANT_TESTS\".", Optional: true, Computed: true, + Validators: []validator.String{ + stringvalidator.OneOf("OFF", "BISECTION_SKIP_REDUNDANT_TESTS"), + }, }, "bisection_concurrency": schema.Int64Attribute{ Description: "Number of concurrent tests during bisection.", Optional: true, Computed: true, + Validators: []validator.Int64{ + int64validator.AtLeast(1), + }, }, "required_statuses": schema.ListAttribute{ Description: "Override required status checks. Set to null to revert to branch protection or trunk.yaml defaults; set to [] to explicitly require no statuses.", @@ -261,7 +297,12 @@ func (r *mergeQueueResource) Read(ctx context.Context, req resource.ReadRequest, resp.State.RemoveResource(ctx) return } - resp.Diagnostics.AddError("Error reading merge queue", err.Error()) + resp.Diagnostics.AddError( + "Error reading merge queue", + fmt.Sprintf("Could not read merge queue %s/%s/%s branch %q: %s", + model.Repo.Host.ValueString(), model.Repo.Owner.ValueString(), model.Repo.Name.ValueString(), + model.TargetBranch.ValueString(), err.Error()), + ) return } @@ -279,7 +320,12 @@ func (r *mergeQueueResource) Update(ctx context.Context, req resource.UpdateRequ _, err := r.client.UpdateQueue(ctx, model.toUpdateRequest()) if err != nil { - resp.Diagnostics.AddError("Error updating merge queue", err.Error()) + resp.Diagnostics.AddError( + "Error updating merge queue", + fmt.Sprintf("Could not update merge queue %s/%s/%s branch %q: %s", + model.Repo.Host.ValueString(), model.Repo.Owner.ValueString(), model.Repo.Name.ValueString(), + model.TargetBranch.ValueString(), err.Error()), + ) return } @@ -288,7 +334,12 @@ func (r *mergeQueueResource) Update(ctx context.Context, req resource.UpdateRequ TargetBranch: model.TargetBranch.ValueString(), }) if err != nil { - resp.Diagnostics.AddError("Error reading merge queue after update", err.Error()) + resp.Diagnostics.AddError( + "Error reading merge queue after update", + fmt.Sprintf("Could not read merge queue %s/%s/%s branch %q after update: %s", + model.Repo.Host.ValueString(), model.Repo.Owner.ValueString(), model.Repo.Name.ValueString(), + model.TargetBranch.ValueString(), err.Error()), + ) return } @@ -318,7 +369,12 @@ func (r *mergeQueueResource) Delete(ctx context.Context, req resource.DeleteRequ ) return } - resp.Diagnostics.AddError("Error deleting merge queue", err.Error()) + resp.Diagnostics.AddError( + "Error deleting merge queue", + fmt.Sprintf("Could not delete merge queue %s/%s/%s branch %q: %s", + model.Repo.Host.ValueString(), model.Repo.Owner.ValueString(), model.Repo.Name.ValueString(), + model.TargetBranch.ValueString(), err.Error()), + ) } } diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 5933fc6..70818c6 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -16,6 +16,8 @@ type trunkProvider struct { version string } +// New returns a factory function that creates a new trunkProvider instance. +// The version string is embedded in provider metadata for registry display. func New(version string) func() provider.Provider { return func() provider.Provider { return &trunkProvider{ From 9db3cbfccee47893e69d1419a7adbfddb52010a9 Mon Sep 17 00:00:00 2001 From: Phil Vendola Date: Mon, 6 Apr 2026 14:37:37 -0400 Subject: [PATCH 4/5] PR review comments --- LICENSE | 2 +- docs/trd/terraform-provider-trunk.md | 244 +++++++++++++++++++++++++++ 2 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 docs/trd/terraform-provider-trunk.md diff --git a/LICENSE b/LICENSE index 9582302..11cfda5 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2024 Trunk Technologies, Inc. +Copyright (c) 2026 Trunk Technologies, Inc. Mozilla Public License Version 2.0 ================================== diff --git a/docs/trd/terraform-provider-trunk.md b/docs/trd/terraform-provider-trunk.md new file mode 100644 index 0000000..1b7188d --- /dev/null +++ b/docs/trd/terraform-provider-trunk.md @@ -0,0 +1,244 @@ +# TRD: Terraform Provider for Trunk Merge Queue + +## Related Docs + +- Trunk Merge Queue public API: `https://api.trunk.io/v1` +- [HashiCorp Terraform Plugin Framework](https://developer.hashicorp.com/terraform/plugin/framework) + +## Problem + +Users want to manage Trunk merge queue configuration as infrastructure-as-code. Today, queue creation and configuration is done through the UI or ad-hoc API calls. There is no declarative, version-controlled way to manage queue settings. + +### Goal + +Build a Terraform provider (`terraform-provider-trunk`) that enables declarative management of merge queues via the Trunk public API. The MVP covers a single resource (`trunk_merge_queue`) supporting create, read, update, delete, and import. The provider will be published to the Terraform Registry. + +## Approach + +### Expected Results + +Users can manage merge queue lifecycle and configuration through standard Terraform workflows: + +```hcl +resource "trunk_merge_queue" "main" { + repo = { + host = "github.com" + owner = "my-org" + name = "my-repo" + } + target_branch = "main" + mode = "parallel" + concurrency = 3 + merge_method = "SQUASH" +} +``` + +### Implementation Description + +**Provider** (`trunk`): Authenticates via org API token (`api_key` attribute or `TRUNK_API_KEY` env var). Optional `base_url` for staging. + +**Resource** (`trunk_merge_queue`): Maps to four Trunk API endpoints: + +| Terraform Operation | API Endpoint | HTTP Method | +| ------------------- | -------------- | ----------- | +| Create | `/createQueue` | POST | +| Read | `/getQueue` | POST | +| Update | `/updateQueue` | POST | +| Delete | `/deleteQueue` | POST | + +**Create lifecycle detail:** The `createQueue` API only accepts `repo`, `targetBranch`, `mode`, and `concurrency`. All other configuration is set via `updateQueue`. The resource's `Create` method must: + +1. Call `createQueue` with identity fields + `mode` + `concurrency` +2. Immediately call `updateQueue` with all remaining optional attributes +3. Call `getQueue` to read back the full state + +If step 2 fails, the resource exists but is partially configured. The `Create` method returns an error with the resource ID set in state, so a subsequent `terraform apply` triggers `Update` to complete configuration. + +**Conflict on create (409):** The `createQueue` API returns 409 if a queue already exists for that repo/branch. The `Create` method detects this and returns a descriptive error telling the user to import instead of create: + +```text +A merge queue for github.com/my-org/my-repo already exists on branch "main". Import it into Terraform state with: + + terraform import trunk_merge_queue. "github.com/my-org/my-repo/main" +``` + +**Delete lifecycle detail:** The `deleteQueue` API returns 400 if the queue still has PRs in it. Since the provider always sends well-formed requests, a 400 is treated as "queue not empty" and surfaces a descriptive error telling the user to set `state = "DRAINING"` and wait for the queue to empty before retrying. All other errors (5xx) are propagated as-is. Deleting a non-existent queue is a no-op at the API level. + +**Import:** Users can import existing queues via `terraform import trunk_merge_queue.main "github.com/my-org/my-repo/main"` (format: `{host}/{owner}/{name}/{target_branch}`). + +**Architecture:** Standalone HTTP client in `internal/client/` separated from Terraform provider logic in `internal/provider/`. The client is independently testable and reusable. + +#### Resource Schema + +**Computed attributes (set by provider, not configurable):** + +| Attribute | Type | Description | +| --------- | ------ | ---------------------------------------------------------- | +| `id` | string | Unique identifier: `{host}/{owner}/{name}/{target_branch}` | + +**Required attributes (identity -- ForceNew):** + +| Attribute | Type | Description | +| --------------- | ------ | ------------------------------------ | +| `repo.host` | string | Repository host (e.g., "github.com") | +| `repo.owner` | string | Repository owner | +| `repo.name` | string | Repository name | +| `target_branch` | string | Target branch for the queue | + +**Optional + Computed attributes (configurable; API always returns a value):** + +All optional attributes are also `Computed: true` because the API always returns a value for every field in `getQueue` responses. This prevents perpetual plan diffs when the user omits a field that has an API-applied default. + +| Attribute | Type | API default | Description | +| --------------------------------- | ------ | ----------- | -------------------------------------------------- | +| `mode` | string | `"single"` | Queue mode: `"single"` or `"parallel"` | +| `concurrency` | int | `1` | Number of concurrent test slots | +| `state` | string | `"RUNNING"` | Queue state: `"RUNNING"`, `"PAUSED"`, `"DRAINING"` | +| `testing_timeout_minutes` | int | -- | Max minutes to wait for tests | +| `pending_failure_depth` | int | -- | PRs below a failure to wait for before eviction | +| `can_optimistically_merge` | bool | `false` | Optimistic merge when lower PR passes | +| `batch` | bool | `false` | Enable batching | +| `batching_max_wait_time_minutes` | int | -- | Max minutes to wait for batch to fill | +| `batching_min_size` | int | -- | Minimum PRs per batch | +| `merge_method` | string | -- | `"MERGE_COMMIT"`, `"SQUASH"`, `"REBASE"` | +| `comments_enabled` | bool | -- | Post GitHub comments on PRs | +| `commands_enabled` | bool | -- | Allow `/trunk merge` comments | +| `create_prs_for_testing_branches` | bool | -- | Create PRs for testing branches | +| `status_check_enabled` | bool | -- | Post GitHub status checks | +| `direct_merge_mode` | string | `"OFF"` | `"OFF"` or `"ALWAYS"` | +| `optimization_mode` | string | `"OFF"` | `"OFF"` or `"BISECTION_SKIP_REDUNDANT_TESTS"` | +| `bisection_concurrency` | int | -- | Concurrent tests during bisection | +| `required_statuses` | list | -- | Override required status checks | + +**Note on `required_statuses`:** This field distinguishes three states: + +- Omitted / `null` -- sends `deleteRequiredStatuses: true`, reverting to branch protection / trunk.yaml defaults +- `[]` (empty list) -- sends `requiredStatuses: []`, explicitly requiring no status checks +- `["ci/test"]` -- sends that list as the required statuses + +The underlying `UpdateQueueRequest.RequiredStatuses` uses `*[]string` (pointer-to-slice) so that a nil pointer is omitted via `omitempty` while an empty slice `&[]string{}` serialises as `[]`. + +### Infrastructure + +No cloud infrastructure needed. The provider is a standalone Go binary that communicates with the existing Trunk API at `https://api.trunk.io/v1`. + +**Registry publishing** requires: + +- GoReleaser for multi-platform builds (`linux_{amd64,arm64}`, `darwin_{amd64,arm64}`, `windows_amd64`) +- GPG signing key for binary verification +- GitHub Actions release workflow (future, not in MVP) + +### Configuration + +**Provider configuration:** + +| Attribute | Type | Required | Sensitive | Default | Description | +| ---------- | ------ | -------- | --------- | ------------------------- | -------------------------------------------------------------------------------------------------------------- | +| `api_key` | string | Yes\* | Yes | `TRUNK_API_KEY` env var | Org-level API token | +| `base_url` | string | No | No | `https://api.trunk.io/v1` | API base URL. Can also be set via `TRUNK_BASE_URL` env var. Use `https://api.trunk-staging.io/v1` for staging. | + +\*Required unless `TRUNK_API_KEY` environment variable is set. + +### Testing Strategy + +**Unit tests (client):** + +- Mock HTTP server (`httptest.NewServer`) for each API method +- Verify request serialization (URL, headers, body) and response deserialization +- Test error handling for non-2xx responses + +**Acceptance tests (provider):** + +- Use `terraform-plugin-testing` framework +- Gated by `TF_ACC=1` environment variable +- Require a real `TRUNK_API_KEY` and a test repository +- Test full lifecycle: create -> read -> update -> read -> destroy +- Test import: create externally, import, verify state matches + +### Security + +- API key marked `Sensitive: true` in Terraform schema (masked in logs/output) +- Environment variable fallback (`TRUNK_API_KEY`) avoids hardcoding secrets in HCL +- All API communication over HTTPS +- No secrets stored in state beyond what Terraform manages + +### Metrics & Analytics + +No custom metrics in the MVP. Usage is tracked implicitly via Trunk API request logs. + +### Documentation + +- `README.md` in the provider directory with usage instructions +- Example HCL configs in `examples/` +- Registry documentation auto-generated from schema descriptions + +## Files to Create/Modify + +- [x] `go/CLAUDE.md` -- Go project area conventions +- [x] `go/terraform-provider-trunk/CLAUDE.md` -- Provider conventions +- [x] `go/terraform-provider-trunk/main.go` -- Provider server entry point +- [x] `go/terraform-provider-trunk/go.mod` / `go.sum` -- Dependencies +- [x] `go/terraform-provider-trunk/.goreleaser.yml` -- Multi-platform build config +- [x] `go/terraform-provider-trunk/internal/provider/provider.go` -- Provider implementation +- [x] `.github/workflows/pr-go.yaml` -- CI workflow for Go +- [x] `go/terraform-provider-trunk/internal/client/types.go` -- Request/response structs +- [x] `go/terraform-provider-trunk/internal/client/client.go` -- HTTP client +- [x] `go/terraform-provider-trunk/internal/client/merge_queue.go` -- Queue API methods +- [x] `go/terraform-provider-trunk/internal/client/client_test.go` -- Client unit tests +- [x] `go/terraform-provider-trunk/internal/client/merge_queue_test.go` -- API method tests +- [x] `go/terraform-provider-trunk/internal/provider/merge_queue_resource.go` -- Resource CRUD +- [x] `go/terraform-provider-trunk/internal/provider/merge_queue_resource_model.go` -- Schema mapping +- [ ] `go/terraform-provider-trunk/internal/provider/merge_queue_resource_test.go` -- Acceptance tests +- [ ] `go/terraform-provider-trunk/internal/provider/provider_test.go` -- Provider tests +- [ ] `go/terraform-provider-trunk/examples/provider/main.tf` -- Provider example +- [ ] `go/terraform-provider-trunk/examples/resources/trunk_merge_queue/main.tf` -- Resource example +- [ ] `go/terraform-provider-trunk/README.md` -- Usage documentation + +## Tech Ladder + +### Checkpoint 1: Project scaffolding and CI + +- [x] Initialize Go module with terraform-plugin-framework dependencies +- [x] Create provider stub with auth schema +- [x] Configure GoReleaser for multi-platform builds +- [x] Add GitHub Actions PR workflow +- [x] Update trunk.yaml for Go tooling + +**Done when:** `go build ./...` compiles, `trunk check` passes, CI workflow triggers on Go changes. + +### Checkpoint 2: API client + +- [x] Implement HTTP client with auth and error handling +- [x] Implement CreateQueue, GetQueue, UpdateQueue, DeleteQueue methods +- [x] Write unit tests with mock HTTP server + +**Done when:** `go test ./internal/client/ -v` passes with full coverage of request/response serialization and error paths. + +### Checkpoint 3: Merge queue resource + +- [x] Implement resource schema with all attributes +- [x] Implement Create (createQueue + updateQueue two-step) +- [x] Implement Read, Update, Delete +- [x] Implement ImportState +- [x] Wire provider Configure to create client + +**Done when:** `go build ./...` compiles. Manual test with `dev_overrides` creates, updates, imports, and destroys a queue. + +### Checkpoint 4: Testing and documentation + +- [ ] Write acceptance tests (lifecycle, update, import) +- [ ] Create example HCL configs +- [ ] Write README with usage instructions + +**Done when:** `TF_ACC=1 go test ./internal/provider/ -v` passes. `terraform validate` passes on examples. + +## Verification + +1. `go build ./...` -- compiles successfully +2. `go test ./...` -- unit tests pass +3. `trunk check` and `trunk fmt` -- linting passes +4. Manual test with `dev_overrides`: + - Build binary locally + - Configure `.terraformrc` with dev override + - Run `terraform plan` and `terraform apply` against a test repo +5. `TF_ACC=1 go test ./internal/provider/ -v` -- acceptance tests pass From 6f041d2433b527876244d954c26b722af92c0a90 Mon Sep 17 00:00:00 2001 From: Phil Vendola Date: Mon, 6 Apr 2026 14:41:01 -0400 Subject: [PATCH 5/5] Pin commit --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d24d892..01a5aeb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,7 @@ jobs: go-version-file: go.mod cache: true - name: Import GPG key - uses: crazy-max/ghaction-import-gpg@v6 + uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd id: import_gpg with: gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}