From b64e5fc12cb37d17dcdff05ea04b786058364f3c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Feb 2026 15:54:16 +0000 Subject: [PATCH 01/19] Initial plan From 4d76992cb07ea9e583a313d5680b6aacd408fad5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Feb 2026 15:56:19 +0000 Subject: [PATCH 02/19] Add change tracking functionality to Get-AzRetirementRecommendation Co-authored-by: cocallaw <11371083+cocallaw@users.noreply.github.com> --- Private/Get-AzRetirementHistory.ps1 | 29 +++++++ Private/New-AzRetirementSnapshot.ps1 | 55 ++++++++++++++ Private/Save-AzRetirementHistory.ps1 | 26 +++++++ Private/Show-AzRetirementComparison.ps1 | 93 +++++++++++++++++++++++ Public/Get-AzRetirementRecommendation.ps1 | 56 +++++++++++++- 5 files changed, 257 insertions(+), 2 deletions(-) create mode 100644 Private/Get-AzRetirementHistory.ps1 create mode 100644 Private/New-AzRetirementSnapshot.ps1 create mode 100644 Private/Save-AzRetirementHistory.ps1 create mode 100644 Private/Show-AzRetirementComparison.ps1 diff --git a/Private/Get-AzRetirementHistory.ps1 b/Private/Get-AzRetirementHistory.ps1 new file mode 100644 index 0000000..5fbdad4 --- /dev/null +++ b/Private/Get-AzRetirementHistory.ps1 @@ -0,0 +1,29 @@ +function Get-AzRetirementHistory { + <# + .SYNOPSIS + Loads the change tracking history from a JSON file + .PARAMETER Path + Path to the history JSON file + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Path + ) + + if (Test-Path -Path $Path) { + try { + $content = Get-Content -Path $Path -Raw | ConvertFrom-Json + Write-Verbose "Loaded history from: $Path" + return $content + } + catch { + Write-Warning "Failed to load history from $Path : $_" + return $null + } + } + else { + Write-Verbose "No existing history file found at: $Path" + return $null + } +} diff --git a/Private/New-AzRetirementSnapshot.ps1 b/Private/New-AzRetirementSnapshot.ps1 new file mode 100644 index 0000000..58ad2a0 --- /dev/null +++ b/Private/New-AzRetirementSnapshot.ps1 @@ -0,0 +1,55 @@ +function New-AzRetirementSnapshot { + <# + .SYNOPSIS + Creates a snapshot of current retirement recommendations for tracking + .PARAMETER Recommendations + Array of recommendation objects + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [object[]]$Recommendations + ) + + # Count by impact level + $impactCounts = @{ + High = 0 + Medium = 0 + Low = 0 + } + + # Count by resource type + $resourceTypeCounts = @{} + + # Track resource IDs + $resourceIds = @() + + foreach ($rec in $Recommendations) { + # Count by impact + if ($rec.Impact) { + $impactCounts[$rec.Impact]++ + } + + # Count by resource type + if ($rec.ResourceType) { + if (-not $resourceTypeCounts.ContainsKey($rec.ResourceType)) { + $resourceTypeCounts[$rec.ResourceType] = 0 + } + $resourceTypeCounts[$rec.ResourceType]++ + } + + # Track resource IDs + if ($rec.ResourceId) { + $resourceIds += $rec.ResourceId + } + } + + return [PSCustomObject]@{ + Timestamp = (Get-Date).ToString('o') + TotalCount = $Recommendations.Count + ImpactCounts = $impactCounts + ResourceTypeCounts = $resourceTypeCounts + ResourceIds = $resourceIds + } +} diff --git a/Private/Save-AzRetirementHistory.ps1 b/Private/Save-AzRetirementHistory.ps1 new file mode 100644 index 0000000..b962cb6 --- /dev/null +++ b/Private/Save-AzRetirementHistory.ps1 @@ -0,0 +1,26 @@ +function Save-AzRetirementHistory { + <# + .SYNOPSIS + Saves the change tracking history to a JSON file + .PARAMETER Path + Path to the history JSON file + .PARAMETER History + The history object to save + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Path, + + [Parameter(Mandatory)] + [PSCustomObject]$History + ) + + try { + $History | ConvertTo-Json -Depth 10 | Set-Content -Path $Path -Force + Write-Verbose "Saved history to: $Path" + } + catch { + Write-Warning "Failed to save history to $Path : $_" + } +} diff --git a/Private/Show-AzRetirementComparison.ps1 b/Private/Show-AzRetirementComparison.ps1 new file mode 100644 index 0000000..3c9a4e2 --- /dev/null +++ b/Private/Show-AzRetirementComparison.ps1 @@ -0,0 +1,93 @@ +function Show-AzRetirementComparison { + <# + .SYNOPSIS + Displays a comparison between current and previous snapshots + .PARAMETER CurrentSnapshot + Current snapshot object + .PARAMETER PreviousSnapshot + Previous snapshot object + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [PSCustomObject]$CurrentSnapshot, + + [Parameter()] + [PSCustomObject]$PreviousSnapshot + ) + + Write-Host "`n=== Azure Retirement Monitor - Change Tracking ===" -ForegroundColor Cyan + Write-Host "Current Run: $($CurrentSnapshot.Timestamp)" -ForegroundColor Gray + + if ($PreviousSnapshot) { + Write-Host "Previous Run: $($PreviousSnapshot.Timestamp)" -ForegroundColor Gray + + # Total count comparison + $totalChange = $CurrentSnapshot.TotalCount - $PreviousSnapshot.TotalCount + $totalChangeSymbol = if ($totalChange -gt 0) { "+" } elseif ($totalChange -lt 0) { "" } else { "" } + $totalChangeColor = if ($totalChange -gt 0) { "Red" } elseif ($totalChange -lt 0) { "Green" } else { "Gray" } + + Write-Host "`nTotal Recommendations: $($CurrentSnapshot.TotalCount) " -NoNewline + if ($totalChange -ne 0) { + Write-Host "($totalChangeSymbol$totalChange)" -ForegroundColor $totalChangeColor + } else { + Write-Host "(no change)" -ForegroundColor Gray + } + + # Impact level comparison + Write-Host "`nBy Impact Level:" -ForegroundColor Yellow + foreach ($impact in @('High', 'Medium', 'Low')) { + $current = $CurrentSnapshot.ImpactCounts.$impact + $previous = $PreviousSnapshot.ImpactCounts.$impact + $change = $current - $previous + $changeSymbol = if ($change -gt 0) { "+" } elseif ($change -lt 0) { "" } else { "" } + $changeColor = if ($change -gt 0) { "Red" } elseif ($change -lt 0) { "Green" } else { "Gray" } + + Write-Host " $impact : $current " -NoNewline + if ($change -ne 0) { + Write-Host "($changeSymbol$change)" -ForegroundColor $changeColor + } else { + Write-Host "(no change)" -ForegroundColor Gray + } + } + + # Resource changes + $currentResourceIds = $CurrentSnapshot.ResourceIds + $previousResourceIds = $PreviousSnapshot.ResourceIds + + $newResources = $currentResourceIds | Where-Object { $_ -notin $previousResourceIds } + $resolvedResources = $previousResourceIds | Where-Object { $_ -notin $currentResourceIds } + + if ($newResources.Count -gt 0 -or $resolvedResources.Count -gt 0) { + Write-Host "`nResource Changes:" -ForegroundColor Yellow + + if ($newResources.Count -gt 0) { + Write-Host " New Issues: $($newResources.Count)" -ForegroundColor Red + foreach ($resourceId in $newResources) { + $resourceName = ($resourceId -split "/")[-1] + Write-Host " + $resourceName" -ForegroundColor Red + } + } + + if ($resolvedResources.Count -gt 0) { + Write-Host " Resolved: $($resolvedResources.Count)" -ForegroundColor Green + foreach ($resourceId in $resolvedResources) { + $resourceName = ($resourceId -split "/")[-1] + Write-Host " - $resourceName" -ForegroundColor Green + } + } + } + } + else { + Write-Host "`nThis is the first run with change tracking enabled." -ForegroundColor Yellow + Write-Host "Total Recommendations: $($CurrentSnapshot.TotalCount)" -ForegroundColor Gray + + Write-Host "`nBy Impact Level:" -ForegroundColor Yellow + foreach ($impact in @('High', 'Medium', 'Low')) { + $count = $CurrentSnapshot.ImpactCounts.$impact + Write-Host " $impact : $count" -ForegroundColor Gray + } + } + + Write-Host "`n=================================================" -ForegroundColor Cyan +} diff --git a/Public/Get-AzRetirementRecommendation.ps1 b/Public/Get-AzRetirementRecommendation.ps1 index 0f4874d..4760f4d 100644 --- a/Public/Get-AzRetirementRecommendation.ps1 +++ b/Public/Get-AzRetirementRecommendation.ps1 @@ -18,6 +18,10 @@ The API method requires: One or more subscription IDs to query. Defaults to all subscriptions. .PARAMETER UseAPI Use the Azure REST API instead of Az.Advisor PowerShell module. Requires Connect-AzRetirementMonitor first. +.PARAMETER EnableChangeTracking +Enable change tracking to monitor progress over time. Saves snapshots to a JSON file and displays comparison with previous run. +.PARAMETER ChangeTrackingPath +Path to the JSON file for storing change tracking history. Defaults to AzRetirementMonitor-History.json in the current directory. .EXAMPLE Get-AzRetirementRecommendation Gets all retirement recommendations using Az.Advisor module (default) @@ -27,6 +31,12 @@ Gets recommendations for a specific subscription using Az.Advisor module .EXAMPLE Get-AzRetirementRecommendation -UseAPI Gets recommendations using the REST API method +.EXAMPLE +Get-AzRetirementRecommendation -EnableChangeTracking +Gets recommendations and tracks changes over time, saving to default history file +.EXAMPLE +Get-AzRetirementRecommendation -EnableChangeTracking -ChangeTrackingPath "C:\Reports\retirement-history.json" +Gets recommendations and tracks changes using a custom history file path #> [CmdletBinding()] param( @@ -34,7 +44,13 @@ Gets recommendations using the REST API method [string[]]$SubscriptionId, [Parameter()] - [switch]$UseAPI + [switch]$UseAPI, + + [Parameter()] + [switch]$EnableChangeTracking, + + [Parameter()] + [string]$ChangeTrackingPath = (Join-Path $PWD "AzRetirementMonitor-History.json") ) begin { @@ -348,6 +364,42 @@ Gets recommendations using the REST API method } end { - return $allRecommendations.ToArray() + $recommendations = $allRecommendations.ToArray() + + # Handle change tracking if enabled + if ($EnableChangeTracking) { + # Load previous history + $history = Get-AzRetirementHistory -Path $ChangeTrackingPath + + # Create snapshot of current run + $currentSnapshot = New-AzRetirementSnapshot -Recommendations $recommendations + + # Get the previous snapshot if history exists + $previousSnapshot = if ($history -and $history.Snapshots -and $history.Snapshots.Count -gt 0) { + $history.Snapshots[-1] + } else { + $null + } + + # Display comparison + Show-AzRetirementComparison -CurrentSnapshot $currentSnapshot -PreviousSnapshot $previousSnapshot + + # Update history with new snapshot + if ($history) { + # Add new snapshot to existing history + $history.Snapshots += $currentSnapshot + } else { + # Create new history object + $history = [PSCustomObject]@{ + Created = (Get-Date).ToString('o') + Snapshots = @($currentSnapshot) + } + } + + # Save updated history + Save-AzRetirementHistory -Path $ChangeTrackingPath -History $history + } + + return $recommendations } } \ No newline at end of file From 8426cb1d691bde8a3dc3abe2df4b2feec7e4645b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Feb 2026 15:58:06 +0000 Subject: [PATCH 03/19] Add tests and documentation for change tracking feature Co-authored-by: cocallaw <11371083+cocallaw@users.noreply.github.com> --- README.md | 95 +++++++++++++++++++++ Tests/AzRetirementMonitor.Tests.ps1 | 123 ++++++++++++++++++++++++++++ 2 files changed, 218 insertions(+) diff --git a/README.md b/README.md index 2b06a64..d7805f3 100644 --- a/README.md +++ b/README.md @@ -173,12 +173,66 @@ Get-AzRetirementRecommendation -SubscriptionId "sub-id-1", "sub-id-2" # Use REST API instead (requires Connect-AzRetirementMonitor -UsingAPI first) Get-AzRetirementRecommendation -UseAPI + +# Enable change tracking to monitor progress over time +Get-AzRetirementRecommendation -EnableChangeTracking + +# Enable change tracking with custom history file path +Get-AzRetirementRecommendation -EnableChangeTracking -ChangeTrackingPath "C:\Reports\retirement-history.json" ``` **Parameters:** - `SubscriptionId` - One or more subscription IDs (defaults to all subscriptions) - `UseAPI` - Use REST API instead of Az.Advisor module +- `EnableChangeTracking` - Enable change tracking to monitor progress over time +- `ChangeTrackingPath` - Path to the JSON file for storing change tracking history (defaults to `AzRetirementMonitor-History.json` in current directory) + +#### Change Tracking + +The change tracking feature helps you monitor your progress in addressing retirement recommendations over time. When enabled: + +- **Stores snapshots** of each run in a JSON file +- **Displays comparison** with the previous run in the console +- **Tracks changes** in total count, impact levels, and individual resources +- **Highlights progress** by showing resolved and new issues + +**Example output:** + +``` +=== Azure Retirement Monitor - Change Tracking === +Current Run: 2024-01-15T10:30:00Z +Previous Run: 2024-01-10T09:00:00Z + +Total Recommendations: 15 (-3) + +By Impact Level: + High : 5 (-1) + Medium : 7 (-2) + Low : 3 (no change) + +Resource Changes: + New Issues: 2 + + new-vm-01 + + new-storage-01 + Resolved: 5 + - old-vm-01 + - old-storage-01 + - legacy-app-01 + - test-db-01 + - deprecated-api-01 + +================================================= +``` + +The history file contains minimal data needed for tracking: +- Timestamp of each run +- Total count of recommendations +- Counts by impact level (High, Medium, Low) +- Counts by resource type +- List of resource IDs (to track which issues are resolved or new) + +This allows you to easily see your progress in cleaning up retirement issues over time without storing full recommendation details. ### Connect-AzRetirementMonitor @@ -320,6 +374,47 @@ $recommendations | Format-Table ResourceName, Impact, Problem, Solution -AutoSiz $recommendations | Export-AzRetirementReport -OutputPath "retirement-report.html" -Format HTML ``` +### Change Tracking Workflow + +Use change tracking to monitor your progress over time: + +```powershell +# 1. Authenticate to Azure +Connect-AzAccount + +# 2. First run - establish baseline with change tracking +Get-AzRetirementRecommendation -EnableChangeTracking + +# Output shows: +# === Azure Retirement Monitor - Change Tracking === +# This is the first run with change tracking enabled. +# Total Recommendations: 25 +# ... + +# 3. Address some issues in your Azure environment +# (migrate resources, update configurations, etc.) + +# 4. Run again to see progress +Get-AzRetirementRecommendation -EnableChangeTracking + +# Output shows: +# === Azure Retirement Monitor - Change Tracking === +# Total Recommendations: 18 (-7) +# By Impact Level: +# High : 5 (-3) +# Medium : 10 (-4) +# Resource Changes: +# Resolved: 7 +# - old-vm-01 +# - legacy-storage-01 +# ... + +# 5. Track over time with custom path +Get-AzRetirementRecommendation -EnableChangeTracking -ChangeTrackingPath "C:\AzureReports\retirement-tracking.json" +``` + +The history file is automatically updated with each run, allowing you to track progress without managing multiple files. + ### Alternative Workflow (API Method) ```powershell diff --git a/Tests/AzRetirementMonitor.Tests.ps1 b/Tests/AzRetirementMonitor.Tests.ps1 index cb49862..058fab1 100644 --- a/Tests/AzRetirementMonitor.Tests.ps1 +++ b/Tests/AzRetirementMonitor.Tests.ps1 @@ -679,4 +679,127 @@ Describe "Token Audience Validation" { $testResult = & $module { Test-AzRetirementMonitorToken } $testResult | Should -Be $false } +} + +Describe "Change Tracking Feature" { + BeforeAll { + # Create a temporary directory for test outputs + $script:TestTrackingDir = Join-Path ([System.IO.Path]::GetTempPath()) "AzRetirementChangeTracking_$([guid]::NewGuid())" + New-Item -Path $script:TestTrackingDir -ItemType Directory -Force | Out-Null + } + + AfterAll { + # Clean up test output directory + if (Test-Path $script:TestTrackingDir) { + Remove-Item -Path $script:TestTrackingDir -Recurse -Force -ErrorAction SilentlyContinue + } + } + + Context "Parameter Validation" { + It "Should have EnableChangeTracking parameter" { + $cmd = Get-Command Get-AzRetirementRecommendation + $cmd.Parameters.ContainsKey('EnableChangeTracking') | Should -Be $true + } + + It "Should have ChangeTrackingPath parameter" { + $cmd = Get-Command Get-AzRetirementRecommendation + $cmd.Parameters.ContainsKey('ChangeTrackingPath') | Should -Be $true + } + + It "ChangeTrackingPath should have a default value" { + $cmd = Get-Command Get-AzRetirementRecommendation + $param = $cmd.Parameters['ChangeTrackingPath'] + $param.Attributes.TypeId.Name | Should -Contain 'ParameterAttribute' + } + } + + Context "Helper Functions" { + It "New-AzRetirementSnapshot should create a snapshot" { + $testRecs = @( + [PSCustomObject]@{ + ResourceId = "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Compute/virtualMachines/vm1" + ResourceType = "Microsoft.Compute/virtualMachines" + Impact = "High" + }, + [PSCustomObject]@{ + ResourceId = "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Storage/storageAccounts/sa1" + ResourceType = "Microsoft.Storage/storageAccounts" + Impact = "Medium" + } + ) + + $module = Get-Module AzRetirementMonitor + $snapshot = & $module { param($recs) New-AzRetirementSnapshot -Recommendations $recs } $testRecs + + $snapshot.TotalCount | Should -Be 2 + $snapshot.ImpactCounts.High | Should -Be 1 + $snapshot.ImpactCounts.Medium | Should -Be 1 + $snapshot.ResourceIds.Count | Should -Be 2 + } + + It "New-AzRetirementSnapshot should handle empty recommendations" { + $module = Get-Module AzRetirementMonitor + $snapshot = & $module { New-AzRetirementSnapshot -Recommendations @() } + + $snapshot.TotalCount | Should -Be 0 + $snapshot.ImpactCounts.High | Should -Be 0 + } + + It "Save-AzRetirementHistory should create a JSON file" { + $testPath = Join-Path $script:TestTrackingDir "test-history.json" + $testHistory = [PSCustomObject]@{ + Created = (Get-Date).ToString('o') + Snapshots = @( + [PSCustomObject]@{ + Timestamp = (Get-Date).ToString('o') + TotalCount = 5 + ImpactCounts = @{High = 2; Medium = 2; Low = 1} + ResourceTypeCounts = @{} + ResourceIds = @() + } + ) + } + + $module = Get-Module AzRetirementMonitor + & $module { param($p, $h) Save-AzRetirementHistory -Path $p -History $h } $testPath $testHistory + + Test-Path $testPath | Should -Be $true + + $saved = Get-Content $testPath -Raw | ConvertFrom-Json + $saved.Snapshots[0].TotalCount | Should -Be 5 + } + + It "Get-AzRetirementHistory should load existing history" { + $testPath = Join-Path $script:TestTrackingDir "test-load-history.json" + $testHistory = [PSCustomObject]@{ + Created = (Get-Date).ToString('o') + Snapshots = @( + [PSCustomObject]@{ + Timestamp = (Get-Date).ToString('o') + TotalCount = 3 + ImpactCounts = @{High = 1; Medium = 1; Low = 1} + ResourceTypeCounts = @{} + ResourceIds = @() + } + ) + } + + $testHistory | ConvertTo-Json -Depth 10 | Set-Content -Path $testPath + + $module = Get-Module AzRetirementMonitor + $loaded = & $module { param($p) Get-AzRetirementHistory -Path $p } $testPath + + $loaded | Should -Not -BeNull + $loaded.Snapshots[0].TotalCount | Should -Be 3 + } + + It "Get-AzRetirementHistory should return null for non-existent file" { + $testPath = Join-Path $script:TestTrackingDir "non-existent.json" + + $module = Get-Module AzRetirementMonitor + $loaded = & $module { param($p) Get-AzRetirementHistory -Path $p } $testPath + + $loaded | Should -BeNull + } + } } \ No newline at end of file From 1cd1d44a4227a1cd40a85d009ad663b61fe22982 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Feb 2026 15:59:14 +0000 Subject: [PATCH 04/19] Add history file to .gitignore and verify tests Co-authored-by: cocallaw <11371083+cocallaw@users.noreply.github.com> --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index e43b0f9..cb50e10 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .DS_Store +AzRetirementMonitor-History.json From fcda5474d78e3e04f3ff8528acd44d8673bf1072 Mon Sep 17 00:00:00 2001 From: Corey Callaway Date: Sun, 15 Feb 2026 13:58:52 -0500 Subject: [PATCH 05/19] Update Show-AzRetirementComparison.ps1 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Private/Show-AzRetirementComparison.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Private/Show-AzRetirementComparison.ps1 b/Private/Show-AzRetirementComparison.ps1 index 3c9a4e2..a761bc2 100644 --- a/Private/Show-AzRetirementComparison.ps1 +++ b/Private/Show-AzRetirementComparison.ps1 @@ -52,8 +52,8 @@ function Show-AzRetirementComparison { } # Resource changes - $currentResourceIds = $CurrentSnapshot.ResourceIds - $previousResourceIds = $PreviousSnapshot.ResourceIds + $currentResourceIds = @($CurrentSnapshot.ResourceIds) + $previousResourceIds = @($PreviousSnapshot.ResourceIds) $newResources = $currentResourceIds | Where-Object { $_ -notin $previousResourceIds } $resolvedResources = $previousResourceIds | Where-Object { $_ -notin $currentResourceIds } From 6fc1f74ced5f914e1497af25df4fd9eb43097c7b Mon Sep 17 00:00:00 2001 From: Corey Callaway Date: Sun, 15 Feb 2026 13:59:12 -0500 Subject: [PATCH 06/19] Update New-AzRetirementSnapshot.ps1 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Private/New-AzRetirementSnapshot.ps1 | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Private/New-AzRetirementSnapshot.ps1 b/Private/New-AzRetirementSnapshot.ps1 index 58ad2a0..d6a7d65 100644 --- a/Private/New-AzRetirementSnapshot.ps1 +++ b/Private/New-AzRetirementSnapshot.ps1 @@ -28,7 +28,12 @@ function New-AzRetirementSnapshot { foreach ($rec in $Recommendations) { # Count by impact if ($rec.Impact) { - $impactCounts[$rec.Impact]++ + if ($impactCounts.ContainsKey($rec.Impact)) { + $impactCounts[$rec.Impact]++ + } + else { + Write-Warning "New-AzRetirementSnapshot: Unexpected Impact value '$($rec.Impact)' encountered. This value will be ignored in impact counts." + } } # Count by resource type From 5e0294ff3ab91b5d85e0e3ddc8514452e6a2151b Mon Sep 17 00:00:00 2001 From: Corey Callaway Date: Sun, 15 Feb 2026 13:59:39 -0500 Subject: [PATCH 07/19] Update Save-AzRetirementHistory.ps1 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Private/Save-AzRetirementHistory.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Private/Save-AzRetirementHistory.ps1 b/Private/Save-AzRetirementHistory.ps1 index b962cb6..23066ee 100644 --- a/Private/Save-AzRetirementHistory.ps1 +++ b/Private/Save-AzRetirementHistory.ps1 @@ -17,7 +17,7 @@ function Save-AzRetirementHistory { ) try { - $History | ConvertTo-Json -Depth 10 | Set-Content -Path $Path -Force + $History | ConvertTo-Json -Depth 10 | Set-Content -Path $Path -Encoding utf8 -Force Write-Verbose "Saved history to: $Path" } catch { From 998ba5c2c85aaac295d981d220716a874b27aa06 Mon Sep 17 00:00:00 2001 From: Corey Callaway Date: Sun, 15 Feb 2026 13:59:53 -0500 Subject: [PATCH 08/19] Update Get-AzRetirementHistory.ps1 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Private/Get-AzRetirementHistory.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Private/Get-AzRetirementHistory.ps1 b/Private/Get-AzRetirementHistory.ps1 index 5fbdad4..a896919 100644 --- a/Private/Get-AzRetirementHistory.ps1 +++ b/Private/Get-AzRetirementHistory.ps1 @@ -13,7 +13,7 @@ function Get-AzRetirementHistory { if (Test-Path -Path $Path) { try { - $content = Get-Content -Path $Path -Raw | ConvertFrom-Json + $content = Get-Content -Path $Path -Raw -Encoding utf8 | ConvertFrom-Json Write-Verbose "Loaded history from: $Path" return $content } From d30d2ff2f194ba0118fd5fd6270950ab533c2be9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Feb 2026 19:03:40 +0000 Subject: [PATCH 09/19] Address remaining code review feedback: improve performance and robustness Co-authored-by: cocallaw <11371083+cocallaw@users.noreply.github.com> --- Private/New-AzRetirementSnapshot.ps1 | 11 ++++++----- Private/Show-AzRetirementComparison.ps1 | 16 ++++++++++++---- Public/Get-AzRetirementRecommendation.ps1 | 13 +++++++++---- README.md | 2 +- 4 files changed, 28 insertions(+), 14 deletions(-) diff --git a/Private/New-AzRetirementSnapshot.ps1 b/Private/New-AzRetirementSnapshot.ps1 index d6a7d65..ae8985c 100644 --- a/Private/New-AzRetirementSnapshot.ps1 +++ b/Private/New-AzRetirementSnapshot.ps1 @@ -22,8 +22,8 @@ function New-AzRetirementSnapshot { # Count by resource type $resourceTypeCounts = @{} - # Track resource IDs - $resourceIds = @() + # Track resource IDs - using List for better performance + $resourceIds = [System.Collections.Generic.List[string]]::new() foreach ($rec in $Recommendations) { # Count by impact @@ -32,7 +32,8 @@ function New-AzRetirementSnapshot { $impactCounts[$rec.Impact]++ } else { - Write-Warning "New-AzRetirementSnapshot: Unexpected Impact value '$($rec.Impact)' encountered. This value will be ignored in impact counts." + Write-Warning "New-AzRetirementSnapshot: Unexpected Impact value '$($rec.Impact)' encountered. This value will be tracked but may not display correctly." + $impactCounts[$rec.Impact] = 1 } } @@ -46,7 +47,7 @@ function New-AzRetirementSnapshot { # Track resource IDs if ($rec.ResourceId) { - $resourceIds += $rec.ResourceId + $resourceIds.Add($rec.ResourceId) } } @@ -55,6 +56,6 @@ function New-AzRetirementSnapshot { TotalCount = $Recommendations.Count ImpactCounts = $impactCounts ResourceTypeCounts = $resourceTypeCounts - ResourceIds = $resourceIds + ResourceIds = $resourceIds.ToArray() } } diff --git a/Private/Show-AzRetirementComparison.ps1 b/Private/Show-AzRetirementComparison.ps1 index a761bc2..a2707ff 100644 --- a/Private/Show-AzRetirementComparison.ps1 +++ b/Private/Show-AzRetirementComparison.ps1 @@ -37,8 +37,8 @@ function Show-AzRetirementComparison { # Impact level comparison Write-Host "`nBy Impact Level:" -ForegroundColor Yellow foreach ($impact in @('High', 'Medium', 'Low')) { - $current = $CurrentSnapshot.ImpactCounts.$impact - $previous = $PreviousSnapshot.ImpactCounts.$impact + $current = if ($CurrentSnapshot.ImpactCounts.ContainsKey($impact)) { $CurrentSnapshot.ImpactCounts.$impact } else { 0 } + $previous = if ($PreviousSnapshot.ImpactCounts.ContainsKey($impact)) { $PreviousSnapshot.ImpactCounts.$impact } else { 0 } $change = $current - $previous $changeSymbol = if ($change -gt 0) { "+" } elseif ($change -lt 0) { "" } else { "" } $changeColor = if ($change -gt 0) { "Red" } elseif ($change -lt 0) { "Green" } else { "Gray" } @@ -64,7 +64,11 @@ function Show-AzRetirementComparison { if ($newResources.Count -gt 0) { Write-Host " New Issues: $($newResources.Count)" -ForegroundColor Red foreach ($resourceId in $newResources) { - $resourceName = ($resourceId -split "/")[-1] + if (-not [string]::IsNullOrWhiteSpace($resourceId) -and $resourceId.Contains("/")) { + $resourceName = ($resourceId -split "/")[-1] + } else { + $resourceName = $resourceId + } Write-Host " + $resourceName" -ForegroundColor Red } } @@ -72,7 +76,11 @@ function Show-AzRetirementComparison { if ($resolvedResources.Count -gt 0) { Write-Host " Resolved: $($resolvedResources.Count)" -ForegroundColor Green foreach ($resourceId in $resolvedResources) { - $resourceName = ($resourceId -split "/")[-1] + if (-not [string]::IsNullOrWhiteSpace($resourceId) -and $resourceId.Contains("/")) { + $resourceName = ($resourceId -split "/")[-1] + } else { + $resourceName = $resourceId + } Write-Host " - $resourceName" -ForegroundColor Green } } diff --git a/Public/Get-AzRetirementRecommendation.ps1 b/Public/Get-AzRetirementRecommendation.ps1 index 4760f4d..90efcdf 100644 --- a/Public/Get-AzRetirementRecommendation.ps1 +++ b/Public/Get-AzRetirementRecommendation.ps1 @@ -21,7 +21,7 @@ Use the Azure REST API instead of Az.Advisor PowerShell module. Requires Connect .PARAMETER EnableChangeTracking Enable change tracking to monitor progress over time. Saves snapshots to a JSON file and displays comparison with previous run. .PARAMETER ChangeTrackingPath -Path to the JSON file for storing change tracking history. Defaults to AzRetirementMonitor-History.json in the current directory. +Path to the JSON file for storing change tracking history. Defaults to AzRetirementMonitor-History.json in the user's home directory. .EXAMPLE Get-AzRetirementRecommendation Gets all retirement recommendations using Az.Advisor module (default) @@ -50,7 +50,7 @@ Gets recommendations and tracks changes using a custom history file path [switch]$EnableChangeTracking, [Parameter()] - [string]$ChangeTrackingPath = (Join-Path $PWD "AzRetirementMonitor-History.json") + [string]$ChangeTrackingPath = (Join-Path $HOME "AzRetirementMonitor-History.json") ) begin { @@ -386,8 +386,13 @@ Gets recommendations and tracks changes using a custom history file path # Update history with new snapshot if ($history) { - # Add new snapshot to existing history - $history.Snapshots += $currentSnapshot + # Add new snapshot to existing history without repeatedly reallocating arrays + $snapshotsList = [System.Collections.Generic.List[object]]::new() + if ($history.Snapshots) { + $snapshotsList.AddRange([object[]]$history.Snapshots) + } + $snapshotsList.Add($currentSnapshot) + $history.Snapshots = $snapshotsList.ToArray() } else { # Create new history object $history = [PSCustomObject]@{ diff --git a/README.md b/README.md index d7805f3..bad204a 100644 --- a/README.md +++ b/README.md @@ -186,7 +186,7 @@ Get-AzRetirementRecommendation -EnableChangeTracking -ChangeTrackingPath "C:\Rep - `SubscriptionId` - One or more subscription IDs (defaults to all subscriptions) - `UseAPI` - Use REST API instead of Az.Advisor module - `EnableChangeTracking` - Enable change tracking to monitor progress over time -- `ChangeTrackingPath` - Path to the JSON file for storing change tracking history (defaults to `AzRetirementMonitor-History.json` in current directory) +- `ChangeTrackingPath` - Path to the JSON file for storing change tracking history (defaults to `AzRetirementMonitor-History.json` in user's home directory) #### Change Tracking From b163bdefcd6d6719c8450a491aa5519ae992690e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Feb 2026 23:02:29 +0000 Subject: [PATCH 10/19] Add comprehensive test coverage for Show-AzRetirementComparison function Co-authored-by: cocallaw <11371083+cocallaw@users.noreply.github.com> --- Tests/AzRetirementMonitor.Tests.ps1 | 129 ++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/Tests/AzRetirementMonitor.Tests.ps1 b/Tests/AzRetirementMonitor.Tests.ps1 index 058fab1..7a533b3 100644 --- a/Tests/AzRetirementMonitor.Tests.ps1 +++ b/Tests/AzRetirementMonitor.Tests.ps1 @@ -681,6 +681,135 @@ Describe "Token Audience Validation" { } } +Describe "Show-AzRetirementComparison Function Coverage" { + BeforeAll { + $module = Get-Module AzRetirementMonitor + } + + Context "First run scenario (no previous snapshot)" { + It "Should handle first run without errors" { + $currentSnapshot = [PSCustomObject]@{ + Timestamp = (Get-Date).ToString('o') + TotalCount = 5 + ImpactCounts = @{High = 2; Medium = 2; Low = 1} + ResourceIds = @() + } + + { & $module { param($c, $p) Show-AzRetirementComparison -CurrentSnapshot $c -PreviousSnapshot $p } $currentSnapshot $null } | Should -Not -Throw + } + } + + Context "Comparison with previous snapshot" { + It "Should handle no changes between runs" { + $snapshot1 = [PSCustomObject]@{ + Timestamp = (Get-Date).AddDays(-1).ToString('o') + TotalCount = 3 + ImpactCounts = @{High = 1; Medium = 1; Low = 1} + ResourceIds = @('/sub/rg/vm1', '/sub/rg/vm2', '/sub/rg/vm3') + } + + $snapshot2 = [PSCustomObject]@{ + Timestamp = (Get-Date).ToString('o') + TotalCount = 3 + ImpactCounts = @{High = 1; Medium = 1; Low = 1} + ResourceIds = @('/sub/rg/vm1', '/sub/rg/vm2', '/sub/rg/vm3') + } + + { & $module { param($c, $p) Show-AzRetirementComparison -CurrentSnapshot $c -PreviousSnapshot $p } $snapshot2 $snapshot1 } | Should -Not -Throw + } + + It "Should handle changes in counts" { + $snapshot1 = [PSCustomObject]@{ + Timestamp = (Get-Date).AddDays(-1).ToString('o') + TotalCount = 5 + ImpactCounts = @{High = 2; Medium = 2; Low = 1} + ResourceIds = @() + } + + $snapshot2 = [PSCustomObject]@{ + Timestamp = (Get-Date).ToString('o') + TotalCount = 3 + ImpactCounts = @{High = 1; Medium = 1; Low = 1} + ResourceIds = @() + } + + { & $module { param($c, $p) Show-AzRetirementComparison -CurrentSnapshot $c -PreviousSnapshot $p } $snapshot2 $snapshot1 } | Should -Not -Throw + } + + It "Should handle new resources" { + $snapshot1 = [PSCustomObject]@{ + Timestamp = (Get-Date).AddDays(-1).ToString('o') + TotalCount = 2 + ImpactCounts = @{High = 1; Medium = 1; Low = 0} + ResourceIds = @('/sub/rg/vm1', '/sub/rg/vm2') + } + + $snapshot2 = [PSCustomObject]@{ + Timestamp = (Get-Date).ToString('o') + TotalCount = 3 + ImpactCounts = @{High = 2; Medium = 1; Low = 0} + ResourceIds = @('/sub/rg/vm1', '/sub/rg/vm2', '/sub/rg/vm3') + } + + { & $module { param($c, $p) Show-AzRetirementComparison -CurrentSnapshot $c -PreviousSnapshot $p } $snapshot2 $snapshot1 } | Should -Not -Throw + } + + It "Should handle resolved resources" { + $snapshot1 = [PSCustomObject]@{ + Timestamp = (Get-Date).AddDays(-1).ToString('o') + TotalCount = 3 + ImpactCounts = @{High = 2; Medium = 1; Low = 0} + ResourceIds = @('/sub/rg/vm1', '/sub/rg/vm2', '/sub/rg/vm3') + } + + $snapshot2 = [PSCustomObject]@{ + Timestamp = (Get-Date).ToString('o') + TotalCount = 1 + ImpactCounts = @{High = 1; Medium = 0; Low = 0} + ResourceIds = @('/sub/rg/vm1') + } + + { & $module { param($c, $p) Show-AzRetirementComparison -CurrentSnapshot $c -PreviousSnapshot $p } $snapshot2 $snapshot1 } | Should -Not -Throw + } + + It "Should handle missing impact keys in previous snapshot" { + $snapshot1 = [PSCustomObject]@{ + Timestamp = (Get-Date).AddDays(-1).ToString('o') + TotalCount = 2 + ImpactCounts = @{High = 2} # Missing Medium and Low + ResourceIds = @() + } + + $snapshot2 = [PSCustomObject]@{ + Timestamp = (Get-Date).ToString('o') + TotalCount = 3 + ImpactCounts = @{High = 1; Medium = 1; Low = 1} + ResourceIds = @() + } + + { & $module { param($c, $p) Show-AzRetirementComparison -CurrentSnapshot $c -PreviousSnapshot $p } $snapshot2 $snapshot1 } | Should -Not -Throw + } + + It "Should handle edge case resource IDs" { + $snapshot1 = [PSCustomObject]@{ + Timestamp = (Get-Date).AddDays(-1).ToString('o') + TotalCount = 3 + ImpactCounts = @{High = 1; Medium = 1; Low = 1} + ResourceIds = @('/sub/rg/vm1', 'simple-name', '') + } + + $snapshot2 = [PSCustomObject]@{ + Timestamp = (Get-Date).ToString('o') + TotalCount = 2 + ImpactCounts = @{High = 1; Medium = 1; Low = 0} + ResourceIds = @('/sub/rg/vm1', 'another-simple') + } + + { & $module { param($c, $p) Show-AzRetirementComparison -CurrentSnapshot $c -PreviousSnapshot $p } $snapshot2 $snapshot1 } | Should -Not -Throw + } + } +} + Describe "Change Tracking Feature" { BeforeAll { # Create a temporary directory for test outputs From 48c1f2375f28d6d5acae6719f40216b47526b0fb Mon Sep 17 00:00:00 2001 From: Corey Callaway Date: Sat, 28 Feb 2026 17:46:15 -0500 Subject: [PATCH 11/19] Add tests for snapshot JSON deserialization Add two regression tests covering JSON round-trip edge cases when loading retirement snapshot history. The first ensures Show-AzRetirementComparison handles ImpactCounts deserialized as PSCustomObject (not hashtable) and does not throw. The second verifies @() normalization for single-snapshot history files (PS 5.1 ConvertFrom-Json behavior), ensuring the snapshots array, count and negative indexing work after reload. --- Tests/AzRetirementMonitor.Tests.ps1 | 57 +++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/Tests/AzRetirementMonitor.Tests.ps1 b/Tests/AzRetirementMonitor.Tests.ps1 index 7a533b3..37b7c88 100644 --- a/Tests/AzRetirementMonitor.Tests.ps1 +++ b/Tests/AzRetirementMonitor.Tests.ps1 @@ -930,5 +930,62 @@ Describe "Change Tracking Feature" { $loaded | Should -BeNull } + + It "Show-AzRetirementComparison should handle ImpactCounts as PSCustomObject after JSON deserialization" { + # Regression test: ImpactCounts loaded from JSON becomes PSCustomObject, not hashtable. + # The comparison should not throw or return wrong values. + $testPath = Join-Path $script:TestTrackingDir "roundtrip-impactcounts-test.json" + $module = Get-Module AzRetirementMonitor + + # Build a snapshot and persist it + $testRecs = @( + [PSCustomObject]@{ ResourceId = '/sub/rg/vm1'; ResourceType = 'Microsoft.Compute/virtualMachines'; Impact = 'High' }, + [PSCustomObject]@{ ResourceId = '/sub/rg/sa1'; ResourceType = 'Microsoft.Storage/storageAccounts'; Impact = 'Medium' } + ) + $snapshot = & $module { param($r) New-AzRetirementSnapshot -Recommendations $r } $testRecs + $historyToSave = [PSCustomObject]@{ + Created = (Get-Date).ToString('o') + Snapshots = @($snapshot) + } + & $module { param($p, $h) Save-AzRetirementHistory -Path $p -History $h } $testPath $historyToSave + + # Reload; ImpactCounts is now PSCustomObject, not hashtable + $loadedHistory = & $module { param($p) Get-AzRetirementHistory -Path $p } $testPath + $deserializedSnapshot = @($loadedHistory.Snapshots)[0] + + # Create a fresh current snapshot (ImpactCounts is a hashtable) + $newSnapshot = & $module { New-AzRetirementSnapshot -Recommendations @() } + + # Must not throw even though PreviousSnapshot.ImpactCounts is a PSCustomObject + { & $module { param($c, $p) Show-AzRetirementComparison -CurrentSnapshot $c -PreviousSnapshot $p } $newSnapshot $deserializedSnapshot } | Should -Not -Throw + } + + It "Should correctly read previous snapshot from single-snapshot history file" { + # Regression test: PS 5.1 ConvertFrom-Json may deserialise a single-element JSON array + # as a bare object rather than an array. @() normalisation must handle this. + $testPath = Join-Path $script:TestTrackingDir "single-snapshot-roundtrip-test.json" + $module = Get-Module AzRetirementMonitor + + $testRecs = @( + [PSCustomObject]@{ ResourceId = '/sub/rg/vm1'; ResourceType = 'Microsoft.Compute/virtualMachines'; Impact = 'High' } + ) + $snapshot = & $module { param($r) New-AzRetirementSnapshot -Recommendations $r } $testRecs + $historyToSave = [PSCustomObject]@{ + Created = (Get-Date).ToString('o') + Snapshots = @($snapshot) + } + & $module { param($p, $h) Save-AzRetirementHistory -Path $p -History $h } $testPath $historyToSave + + # Reload history + $loadedHistory = & $module { param($p) Get-AzRetirementHistory -Path $p } $testPath + + # @() normalisation must yield exactly one snapshot + $snapshotsArray = @($loadedHistory.Snapshots) + $snapshotsArray.Count | Should -Be 1 + $snapshotsArray[0].TotalCount | Should -Be 1 + + # Accessing the last element must work correctly + $snapshotsArray[-1].TotalCount | Should -Be 1 + } } } \ No newline at end of file From afd5a96f72f72da5778cbbb6a909eeaedfbea862 Mon Sep 17 00:00:00 2001 From: Corey Callaway Date: Sat, 28 Feb 2026 17:46:54 -0500 Subject: [PATCH 12/19] Handle PS 5.1 JSON formats for snapshots Make snapshot and ImpactCounts handling robust across PowerShell versions. Show-AzRetirementComparison now detects whether ImpactCounts is a hashtable or PSCustomObject and accesses values accordingly (casting nulls to 0). Get-AzRetirementRecommendation normalizes history.Snapshots with @() to handle ConvertFrom-Json returning a bare PSCustomObject in PS 5.1, and uses a List plus AddRange(@(...)) to safely append the current snapshot. These changes fix incorrect counts and index errors when loading history from JSON. --- Private/Show-AzRetirementComparison.ps1 | 14 ++++++++++++-- Public/Get-AzRetirementRecommendation.ps1 | 13 +++++++++---- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/Private/Show-AzRetirementComparison.ps1 b/Private/Show-AzRetirementComparison.ps1 index a2707ff..961f12f 100644 --- a/Private/Show-AzRetirementComparison.ps1 +++ b/Private/Show-AzRetirementComparison.ps1 @@ -37,8 +37,18 @@ function Show-AzRetirementComparison { # Impact level comparison Write-Host "`nBy Impact Level:" -ForegroundColor Yellow foreach ($impact in @('High', 'Medium', 'Low')) { - $current = if ($CurrentSnapshot.ImpactCounts.ContainsKey($impact)) { $CurrentSnapshot.ImpactCounts.$impact } else { 0 } - $previous = if ($PreviousSnapshot.ImpactCounts.ContainsKey($impact)) { $PreviousSnapshot.ImpactCounts.$impact } else { 0 } + # ImpactCounts may be a hashtable (freshly created) or PSCustomObject (loaded from JSON). + # Use -is [hashtable] to select the correct access method for both cases. + $current = if ($CurrentSnapshot.ImpactCounts -is [hashtable]) { + if ($CurrentSnapshot.ImpactCounts.ContainsKey($impact)) { $CurrentSnapshot.ImpactCounts[$impact] } else { 0 } + } else { + $val = $CurrentSnapshot.ImpactCounts.$impact; if ($null -ne $val) { [int]$val } else { 0 } + } + $previous = if ($PreviousSnapshot.ImpactCounts -is [hashtable]) { + if ($PreviousSnapshot.ImpactCounts.ContainsKey($impact)) { $PreviousSnapshot.ImpactCounts[$impact] } else { 0 } + } else { + $val = $PreviousSnapshot.ImpactCounts.$impact; if ($null -ne $val) { [int]$val } else { 0 } + } $change = $current - $previous $changeSymbol = if ($change -gt 0) { "+" } elseif ($change -lt 0) { "" } else { "" } $changeColor = if ($change -gt 0) { "Red" } elseif ($change -lt 0) { "Green" } else { "Gray" } diff --git a/Public/Get-AzRetirementRecommendation.ps1 b/Public/Get-AzRetirementRecommendation.ps1 index 90efcdf..aee74b2 100644 --- a/Public/Get-AzRetirementRecommendation.ps1 +++ b/Public/Get-AzRetirementRecommendation.ps1 @@ -374,9 +374,12 @@ Gets recommendations and tracks changes using a custom history file path # Create snapshot of current run $currentSnapshot = New-AzRetirementSnapshot -Recommendations $recommendations - # Get the previous snapshot if history exists - $previousSnapshot = if ($history -and $history.Snapshots -and $history.Snapshots.Count -gt 0) { - $history.Snapshots[-1] + # Get the previous snapshot if history exists. + # Wrap Snapshots in @() to normalise PS 5.1 behaviour where ConvertFrom-Json + # returns a bare PSCustomObject instead of a single-element array. + $previousSnapshot = if ($history -and $history.Snapshots) { + $snapshotsArray = @($history.Snapshots) + if ($snapshotsArray.Count -gt 0) { $snapshotsArray[-1] } else { $null } } else { $null } @@ -389,7 +392,9 @@ Gets recommendations and tracks changes using a custom history file path # Add new snapshot to existing history without repeatedly reallocating arrays $snapshotsList = [System.Collections.Generic.List[object]]::new() if ($history.Snapshots) { - $snapshotsList.AddRange([object[]]$history.Snapshots) + # Wrap in @() so a single snapshot deserialised as a bare PSCustomObject + # by ConvertFrom-Json (PS 5.1) is still passed as an array to AddRange. + $snapshotsList.AddRange([object[]]@($history.Snapshots)) } $snapshotsList.Add($currentSnapshot) $history.Snapshots = $snapshotsList.ToArray() From 95dabe129c93a6a77eb9f87339ce9a87f56763ea Mon Sep 17 00:00:00 2001 From: Corey Callaway Date: Sat, 28 Feb 2026 17:47:14 -0500 Subject: [PATCH 13/19] Document change-tracking in QUICKSTART Add a "Track Changes Over Time" section to QUICKSTART.md that documents using Get-AzRetirementRecommendation -EnableChangeTracking (and -ChangeTrackingPath) to establish a baseline and track deltas on subsequent runs. Explains history file auto-update and the console output (total and per-impact deltas, newly appeared and resolved resource IDs) to help users monitor progress resolving retirement recommendations. --- QUICKSTART.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/QUICKSTART.md b/QUICKSTART.md index 61231ba..9785f7a 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -83,6 +83,28 @@ Get-AzRetirementRecommendation | Export-AzRetirementReport -OutputPath "report.j Get-AzRetirementRecommendation | Export-AzRetirementReport -OutputPath "report.html" -Format HTML ``` +## Track Changes Over Time + +Use change tracking to monitor your progress in resolving retirement recommendations: + +```powershell +# First run — establishes the baseline +Get-AzRetirementRecommendation -EnableChangeTracking + +# Subsequent runs — shows what has been resolved and what is new +Get-AzRetirementRecommendation -EnableChangeTracking + +# Use a custom history file path +Get-AzRetirementRecommendation -EnableChangeTracking -ChangeTrackingPath "C:\Reports\retirement-history.json" +``` + +The history file is updated automatically on each run. The console output shows: + +- Total recommendation count with delta from the previous run +- Per-impact-level counts (High / Medium / Low) with deltas +- Newly appeared resource IDs +- Resource IDs that have been resolved since the last run + ## Troubleshooting ### "Az.Advisor module not available or not connected" From e63512fcced58c73c0c951864a672333ff184ab9 Mon Sep 17 00:00:00 2001 From: Corey Callaway Date: Sat, 28 Feb 2026 17:53:14 -0500 Subject: [PATCH 14/19] Suppress PSUseShouldProcess warning Add a SuppressMessageAttribute to New-AzRetirementSnapshot to silence the PSUseShouldProcessForStateChangingFunctions analyzer. A clarifying comment explains the private function only constructs an in-memory object and does not change system state, so ShouldProcess/ShouldContinue are not applicable. --- Private/New-AzRetirementSnapshot.ps1 | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Private/New-AzRetirementSnapshot.ps1 b/Private/New-AzRetirementSnapshot.ps1 index ae8985c..4d1aecf 100644 --- a/Private/New-AzRetirementSnapshot.ps1 +++ b/Private/New-AzRetirementSnapshot.ps1 @@ -5,6 +5,9 @@ function New-AzRetirementSnapshot { .PARAMETER Recommendations Array of recommendation objects #> + # SuppressMessageAttribute: this private function only constructs an in-memory object; + # no system state is changed, so ShouldProcess/ShouldContinue are not applicable. + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')] [CmdletBinding()] param( [Parameter(Mandatory)] From 750a4998e69ab3ba7fb7fe56c1e802a9fa1cfce6 Mon Sep 17 00:00:00 2001 From: Corey Callaway Date: Sat, 7 Mar 2026 20:45:24 -0500 Subject: [PATCH 15/19] Default ChangeTrackingPath to current dir Change the default behavior for ChangeTrackingPath to use the current working directory instead of the user's home directory. Resolve the path at invocation time in Get-AzRetirementRecommendation.ps1 so the default file (AzRetirementMonitor-History.json) reflects the location where the command is run; preserve ability to override via parameter. Update README to document the new default location and add an explanatory comment in the script. --- Public/Get-AzRetirementRecommendation.ps1 | 10 ++++++++-- README.md | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/Public/Get-AzRetirementRecommendation.ps1 b/Public/Get-AzRetirementRecommendation.ps1 index aee74b2..3abc491 100644 --- a/Public/Get-AzRetirementRecommendation.ps1 +++ b/Public/Get-AzRetirementRecommendation.ps1 @@ -21,7 +21,7 @@ Use the Azure REST API instead of Az.Advisor PowerShell module. Requires Connect .PARAMETER EnableChangeTracking Enable change tracking to monitor progress over time. Saves snapshots to a JSON file and displays comparison with previous run. .PARAMETER ChangeTrackingPath -Path to the JSON file for storing change tracking history. Defaults to AzRetirementMonitor-History.json in the user's home directory. +Path to the JSON file for storing change tracking history. Defaults to AzRetirementMonitor-History.json in the current working directory. .EXAMPLE Get-AzRetirementRecommendation Gets all retirement recommendations using Az.Advisor module (default) @@ -50,12 +50,18 @@ Gets recommendations and tracks changes using a custom history file path [switch]$EnableChangeTracking, [Parameter()] - [string]$ChangeTrackingPath = (Join-Path $HOME "AzRetirementMonitor-History.json") + [string]$ChangeTrackingPath ) begin { $allRecommendations = [System.Collections.Generic.List[object]]::new() + # Resolve ChangeTrackingPath at invocation time so it always reflects the + # current working directory when the command is run, not when the module loaded. + if (-not $ChangeTrackingPath) { + $ChangeTrackingPath = Join-Path (Get-Location).Path 'AzRetirementMonitor-History.json' + } + if ($UseAPI) { # API mode - requires authentication via Connect-AzRetirementMonitor if (-not $script:AccessToken) { diff --git a/README.md b/README.md index bad204a..b903590 100644 --- a/README.md +++ b/README.md @@ -186,7 +186,7 @@ Get-AzRetirementRecommendation -EnableChangeTracking -ChangeTrackingPath "C:\Rep - `SubscriptionId` - One or more subscription IDs (defaults to all subscriptions) - `UseAPI` - Use REST API instead of Az.Advisor module - `EnableChangeTracking` - Enable change tracking to monitor progress over time -- `ChangeTrackingPath` - Path to the JSON file for storing change tracking history (defaults to `AzRetirementMonitor-History.json` in user's home directory) +- `ChangeTrackingPath` - Path to the JSON file for storing change tracking history (defaults to `AzRetirementMonitor-History.json` in the current working directory) #### Change Tracking From 7cb8c6669832625ecc29440ec0b922993f51b044 Mon Sep 17 00:00:00 2001 From: Corey Callaway Date: Sat, 7 Mar 2026 20:45:42 -0500 Subject: [PATCH 16/19] Update AzRetirementMonitor.Tests.ps1 --- Tests/AzRetirementMonitor.Tests.ps1 | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/Tests/AzRetirementMonitor.Tests.ps1 b/Tests/AzRetirementMonitor.Tests.ps1 index 37b7c88..3f473b2 100644 --- a/Tests/AzRetirementMonitor.Tests.ps1 +++ b/Tests/AzRetirementMonitor.Tests.ps1 @@ -835,10 +835,24 @@ Describe "Change Tracking Feature" { $cmd.Parameters.ContainsKey('ChangeTrackingPath') | Should -Be $true } - It "ChangeTrackingPath should have a default value" { + It "ChangeTrackingPath should be an optional string parameter" { $cmd = Get-Command Get-AzRetirementRecommendation $param = $cmd.Parameters['ChangeTrackingPath'] $param.Attributes.TypeId.Name | Should -Contain 'ParameterAttribute' + $param.ParameterType | Should -Be ([string]) + } + + It "ChangeTrackingPath should resolve to current working directory when not specified" { + # The default is resolved at invocation time inside the begin block, not at module + # load time, so it always reflects the caller's current working directory. + $expectedPath = Join-Path (Get-Location).Path 'AzRetirementMonitor-History.json' + $cmd = Get-Command Get-AzRetirementRecommendation + # Confirm no static default is baked into the parameter metadata + $param = $cmd.Parameters['ChangeTrackingPath'] + $param.DefaultValue | Should -BeNullOrEmpty + # Confirm the expected runtime path would be constructed correctly + $expectedPath | Should -Match 'AzRetirementMonitor-History\.json$' + $expectedPath | Should -Not -Match ([regex]::Escape($HOME)) } } From 8601b478c3b563968c2990599db7f752c6b1c76e Mon Sep 17 00:00:00 2001 From: Corey Callaway Date: Sat, 7 Mar 2026 20:56:21 -0500 Subject: [PATCH 17/19] Update test to assert path uses current location Replace the negative assertion that the expected path does not match $HOME with a positive assertion that it begins with the current working directory (Get-Location). This makes the test more explicit and robust across environments by verifying the constructed runtime path is based on the current location. --- Tests/AzRetirementMonitor.Tests.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/AzRetirementMonitor.Tests.ps1 b/Tests/AzRetirementMonitor.Tests.ps1 index 3f473b2..07baa5f 100644 --- a/Tests/AzRetirementMonitor.Tests.ps1 +++ b/Tests/AzRetirementMonitor.Tests.ps1 @@ -852,7 +852,7 @@ Describe "Change Tracking Feature" { $param.DefaultValue | Should -BeNullOrEmpty # Confirm the expected runtime path would be constructed correctly $expectedPath | Should -Match 'AzRetirementMonitor-History\.json$' - $expectedPath | Should -Not -Match ([regex]::Escape($HOME)) + $expectedPath | Should -BeLike "$(Get-Location)*" } } From 10e9982351c480c7ecfae62c5f3b2c4b7ea2b844 Mon Sep 17 00:00:00 2001 From: Corey Callaway Date: Sat, 7 Mar 2026 21:15:20 -0500 Subject: [PATCH 18/19] Add help and output types to retirement cmdlets Add missing .DESCRIPTION and .OUTPUTS sections to several retirement-related functions and emit explicit OutputType where appropriate. Updated Get-AzRetirementHistory, New-AzRetirementSnapshot, Save-AzRetirementHistory, Show-AzRetirementComparison, Export-AzRetirementReport, Get-AzRetirementMetadataItem, and Get-AzRetirementRecommendation to document expected outputs and behavior. Also made Show-AzRetirementComparison more robust when reading ImpactCounts (handles both hashtable and object property forms and defaults missing impact levels to 0), and set Export-AzRetirementReport OutputType to void. --- Private/Get-AzRetirementHistory.ps1 | 10 ++++++++-- Private/New-AzRetirementSnapshot.ps1 | 14 ++++++++++++-- Private/Save-AzRetirementHistory.ps1 | 11 ++++++++--- Private/Show-AzRetirementComparison.ps1 | 20 ++++++++++++++++---- Public/Export-AzRetirementReport.ps1 | 3 +++ Public/Get-AzRetirementMetadataItem.ps1 | 4 ++++ Public/Get-AzRetirementRecommendation.ps1 | 6 ++++++ 7 files changed, 57 insertions(+), 11 deletions(-) diff --git a/Private/Get-AzRetirementHistory.ps1 b/Private/Get-AzRetirementHistory.ps1 index a896919..6ffff16 100644 --- a/Private/Get-AzRetirementHistory.ps1 +++ b/Private/Get-AzRetirementHistory.ps1 @@ -1,9 +1,15 @@ function Get-AzRetirementHistory { <# .SYNOPSIS - Loads the change tracking history from a JSON file + Loads the change tracking history from a JSON file. + .DESCRIPTION + Reads the JSON file at the specified path and returns the deserialized history object. + Returns $null if the file does not exist or cannot be parsed. .PARAMETER Path - Path to the history JSON file + Full path to the history JSON file. + .OUTPUTS + PSCustomObject + An object with Created (string) and Snapshots (array) properties, or $null if the file is missing or invalid. #> [CmdletBinding()] param( diff --git a/Private/New-AzRetirementSnapshot.ps1 b/Private/New-AzRetirementSnapshot.ps1 index 4d1aecf..f808f69 100644 --- a/Private/New-AzRetirementSnapshot.ps1 +++ b/Private/New-AzRetirementSnapshot.ps1 @@ -1,9 +1,19 @@ function New-AzRetirementSnapshot { <# .SYNOPSIS - Creates a snapshot of current retirement recommendations for tracking + Creates a snapshot of current retirement recommendations for change tracking. + .DESCRIPTION + Aggregates the provided recommendation objects into a lightweight snapshot containing + the timestamp, total count, per-impact-level counts, per-resource-type counts, and the + list of resource IDs. The snapshot is used by Show-AzRetirementComparison and + Save-AzRetirementHistory. .PARAMETER Recommendations - Array of recommendation objects + Array of recommendation objects returned by Get-AzRetirementRecommendation. An empty + collection is permitted and produces a snapshot with zero counts. + .OUTPUTS + PSCustomObject + An object with properties: Timestamp (ISO 8601 string), TotalCount (int), + ImpactCounts (hashtable), ResourceTypeCounts (hashtable), ResourceIds (string[]). #> # SuppressMessageAttribute: this private function only constructs an in-memory object; # no system state is changed, so ShouldProcess/ShouldContinue are not applicable. diff --git a/Private/Save-AzRetirementHistory.ps1 b/Private/Save-AzRetirementHistory.ps1 index 23066ee..43e877b 100644 --- a/Private/Save-AzRetirementHistory.ps1 +++ b/Private/Save-AzRetirementHistory.ps1 @@ -1,11 +1,16 @@ function Save-AzRetirementHistory { <# .SYNOPSIS - Saves the change tracking history to a JSON file + Saves the change tracking history to a JSON file. + .DESCRIPTION + Serializes the history object to JSON (depth 10) and writes it to the specified path, + creating or overwriting the file. .PARAMETER Path - Path to the history JSON file + Full path to the history JSON file. .PARAMETER History - The history object to save + The history object (with Created and Snapshots properties) to persist. + .OUTPUTS + None. Writes a file to disk. #> [CmdletBinding()] param( diff --git a/Private/Show-AzRetirementComparison.ps1 b/Private/Show-AzRetirementComparison.ps1 index 961f12f..b4ffb82 100644 --- a/Private/Show-AzRetirementComparison.ps1 +++ b/Private/Show-AzRetirementComparison.ps1 @@ -1,11 +1,19 @@ function Show-AzRetirementComparison { <# .SYNOPSIS - Displays a comparison between current and previous snapshots + Displays a comparison between current and previous retirement snapshots. + .DESCRIPTION + Writes a formatted change-tracking summary to the console using Write-Host. + When a PreviousSnapshot is provided, shows deltas for total count, per-impact-level + counts, and lists new and resolved resource IDs. On the first run (no previous + snapshot) it displays the baseline counts. .PARAMETER CurrentSnapshot - Current snapshot object + Snapshot object for the current run, as returned by New-AzRetirementSnapshot. .PARAMETER PreviousSnapshot - Previous snapshot object + Optional snapshot object from the previous run. When $null, the output indicates + this is the first tracked run. + .OUTPUTS + None. Writes formatted output to the host. #> [CmdletBinding()] param( @@ -102,7 +110,11 @@ function Show-AzRetirementComparison { Write-Host "`nBy Impact Level:" -ForegroundColor Yellow foreach ($impact in @('High', 'Medium', 'Low')) { - $count = $CurrentSnapshot.ImpactCounts.$impact + $count = if ($CurrentSnapshot.ImpactCounts -is [hashtable]) { + if ($CurrentSnapshot.ImpactCounts.ContainsKey($impact)) { $CurrentSnapshot.ImpactCounts[$impact] } else { 0 } + } else { + $val = $CurrentSnapshot.ImpactCounts.$impact; if ($null -ne $val) { [int]$val } else { 0 } + } Write-Host " $impact : $count" -ForegroundColor Gray } } diff --git a/Public/Export-AzRetirementReport.ps1 b/Public/Export-AzRetirementReport.ps1 index 5a5c262..8d63256 100644 --- a/Public/Export-AzRetirementReport.ps1 +++ b/Public/Export-AzRetirementReport.ps1 @@ -12,6 +12,8 @@ Recommendation objects from Get-AzRetirementRecommendation (accepts pipeline inp File path for the exported report .PARAMETER Format Export format: CSV, JSON, or HTML (default: CSV) +.OUTPUTS +None. Creates a file at the specified OutputPath. .EXAMPLE Get-AzRetirementRecommendation | Export-AzRetirementReport -OutputPath "report.csv" -Format CSV Exports recommendations to CSV format @@ -23,6 +25,7 @@ Get-AzRetirementRecommendation -UseAPI | Export-AzRetirementReport -OutputPath " Exports API-sourced recommendations to JSON format #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Low')] + [OutputType([void])] param( [Parameter(Mandatory, ValueFromPipeline)] [object[]]$Recommendations, diff --git a/Public/Get-AzRetirementMetadataItem.ps1 b/Public/Get-AzRetirementMetadataItem.ps1 index ab8106d..c55b067 100644 --- a/Public/Get-AzRetirementMetadataItem.ps1 +++ b/Public/Get-AzRetirementMetadataItem.ps1 @@ -5,8 +5,12 @@ Gets Azure Advisor recommendation metadata .DESCRIPTION Note: This function only works with the -UseAPI mode as Az.Advisor module does not expose metadata retrieval cmdlets. You must run Connect-AzRetirementMonitor -UsingAPI first. +.OUTPUTS +PSCustomObject[] +Each object contains: Name, Id, Type, DisplayName, DependsOn, ApplicableScenarios. #> [CmdletBinding()] + [OutputType([PSCustomObject[]])] param() if (-not $script:AccessToken) { diff --git a/Public/Get-AzRetirementRecommendation.ps1 b/Public/Get-AzRetirementRecommendation.ps1 index 3abc491..65ce716 100644 --- a/Public/Get-AzRetirementRecommendation.ps1 +++ b/Public/Get-AzRetirementRecommendation.ps1 @@ -22,6 +22,11 @@ Use the Azure REST API instead of Az.Advisor PowerShell module. Requires Connect Enable change tracking to monitor progress over time. Saves snapshots to a JSON file and displays comparison with previous run. .PARAMETER ChangeTrackingPath Path to the JSON file for storing change tracking history. Defaults to AzRetirementMonitor-History.json in the current working directory. +.OUTPUTS +PSCustomObject[] +Each object contains: SubscriptionId, ResourceId, ResourceName, ResourceType, ResourceGroup, +Category, Impact, Problem, Solution, Description, LastUpdated, IsRetirement, RecommendationId, +LearnMoreLink, ResourceLink. .EXAMPLE Get-AzRetirementRecommendation Gets all retirement recommendations using Az.Advisor module (default) @@ -39,6 +44,7 @@ Get-AzRetirementRecommendation -EnableChangeTracking -ChangeTrackingPath "C:\Rep Gets recommendations and tracks changes using a custom history file path #> [CmdletBinding()] + [OutputType([PSCustomObject[]])] param( [Parameter(ValueFromPipeline)] [string[]]$SubscriptionId, From 66af282924a4ea2148803b80ea60b0423c4ebffc Mon Sep 17 00:00:00 2001 From: Corey Callaway Date: Sat, 7 Mar 2026 21:16:18 -0500 Subject: [PATCH 19/19] Release v3.0.0: add change tracking Bump module version to 3.0.0 and introduce change-tracking functionality. Update AzRetirementMonitor.psd1 release notes to describe new -EnableChangeTracking and -ChangeTrackingPath parameters, JSON snapshot history behavior, console comparison output, and PowerShell 5.1 compatibility. Update QUICKSTART.md and README.md to reflect v3.0 documentation and usage notes; also adjust minor wording for the v2.0 section. --- AzRetirementMonitor.psd1 | 19 +++++++++++-------- QUICKSTART.md | 14 +++++++++++++- README.md | 11 ++++++++++- 3 files changed, 34 insertions(+), 10 deletions(-) diff --git a/AzRetirementMonitor.psd1 b/AzRetirementMonitor.psd1 index 5b40e5e..c8f39b8 100644 --- a/AzRetirementMonitor.psd1 +++ b/AzRetirementMonitor.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'AzRetirementMonitor.psm1' - ModuleVersion = '2.0.0' + ModuleVersion = '3.0.0' GUID = '6775bae9-a3ec-43de-abd9-14308dd345c4' Author = 'Corey Callaway' CompanyName = 'Independent' @@ -22,14 +22,17 @@ LicenseUri = 'https://github.com/cocallaw/AzRetirementMonitor/blob/main/LICENSE' ProjectUri = 'https://github.com/cocallaw/AzRetirementMonitor' ReleaseNotes = @' +## Version 3.0.0 - Change Tracking +- **New**: `-EnableChangeTracking` parameter on `Get-AzRetirementRecommendation` to monitor progress over time +- **New**: `-ChangeTrackingPath` parameter to specify a custom history file location (defaults to `AzRetirementMonitor-History.json` in the current directory) +- Snapshots of each run are stored in a JSON history file +- Console output shows comparison with the previous run: total count, impact-level deltas, new and resolved resources +- PowerShell 5.1 compatibility for JSON snapshot deserialization + ## Version 2.0.0 - Breaking Changes -- **Default behavior changed**: Now uses Az.Advisor PowerShell module by default instead of REST API -- **Connect-AzRetirementMonitor** now requires -UsingAPI switch and is only needed for API mode -- For default usage: Install Az.Advisor, run Connect-AzAccount, then Get-AzRetirementRecommendation -- For API usage: Run Connect-AzRetirementMonitor -UsingAPI, then Get-AzRetirementRecommendation -UseAPI -- Az.Advisor module is now recommended (checked at runtime) -- Provides full parity with Azure Advisor recommendations -- **PowerShell compatibility**: Now supports both PowerShell Core (7+) and Desktop (5.1) +- Default behavior changed: Now uses Az.Advisor PowerShell module by default instead of REST API +- Connect-AzRetirementMonitor now requires -UsingAPI switch and is only needed for API mode +- PowerShell compatibility: Supports both PowerShell Core (7+) and Desktop (5.1) '@ } } diff --git a/QUICKSTART.md b/QUICKSTART.md index 9785f7a..bad66cd 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -1,4 +1,4 @@ -# AzRetirementMonitor v2.0 - Quick Start Guide +# AzRetirementMonitor v3.0 - Quick Start Guide **Compatible with PowerShell 5.1+ (Desktop and Core)** @@ -37,6 +37,18 @@ Get-AzRetirementRecommendation -UseAPI | Export-AzRetirementReport -OutputPath " Disconnect-AzRetirementMonitor ``` +## What's New in v3.0? + +### 📊 Change Tracking + +Track your progress in resolving retirement recommendations over time: + +- **`-EnableChangeTracking`** — saves a snapshot on each run and compares it to the previous one +- **`-ChangeTrackingPath`** — use a custom history file path instead of the default +- Console output shows total count deltas, impact-level changes, and new/resolved resources + +See the **Track Changes Over Time** section above for usage examples. + ## What Changed in v2.0? ### ✅ Default Method (NEW) diff --git a/README.md b/README.md index b903590..2eb8ad1 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,18 @@ Azure services evolve constantly, with features, APIs, and entire services being **AzRetirementMonitor** helps you proactively identify Azure resources affected by upcoming retirements by querying Azure Advisor for service upgrade and retirement recommendations across all your subscriptions. This gives you time to plan migrations and upgrades before services are discontinued. +## 📊 Version 3.0.0 - Change Tracking + +**Version 3.0.0 adds change tracking to monitor your progress over time:** + +- **New parameter**: `-EnableChangeTracking` on `Get-AzRetirementRecommendation` saves a snapshot each run and compares it to the previous one +- **New parameter**: `-ChangeTrackingPath` to specify a custom history file location (defaults to `AzRetirementMonitor-History.json` in the current directory) +- Console output shows total count deltas, per-impact-level changes, and new/resolved resource IDs +- Full PowerShell 5.1 compatibility for snapshot JSON deserialization + ## 🚀 Version 2.0.0 - Breaking Changes -**Version 2.0.0 introduces a major change in how the module works:** +**Version 2.0.0 introduced a major change in how the module works:** - **Default behavior**: Now uses Az.Advisor PowerShell module (full parity with Azure Advisor) - **API mode**: Available via `-UseAPI` switch on Get-AzRetirementRecommendation