Skip to content

Instantly share code, notes, and snippets.

@ned1313
Created June 1, 2026 15:19
Show Gist options
  • Select an option

  • Save ned1313/ebfe5e1c240f0dffdb3fc0bb33d56b56 to your computer and use it in GitHub Desktop.

Select an option

Save ned1313/ebfe5e1c240f0dffdb3fc0bb33d56b56 to your computer and use it in GitHub Desktop.
HCP Terraform Change Request Summary
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
}
#!/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