Add opt-in change tracking for retirement recommendations#22
Add opt-in change tracking for retirement recommendations#22
Conversation
Co-authored-by: cocallaw <11371083+cocallaw@users.noreply.github.com>
Co-authored-by: cocallaw <11371083+cocallaw@users.noreply.github.com>
Co-authored-by: cocallaw <11371083+cocallaw@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR adds an opt-in change tracking feature to the AzRetirementMonitor module, enabling users to monitor their progress in cleaning up Azure resources slated for retirement. The implementation stores lightweight JSON snapshots after each run and displays console comparisons showing deltas in recommendation counts, impact levels, and individual resource changes.
Changes:
- Added four new private helper functions to handle snapshot creation, history file I/O, and comparison display logic
- Extended Get-AzRetirementRecommendation with two new optional parameters:
-EnableChangeTrackingand-ChangeTrackingPath - Added comprehensive test coverage for the new helper functions (parameter validation, snapshot creation, history save/load)
- Updated documentation with usage examples, parameter descriptions, and workflow guidance
Reviewed changes
Copilot reviewed 7 out of 8 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
| Public/Get-AzRetirementRecommendation.ps1 | Added change tracking parameters and logic to create snapshots, compare with previous runs, and save history |
| Private/New-AzRetirementSnapshot.ps1 | Creates a snapshot of current recommendations with counts by impact/resource type and resource IDs |
| Private/Get-AzRetirementHistory.ps1 | Loads the change tracking history from a JSON file with error handling |
| Private/Save-AzRetirementHistory.ps1 | Saves the history object to JSON file with error handling |
| Private/Show-AzRetirementComparison.ps1 | Displays formatted console output comparing current and previous snapshots with color-coded deltas |
| Tests/AzRetirementMonitor.Tests.ps1 | Added comprehensive tests for parameter validation and helper functions |
| README.md | Added documentation for change tracking feature including parameters, usage examples, and workflow |
| .gitignore | Added default history file to prevent accidental commits |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| foreach ($resourceId in $newResources) { | ||
| $resourceName = ($resourceId -split "/")[-1] | ||
| Write-Host " + $resourceName" -ForegroundColor Red |
There was a problem hiding this comment.
If a resourceId is an empty string or contains only a single segment without slashes, the split operation will still work but may not produce the expected output. Consider adding a null/empty check before splitting, or handling edge cases where the resource ID format might be unexpected. This would make the function more robust against malformed data in the history file.
There was a problem hiding this comment.
@copilot add null or empty checks based on the comment above
There was a problem hiding this comment.
Already implemented in commit d30d2ff. Added null/empty checks using [string]::IsNullOrWhiteSpace() and .Contains("/") before splitting resourceId on lines 67 and 79 to handle edge cases gracefully.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
…tness Co-authored-by: cocallaw <11371083+cocallaw@users.noreply.github.com>
|
Just as a heads up, I was blocked by some firewall rules while working on your feedback. Expand below for details. Warning Firewall rules blocked me from connecting to one or more addresses (expand for details)I tried to connect to the following addresses, but was blocked by firewall rules:
If you need me to access, download, or install something from one of these locations, you can either:
|
|
@copilot management.azure.com has been added to firewall allow list for the repo |
Co-authored-by: cocallaw <11371083+cocallaw@users.noreply.github.com>
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.
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.
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.
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.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 9 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| It "ChangeTrackingPath should have a default value" { | ||
| $cmd = Get-Command Get-AzRetirementRecommendation | ||
| $param = $cmd.Parameters['ChangeTrackingPath'] | ||
| $param.Attributes.TypeId.Name | Should -Contain 'ParameterAttribute' |
There was a problem hiding this comment.
The assertion in this test doesn't match the test name: checking for ParameterAttribute presence doesn't verify that ChangeTrackingPath actually has a default value. Consider asserting on $cmd.Parameters['ChangeTrackingPath'].DefaultValue (or validating the parameter definition text) so the test fails if the default is removed/changed unintentionally.
| $param.Attributes.TypeId.Name | Should -Contain 'ParameterAttribute' | |
| $param.DefaultValue | Should -Not -BeNullOrEmpty |
| function Save-AzRetirementHistory { | ||
| <# | ||
| .SYNOPSIS |
There was a problem hiding this comment.
Save-AzRetirementHistory uses the verb Save, which is not an approved PowerShell verb and is likely to be flagged by PSScriptAnalyzer (PSUseApprovedVerbs) in CI. Consider renaming to an approved verb (e.g., Set-AzRetirementHistory or Export-AzRetirementHistory) and updating call sites/tests accordingly.
|
|
||
| $newResources = $currentResourceIds | Where-Object { $_ -notin $previousResourceIds } | ||
| $resolvedResources = $previousResourceIds | Where-Object { $_ -notin $currentResourceIds } | ||
|
|
There was a problem hiding this comment.
The new/resolved resource calculations use Where-Object { $_ -notin $otherList }, which does an O(n*m) membership scan and can become a hot spot when many resource IDs are tracked. Consider converting one side to a HashSet[string] (or a hashtable lookup) and doing O(1) membership checks to keep comparisons fast as history grows.
| $newResources = $currentResourceIds | Where-Object { $_ -notin $previousResourceIds } | |
| $resolvedResources = $previousResourceIds | Where-Object { $_ -notin $currentResourceIds } | |
| # Use hash sets for efficient membership checks instead of O(n*m) -notin scans | |
| $previousIdSet = [System.Collections.Generic.HashSet[string]]::new([string[]]$previousResourceIds) | |
| $currentIdSet = [System.Collections.Generic.HashSet[string]]::new([string[]]$currentResourceIds) | |
| $newResources = $currentResourceIds | Where-Object { -not $previousIdSet.Contains([string]$_) } | |
| $resolvedResources = $previousResourceIds | Where-Object { -not $currentIdSet.Contains([string]$_) } | |
| $testHistory | ConvertTo-Json -Depth 10 | Set-Content -Path $testPath | ||
|
|
||
| $module = Get-Module AzRetirementMonitor | ||
| $loaded = & $module { param($p) Get-AzRetirementHistory -Path $p } $testPath | ||
|
|
There was a problem hiding this comment.
This test writes the JSON history file with Set-Content without specifying -Encoding, but Get-AzRetirementHistory reads with -Encoding utf8. On Windows PowerShell 5.1, Set-Content defaults to UTF-16LE, which can make this test (and the scenario it models) fail. Use Save-AzRetirementHistory to write the file, or specify -Encoding utf8 here for consistent cross-version behavior.
Change Tracking Feature - Code Review Feedback Addressed ✅
All Code Review Comments Addressed
✅ Performance improvements:
resourceIds += $rec.ResourceIdto useList<string>for better performanceList<object>to avoid repeated reallocations✅ Robustness improvements:
✅ Stability improvements:
$PWDto$HOMEfor consistent file location✅ Test Coverage:
✅ Integration tests with edge cases verified
Original prompt
✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.