Created
June 1, 2026 15:19
-
-
Save ned1313/ebfe5e1c240f0dffdb3fc0bb33d56b56 to your computer and use it in GitHub Desktop.
HCP Terraform Change Request Summary
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| param( | |
| [Parameter(Mandatory = $true)] | |
| [string]$OrganizationName, | |
| [string]$CredentialsPath = "$HOME/.terraform.d/credentials.tfrc.json", | |
| [string]$Hostname = "app.terraform.io", | |
| [string]$ApiBaseUrl = "https://app.terraform.io/api/v2", | |
| [switch]$RawOutput, | |
| [switch]$JsonOutput, | |
| [string]$JsonPath, | |
| [string]$CsvPath | |
| ) | |
| Set-StrictMode -Version Latest | |
| $ErrorActionPreference = "Stop" | |
| function Get-HcpTerraformToken { | |
| param( | |
| [Parameter(Mandatory = $false)] | |
| [string]$Path, | |
| [Parameter(Mandatory = $true)] | |
| [string]$HostName | |
| ) | |
| # Check for token in environment variable first | |
| $envToken = [Environment]::GetEnvironmentVariable('TFE_TOKEN') | |
| if (-not [string]::IsNullOrWhiteSpace($envToken)) { | |
| return $envToken | |
| } | |
| # Fall back to credentials file if path provided | |
| if ([string]::IsNullOrWhiteSpace($Path)) { | |
| throw "TFE_TOKEN environment variable not set and no credentials path provided" | |
| } | |
| if (-not (Test-Path -Path $Path)) { | |
| throw "Terraform credentials file not found: $Path" | |
| } | |
| $content = Get-Content -Path $Path -Raw | ConvertFrom-Json | |
| if (-not $content.credentials) { | |
| throw "No credentials object found in: $Path" | |
| } | |
| if (-not $content.credentials.$HostName) { | |
| $availableHosts = ($content.credentials.PSObject.Properties.Name -join ", ") | |
| throw "No token for host '$HostName' in credentials file. Available hosts: $availableHosts" | |
| } | |
| $token = $content.credentials.$HostName.token | |
| if ([string]::IsNullOrWhiteSpace($token)) { | |
| throw "Token is empty for host '$HostName' in: $Path" | |
| } | |
| return $token | |
| } | |
| function Invoke-HcpApiGet { | |
| param( | |
| [Parameter(Mandatory = $true)] | |
| [string]$Uri, | |
| [Parameter(Mandatory = $true)] | |
| [hashtable]$Headers | |
| ) | |
| try { | |
| return Invoke-RestMethod -Method Get -Uri $Uri -Headers $Headers -ContentType "application/vnd.api+json" | |
| } | |
| catch { | |
| $message = "API request failed: GET $Uri" | |
| if ($_.Exception.Response) { | |
| try { | |
| $stream = $_.Exception.Response.GetResponseStream() | |
| $reader = New-Object System.IO.StreamReader($stream) | |
| $body = $reader.ReadToEnd() | |
| if (-not [string]::IsNullOrWhiteSpace($body)) { | |
| $message = "$message`nResponse body: $body" | |
| } | |
| } | |
| catch { | |
| # Keep original message if response body cannot be parsed. | |
| } | |
| } | |
| throw $message | |
| } | |
| } | |
| function Get-PaginatedData { | |
| param( | |
| [Parameter(Mandatory = $true)] | |
| [string]$BaseUri, | |
| [Parameter(Mandatory = $true)] | |
| [hashtable]$Headers, | |
| [int]$PageSize = 100 | |
| ) | |
| $all = @() | |
| $pageNumber = 1 | |
| while ($true) { | |
| $separator = if ($BaseUri.Contains("?")) { "&" } else { "?" } | |
| $uri = "$BaseUri$separator" + "page[number]=$pageNumber&page[size]=$PageSize" | |
| $response = Invoke-HcpApiGet -Uri $uri -Headers $Headers | |
| if ($response.data) { | |
| $all += @($response.data) | |
| } | |
| $nextPage = $null | |
| if ($response.meta -and $response.meta.pagination) { | |
| $nextPage = $response.meta.pagination.'next-page' | |
| } | |
| if (-not $nextPage) { | |
| break | |
| } | |
| $pageNumber = [int]$nextPage | |
| } | |
| return $all | |
| } | |
| function Get-OptionalProperty { | |
| param( | |
| [Parameter(Mandatory = $true)] | |
| [object]$Object, | |
| [Parameter(Mandatory = $true)] | |
| [string]$PropertyName | |
| ) | |
| if ($null -eq $Object) { | |
| return $null | |
| } | |
| $prop = $Object.PSObject.Properties[$PropertyName] | |
| if ($null -ne $prop) { | |
| return $prop.Value | |
| } | |
| return $null | |
| } | |
| $token = Get-HcpTerraformToken -Path $CredentialsPath -HostName $Hostname | |
| if ([string]::IsNullOrWhiteSpace($token)) { | |
| throw "Failed to obtain authentication token" | |
| } | |
| $headers = @{ | |
| Authorization = "Bearer $token" | |
| } | |
| $org = [System.Uri]::EscapeDataString($OrganizationName) | |
| $workspacesUri = "$ApiBaseUrl/organizations/$org/workspaces" | |
| Write-Host "Querying workspaces for organization '$OrganizationName'..." | |
| $workspaces = Get-PaginatedData -BaseUri $workspacesUri -Headers $headers | |
| Write-Host "Found $($workspaces.Count) workspace(s)." | |
| $results = @() | |
| foreach ($workspace in $workspaces) { | |
| $workspaceName = $workspace.attributes.name | |
| $workspaceId = $workspace.id | |
| $pendingCount = [int]($workspace.attributes.'unarchived-workspace-change-requests-count') | |
| if ($pendingCount -le 0) { | |
| continue | |
| } | |
| Write-Host "Workspace '$workspaceName' has $pendingCount unarchived change request(s)." | |
| $workspaceIdEncoded = [System.Uri]::EscapeDataString($workspaceId) | |
| $changeRequestsUri = "$ApiBaseUrl/workspaces/$workspaceIdEncoded/change-requests" | |
| $changeRequests = Get-PaginatedData -BaseUri $changeRequestsUri -Headers $headers | |
| foreach ($changeRequest in $changeRequests) { | |
| $changeRequestId = $changeRequest.id | |
| $changeRequestAttrs = Get-OptionalProperty -Object $changeRequest -PropertyName "attributes" | |
| $changeRequestIdEncoded = [System.Uri]::EscapeDataString($changeRequestId) | |
| $changeRequestUri = "$ApiBaseUrl/change-requests/$changeRequestIdEncoded" | |
| $detail = Invoke-HcpApiGet -Uri $changeRequestUri -Headers $headers | |
| $attrs = Get-OptionalProperty -Object $detail.data -PropertyName "attributes" | |
| if ($null -eq $attrs) { | |
| $attrs = $changeRequestAttrs | |
| } | |
| $relationships = Get-OptionalProperty -Object $detail.data -PropertyName "relationships" | |
| $relationshipWorkspace = Get-OptionalProperty -Object $relationships -PropertyName "workspace" | |
| $relationshipCreatedBy = Get-OptionalProperty -Object $relationships -PropertyName "created-by" | |
| $subject = Get-OptionalProperty -Object $attrs -PropertyName "subject" | |
| $message = Get-OptionalProperty -Object $attrs -PropertyName "message" | |
| $createdAt = Get-OptionalProperty -Object $attrs -PropertyName "created-at" | |
| $archivedAt = Get-OptionalProperty -Object $attrs -PropertyName "archived-at" | |
| $relationshipWorkspaceData = Get-OptionalProperty -Object $relationshipWorkspace -PropertyName "data" | |
| $relationshipWorkspaceId = Get-OptionalProperty -Object $relationshipWorkspaceData -PropertyName "id" | |
| $relationshipCreatedByData = Get-OptionalProperty -Object $relationshipCreatedBy -PropertyName "data" | |
| $relationshipCreatedById = Get-OptionalProperty -Object $relationshipCreatedByData -PropertyName "id" | |
| $results += [pscustomobject]@{ | |
| Workspace = if ($relationshipWorkspaceId) { $relationshipWorkspaceId } else { $workspaceName } | |
| Subject = $subject | |
| Message = $message | |
| CreatedAt = $createdAt | |
| ArchivedAt = $archivedAt | |
| CreatedBy = $relationshipCreatedById | |
| } | |
| } | |
| } | |
| if ($results.Count -eq 0) { | |
| Write-Host "No unarchived change requests found in any workspace." | |
| return | |
| } | |
| if ($CsvPath) { | |
| $results | Export-Csv -Path $CsvPath -NoTypeInformation | |
| Write-Host "CSV exported to: $CsvPath" | |
| } | |
| if ($JsonPath) { | |
| $results | ConvertTo-Json -Depth 10 | Set-Content -Path $JsonPath | |
| Write-Host "JSON exported to: $JsonPath" | |
| } | |
| if ($JsonOutput) { | |
| $results | ConvertTo-Json -Depth 10 | |
| return | |
| } | |
| if ($RawOutput) { | |
| $results | |
| } | |
| else { | |
| $results | | |
| Sort-Object Workspace, CreatedAt | | |
| Select-Object Workspace, Subject, Message, CreatedAt, ArchivedAt, CreatedBy | | |
| Format-Table -AutoSize | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/bash | |
| set -euo pipefail | |
| # Configuration | |
| ORGANIZATION_NAME="" | |
| CREDENTIALS_PATH="${HOME}/.terraform.d/credentials.tfrc.json" | |
| HOSTNAME="app.terraform.io" | |
| API_BASE_URL="https://app.terraform.io/api/v2" | |
| RAW_OUTPUT=false | |
| JSON_OUTPUT=false | |
| JSON_PATH="" | |
| CSV_PATH="" | |
| # Helper function to print usage | |
| usage() { | |
| cat <<EOF | |
| Usage: $(basename "$0") -o ORGANIZATION_NAME [options] | |
| Required: | |
| -o, --organization ORGANIZATION_NAME HCP Terraform organization name | |
| Options: | |
| -c, --credentials PATH Path to credentials file (default: ~/.terraform.d/credentials.tfrc.json) | |
| -h, --hostname HOSTNAME Terraform hostname (default: app.terraform.io) | |
| -a, --api-url URL API base URL (default: https://app.terraform.io/api/v2) | |
| --raw-output Output raw objects (one per line) | |
| --json-output Output results as JSON to stdout | |
| --json-path PATH Export results to JSON file | |
| --csv-path PATH Export results to CSV file | |
| --help Show this help message | |
| Examples: | |
| $(basename "$0") -o my-org | |
| $(basename "$0") -o my-org --json-output | |
| $(basename "$0") -o my-org --json-path results.json | |
| EOF | |
| exit 1 | |
| } | |
| # Parse command line arguments | |
| while [[ $# -gt 0 ]]; do | |
| case $1 in | |
| -o|--organization) | |
| ORGANIZATION_NAME="$2" | |
| shift 2 | |
| ;; | |
| -c|--credentials) | |
| CREDENTIALS_PATH="$2" | |
| shift 2 | |
| ;; | |
| -h|--hostname) | |
| HOSTNAME="$2" | |
| shift 2 | |
| ;; | |
| -a|--api-url) | |
| API_BASE_URL="$2" | |
| shift 2 | |
| ;; | |
| --raw-output) | |
| RAW_OUTPUT=true | |
| shift | |
| ;; | |
| --json-output) | |
| JSON_OUTPUT=true | |
| shift | |
| ;; | |
| --json-path) | |
| JSON_PATH="$2" | |
| shift 2 | |
| ;; | |
| --csv-path) | |
| CSV_PATH="$2" | |
| shift 2 | |
| ;; | |
| --help) | |
| usage | |
| ;; | |
| *) | |
| echo "Unknown option: $1" | |
| usage | |
| ;; | |
| esac | |
| done | |
| # Validate required parameters | |
| if [[ -z "$ORGANIZATION_NAME" ]]; then | |
| echo "Error: Organization name is required" | |
| usage | |
| fi | |
| # Validate dependencies | |
| for cmd in jq curl; do | |
| if ! command -v "$cmd" &> /dev/null; then | |
| echo "Error: $cmd is required but not installed" | |
| exit 1 | |
| fi | |
| done | |
| # Get HCP Terraform token from environment variable or credentials file | |
| get_token() { | |
| local path="$1" | |
| local hostname="$2" | |
| # Check for token in environment variable first | |
| if [[ -n "${TFE_TOKEN:-}" ]]; then | |
| echo "$TFE_TOKEN" | |
| return 0 | |
| fi | |
| # Fall back to credentials file if path provided | |
| if [[ -z "$path" ]]; then | |
| echo "Error: TFE_TOKEN environment variable not set and no credentials path provided" >&2 | |
| exit 1 | |
| fi | |
| if [[ ! -f "$path" ]]; then | |
| echo "Error: Terraform credentials file not found: $path" >&2 | |
| exit 1 | |
| fi | |
| local token | |
| token=$(jq -r ".credentials[\"$hostname\"].token" "$path" 2>/dev/null) | |
| if [[ -z "$token" || "$token" == "null" ]]; then | |
| echo "Error: No token found for host '$hostname' in credentials file" >&2 | |
| exit 1 | |
| fi | |
| echo "$token" | |
| } | |
| # Invoke HCP Terraform API with GET | |
| invoke_hcp_api_get() { | |
| local uri="$1" | |
| local token="$2" | |
| local response | |
| response=$(curl -s -w "\n%{http_code}" \ | |
| -H "Authorization: Bearer $token" \ | |
| -H "Content-Type: application/vnd.api+json" \ | |
| "$uri") | |
| local http_code | |
| http_code=$(echo "$response" | tail -n1) | |
| local body | |
| body=$(echo "$response" | head -n-1) | |
| if [[ "$http_code" != "200" ]]; then | |
| echo "Error: API request failed with status $http_code: GET $uri" >&2 | |
| echo "$body" >&2 | |
| exit 1 | |
| fi | |
| echo "$body" | |
| } | |
| # Get paginated data from API | |
| get_paginated_data() { | |
| local base_uri="$1" | |
| local token="$2" | |
| local page_size=100 | |
| local page_number=1 | |
| local all_data="[]" | |
| while true; do | |
| local separator="?" | |
| if [[ "$base_uri" == *"?"* ]]; then | |
| separator="&" | |
| fi | |
| local uri="${base_uri}${separator}page[number]=${page_number}&page[size]=${page_size}" | |
| local response | |
| response=$(invoke_hcp_api_get "$uri" "$token") | |
| local data | |
| data=$(echo "$response" | jq '.data') | |
| all_data=$(echo "$all_data" | jq --argjson new "$data" '. += $new') | |
| local next_page | |
| next_page=$(echo "$response" | jq -r '.meta.pagination."next-page" // empty') | |
| if [[ -z "$next_page" ]]; then | |
| break | |
| fi | |
| page_number="$next_page" | |
| done | |
| echo "$all_data" | |
| } | |
| # Get optional property from JSON object | |
| get_optional_property() { | |
| local object="$1" | |
| local property="$2" | |
| echo "$object" | jq -r ".$property // empty" | |
| } | |
| # Format output as CSV | |
| format_csv() { | |
| local data="$1" | |
| # CSV header | |
| echo "Workspace,Subject,Message,CreatedAt,ArchivedAt,CreatedBy" | |
| # CSV rows | |
| echo "$data" | jq -r '.[] | [.Workspace, .Subject, .Message, .CreatedAt, .ArchivedAt, .CreatedBy] | @csv' | |
| } | |
| # Main script | |
| echo "Querying workspaces for organization '$ORGANIZATION_NAME'..." >&2 | |
| TOKEN=$(get_token "$CREDENTIALS_PATH" "$HOSTNAME") | |
| WORKSPACES_URI="${API_BASE_URL}/organizations/$(echo -n "$ORGANIZATION_NAME" | jq -sRr @uri)/workspaces" | |
| WORKSPACES=$(get_paginated_data "$WORKSPACES_URI" "$TOKEN") | |
| WORKSPACE_COUNT=$(echo "$WORKSPACES" | jq 'length') | |
| echo "Found $WORKSPACE_COUNT workspace(s)." >&2 | |
| RESULTS="[]" | |
| while IFS= read -r workspace; do | |
| WORKSPACE_NAME=$(echo "$workspace" | jq -r '.attributes.name') | |
| WORKSPACE_ID=$(echo "$workspace" | jq -r '.id') | |
| PENDING_COUNT=$(echo "$workspace" | jq -r '.attributes."unarchived-workspace-change-requests-count" // 0') | |
| if (( PENDING_COUNT <= 0 )); then | |
| continue | |
| fi | |
| echo "Workspace '$WORKSPACE_NAME' has $PENDING_COUNT unarchived change request(s)." >&2 | |
| CHANGE_REQUESTS_URI="${API_BASE_URL}/workspaces/$(echo -n "$WORKSPACE_ID" | jq -sRr @uri)/change-requests" | |
| CHANGE_REQUESTS=$(get_paginated_data "$CHANGE_REQUESTS_URI" "$TOKEN") | |
| while IFS= read -r change_request; do | |
| CHANGE_REQUEST_ID=$(echo "$change_request" | jq -r '.id') | |
| CHANGE_REQUEST_URI="${API_BASE_URL}/change-requests/$(echo -n "$CHANGE_REQUEST_ID" | jq -sRr @uri)" | |
| DETAIL=$(invoke_hcp_api_get "$CHANGE_REQUEST_URI" "$TOKEN") | |
| DETAIL_DATA=$(echo "$DETAIL" | jq '.data') | |
| ATTRS=$(echo "$DETAIL_DATA" | jq '.attributes // empty') | |
| RELATIONSHIPS=$(echo "$DETAIL_DATA" | jq '.relationships // empty') | |
| SUBJECT=$(echo "$ATTRS" | jq -r '.subject // empty') | |
| MESSAGE=$(echo "$ATTRS" | jq -r '.message // empty') | |
| CREATED_AT=$(echo "$ATTRS" | jq -r '."created-at" // empty') | |
| ARCHIVED_AT=$(echo "$ATTRS" | jq -r '."archived-at" // empty') | |
| RELATIONSHIP_WORKSPACE=$(echo "$RELATIONSHIPS" | jq '.workspace // empty') | |
| RELATIONSHIP_CREATED_BY=$(echo "$RELATIONSHIPS" | jq '."created-by" // empty') | |
| WORKSPACE_REL_ID=$(echo "$RELATIONSHIP_WORKSPACE" | jq -r '.data.id // empty') | |
| CREATED_BY_REL_ID=$(echo "$RELATIONSHIP_CREATED_BY" | jq -r '.data.id // empty') | |
| FINAL_WORKSPACE="${WORKSPACE_REL_ID:-$WORKSPACE_NAME}" | |
| local result | |
| result=$(jq -n \ | |
| --arg workspace "$FINAL_WORKSPACE" \ | |
| --arg subject "$SUBJECT" \ | |
| --arg message "$MESSAGE" \ | |
| --arg created_at "$CREATED_AT" \ | |
| --arg archived_at "$ARCHIVED_AT" \ | |
| --arg created_by "$CREATED_BY_REL_ID" \ | |
| '{Workspace: $workspace, Subject: $subject, Message: $message, CreatedAt: $created_at, ArchivedAt: $archived_at, CreatedBy: $created_by}') | |
| RESULTS=$(echo "$RESULTS" | jq --argjson new "$result" '. += [$new]') | |
| done < <(echo "$CHANGE_REQUESTS" | jq -c '.[]') | |
| done < <(echo "$WORKSPACES" | jq -c '.[]') | |
| if [[ $(echo "$RESULTS" | jq 'length') -eq 0 ]]; then | |
| echo "No unarchived change requests found in any workspace." >&2 | |
| exit 0 | |
| fi | |
| # Handle CSV export | |
| if [[ -n "$CSV_PATH" ]]; then | |
| format_csv "$RESULTS" > "$CSV_PATH" | |
| echo "CSV exported to: $CSV_PATH" >&2 | |
| fi | |
| # Handle JSON file export | |
| if [[ -n "$JSON_PATH" ]]; then | |
| echo "$RESULTS" | jq '.' > "$JSON_PATH" | |
| echo "JSON exported to: $JSON_PATH" >&2 | |
| fi | |
| # Output results | |
| if [[ "$JSON_OUTPUT" == true ]]; then | |
| echo "$RESULTS" | jq '.' | |
| elif [[ "$RAW_OUTPUT" == true ]]; then | |
| echo "$RESULTS" | jq -c '.[]' | |
| else | |
| # Format as table | |
| echo "$RESULTS" | jq -r '["Workspace", "Subject", "Message", "CreatedAt", "ArchivedAt", "CreatedBy"], (.[] | [.Workspace, .Subject, .Message, .CreatedAt, .ArchivedAt, .CreatedBy]) | @csv' | column -t -s',' | |
| fi |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment