Public/Approve-AvmUpdate.ps1
|
function Approve-AvmUpdate { <# .SYNOPSIS Approval gate for AVM updates — supports local interactive, GitHub PR, and Azure DevOps PR modes. .DESCRIPTION Filters the update plan through the configured approval policy: - autoApproveRiskTiers (default: LOW) proceed without sign-off. - manualApprovalRiskTiers (default: MEDIUM, HIGH, UNKNOWN) require human action. In 'local' mode, prompts interactively via Read-Host. In 'github' mode, creates a branch, applies updates, commits, pushes, and opens a PR. Merging the PR is the approval. In 'azuredevops' mode, same flow via 'az repos pr create'. Never auto-merges. Returns the approved subset plus an audit record. The github/azuredevops branch name is derived deterministically from the sorted set of module+targetVersion pairs in the plan (Renovate/Dependabot-style idempotent PRs): re-running with the same pending updates resolves to the same branch and refreshes the existing open PR in place (force-push + edit) instead of opening a duplicate. A change in the pending update set hashes to a different branch/PR, and any other open PR still on an old branch under the same prefix is left alone with a warning logged so it can be closed manually. .PARAMETER Plan The enriched plan from Get-AvmUpdatePlan. .PARAMETER ReportPath Path to the generated Markdown report (used as PR body in github/azuredevops modes). .PARAMETER ApprovalMode Approval mechanism: 'local', 'github', or 'azuredevops'. Defaults to 'local'. .PARAMETER DryRun Show what would be asked/done without making changes or opening PRs. .OUTPUTS PSCustomObject with: approvedItems[], skippedItems[], auditRecord, prUrl, prUrls[]. When github.splitLowFromHigherRisk is true and both LOW and MEDIUM+/UNKNOWN items exist, two branches/PRs are created and prUrls[] contains both URLs (prUrl holds the first, for backward compatibility). Otherwise a single PR is created as before and prUrls[] contains at most one URL. .EXAMPLE $plan = Get-AvmUpdatePlan -Path ./infra Approve-AvmUpdate -Plan $plan -ApprovalMode local .EXAMPLE Approve-AvmUpdate -Plan $plan -ReportPath ./avm-report/avm-update-report.md -ApprovalMode github #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [Parameter(Mandatory)] [PSCustomObject]$Plan, [Parameter()] [string]$ReportPath, [Parameter()] [ValidateSet('local', 'github', 'azuredevops')] [string]$ApprovalMode = 'local', [Parameter()] [switch]$DryRun, [Parameter()] [string]$WorkingDirectory = '.' ) $cfg = Get-AvmConfig $autoTiers = @($cfg.autoApproveRiskTiers) $manualTiers = @($cfg.manualApprovalRiskTiers) $approved = [System.Collections.Generic.List[PSCustomObject]]::new() $skipped = [System.Collections.Generic.List[PSCustomObject]]::new() $prUrl = $null $prUrls = [System.Collections.Generic.List[string]]::new() $auditRecord = [PSCustomObject]@{ mode = $ApprovalMode timestamp = (Get-Date -Format 'o') approvedItems = @() skippedItems = @() actor = $env:USERNAME ?? $env:USER ?? 'unknown' } # Split into auto and manual tiers $autoItems = @($Plan.candidates | Where-Object { $_.riskTier -in $autoTiers }) $manualItems = @($Plan.candidates | Where-Object { $_.riskTier -in $manualTiers }) # Auto-approve items foreach ($item in $autoItems) { $approved.Add($item) } switch ($ApprovalMode) { 'local' { if ($DryRun) { Write-Host "`n[DRY RUN] Would auto-approve $($autoItems.Count) LOW item(s)." -ForegroundColor Cyan Write-Host "[DRY RUN] Would prompt for $($manualItems.Count) MEDIUM/HIGH/UNKNOWN item(s).`n" -ForegroundColor Cyan foreach ($item in $manualItems) { Write-Host " [DRY RUN] Prompt: Approve $($item.module) $($item.currentVersion) → $($item.latestStable) [$($item.riskTier)]?" -ForegroundColor Yellow } $approved.Clear() break } if ($manualItems.Count -gt 0) { Write-Host "`nAVM Update Approval — Manual Review Required" -ForegroundColor Cyan Write-Host ("─" * 60) -ForegroundColor DarkGray Write-Host "Auto-approved (LOW): $($autoItems.Count) item(s)" -ForegroundColor Green $upToDateCount = if ($Plan.PSObject.Properties['upToDate'] -and $Plan.upToDate) { @($Plan.upToDate).Count } else { 0 } if ($upToDateCount -gt 0) { Write-Host "Already up to date: $upToDateCount module(s)" -ForegroundColor Green } Write-Host "Needs review: $($manualItems.Count) item(s)`n" -ForegroundColor Yellow # Show summary table Write-Host (" {0,-35} {1,-12} {2,-10} {3,-8} {4}" -f 'Module','Current','Latest','Jump','Risk') -ForegroundColor White Write-Host ("-" * 80) -ForegroundColor DarkGray foreach ($item in $manualItems) { $color = switch ($item.riskTier) { 'HIGH' { 'Red' } 'MEDIUM' { 'Yellow' } default { 'White' } } Write-Host (" {0,-35} {1,-12} {2,-10} {3,-8} {4}" -f $item.module, $item.currentVersion, $item.latestStable, $item.updateType, $item.riskTier) -ForegroundColor $color } # Show auto-approved LOW items foreach ($item in $autoItems) { Write-Host (" {0,-35} {1,-12} {2,-10} {3,-8} {4}" -f $item.module, $item.currentVersion, $item.latestStable, $item.updateType, $item.riskTier) -ForegroundColor Green } # Show up-to-date modules in green $upToDateItems = @() if ($Plan.PSObject.Properties['upToDate'] -and $Plan.upToDate) { $upToDateItems = @($Plan.upToDate) } foreach ($item in $upToDateItems) { Write-Host (" {0,-35} {1,-12} {2,-10} {3,-8} {4}" -f $item.module, $item.currentVersion, $item.currentVersion, 'none', 'up-to-date') -ForegroundColor Green } Write-Host "" $bulkChoice = Read-Host "Approve all LOW+MEDIUM automatically? [y=yes all LOW, m=review each, n=skip all] (default: m)" if (-not $bulkChoice) { $bulkChoice = 'm' } foreach ($item in $manualItems) { $decision = switch ($bulkChoice.ToLower()) { 'y' { if ($item.riskTier -in @('LOW','MEDIUM')) { 'approve' } else { 'review' } } 'n' { 'skip' } default { 'review' } } if ($decision -eq 'review') { Write-Host "`nReviewing: $($item.module) [$($item.riskTier)]" -ForegroundColor Cyan Write-Host " Version : $($item.currentVersion) → $($item.latestStable) ($($item.updateType))" -ForegroundColor White Write-Host " File : $($item.file):$($item.lineNumber)" -ForegroundColor DarkGray if ($item.riskReasons.Count -gt 0) { Write-Host " Risk :" -ForegroundColor Yellow foreach ($r in $item.riskReasons) { Write-Host " • $r" -ForegroundColor Yellow } } $diff = $item.interfaceDiff if ($diff -and $diff.available) { if ($diff.removedInputs.Count) { Write-Host " Diff : Removed inputs: $($diff.removedInputs -join ', ')" -ForegroundColor Red } if ($diff.addedRequired.Count) { Write-Host " Diff : Added required: $($diff.addedRequired -join ', ')" -ForegroundColor Red } if ($diff.typeChanged.Count) { Write-Host " Diff : Type changes: $($diff.typeChanged -join ', ')" -ForegroundColor Yellow } if ($diff.removedOutputs.Count) { Write-Host " Diff : Removed outputs: $($diff.removedOutputs -join ', ')" -ForegroundColor Red } if ($diff.addedOptional.Count) { Write-Host " Diff : Added optional: $($diff.addedOptional -join ', ')" -ForegroundColor Green } } elseif (-not $diff -or -not $diff.available) { Write-Host " Diff : (unavailable — review manually)" -ForegroundColor DarkGray } if ($item.changelogLines.Count -gt 0) { Write-Host " Changes :" -ForegroundColor White foreach ($cl in $item.changelogLines | Select-Object -First 5) { Write-Host " • $cl" -ForegroundColor DarkYellow } } else { Write-Host " Changes : (no changelog evidence found)" -ForegroundColor DarkGray } $choice = Read-Host " Approve this update? [y/n]" $decision = if ($choice -eq 'y') { 'approve' } else { 'skip' } } if ($decision -eq 'approve') { $approved.Add($item) } else { $skipped.Add($item) } } } } 'github' { $token = $env:GITHUB_TOKEN if (-not $token) { throw "GITHUB_TOKEN environment variable is required for github approval mode." } $branchPrefix = $cfg.github.branchPrefix $splitEnabled = [bool]$cfg.github.splitLowFromHigherRisk $lowSplitItems = @($Plan.candidates | Where-Object { $_.riskTier -eq 'LOW' }) $higherSplitItems = @($Plan.candidates | Where-Object { $_.riskTier -ne 'LOW' }) $doSplit = $splitEnabled -and $lowSplitItems.Count -gt 0 -and $higherSplitItems.Count -gt 0 # Idempotent PR flow: the branch name is derived from the sorted set of # module+targetVersion pairs, so the same pending updates always resolve to the # same branch/PR instead of a new one on every run. $openPrs = Find-AvmOpenPullRequests -ApprovalMode 'github' -BranchPrefix $branchPrefix if ($doSplit) { $branchNameLow = Get-AvmUpdateBranchName -BranchPrefix $branchPrefix -Items $lowSplitItems -Suffix 'low' $branchNameReview = Get-AvmUpdateBranchName -BranchPrefix $branchPrefix -Items $higherSplitItems -Suffix 'review' $existingLowPr = $openPrs | Where-Object { $_.branchName -eq $branchNameLow } | Select-Object -First 1 $existingReviewPr = $openPrs | Where-Object { $_.branchName -eq $branchNameReview } | Select-Object -First 1 $stalePrs = @($openPrs | Where-Object { $_.branchName -ne $branchNameLow -and $_.branchName -ne $branchNameReview }) if ($DryRun) { if ($existingLowPr) { Write-Host "[DRY RUN] Would update existing LOW-risk PR in place: $($existingLowPr.url) (branch '$branchNameLow')." -ForegroundColor Cyan } else { Write-Host "[DRY RUN] Would create branch '$branchNameLow' with $($lowSplitItems.Count) LOW item(s) and open a new PR." -ForegroundColor Cyan } if ($existingReviewPr) { Write-Host "[DRY RUN] Would update existing review PR in place: $($existingReviewPr.url) (branch '$branchNameReview')." -ForegroundColor Cyan } else { Write-Host "[DRY RUN] Would create branch '$branchNameReview' with $($higherSplitItems.Count) MEDIUM/HIGH/UNKNOWN item(s) and open a new PR." -ForegroundColor Cyan } foreach ($stale in $stalePrs) { Write-Warning "Stale AVM update PR for a different update set is still open: $($stale.url) (branch '$($stale.branchName)'). Close it manually if it's no longer needed." } break } foreach ($stale in $stalePrs) { Write-Warning "Stale AVM update PR for a different update set is still open: $($stale.url) (branch '$($stale.branchName)'). Close it manually if it's no longer needed." } # Note: autoItems (LOW, by default config) were already added to $approved # above the switch statement. Add manualItems here so $approved ends up as the # full candidate set exactly once — matching non-split behavior — regardless of # how items are distributed across the low/review branches below. foreach ($item in $manualItems) { $approved.Add($item) } $absReportPath = if ($ReportPath) { (Resolve-Path $ReportPath -ErrorAction SilentlyContinue)?.Path ?? $ReportPath } else { $null } $reportDir = if ($absReportPath) { Split-Path $absReportPath -Parent } else { $null } Push-Location $WorkingDirectory try { $baseRef = (git rev-parse --abbrev-ref HEAD 2>&1 | Select-Object -Last 1).Trim() $lowReportPath = $null if ($reportDir) { $lowReport = New-AvmUpdateReport -Plan $Plan -OutputDirectory (Join-Path $reportDir 'low') -Items $lowSplitItems if (Test-Path $lowReport.reportPath) { $lowReportPath = $lowReport.reportPath } } $lowPrUrl = New-AvmPullRequestBranch -ApprovalMode 'github' -BranchName $branchNameLow -Items $lowSplitItems ` -Title "chore(avm): update AVM module versions (LOW risk)" -BodyFilePath $lowReportPath -ExistingPr $existingLowPr ` -CommitMessage "chore(avm): update LOW risk AVM module versions [$(Get-Date -Format 'yyyy-MM-dd')]" if ($lowPrUrl) { $prUrls.Add($lowPrUrl) } git checkout $baseRef 2>&1 | Write-Verbose $reviewReportPath = $null if ($reportDir) { $reviewReport = New-AvmUpdateReport -Plan $Plan -OutputDirectory (Join-Path $reportDir 'review') -Items $higherSplitItems if (Test-Path $reviewReport.reportPath) { $reviewReportPath = $reviewReport.reportPath } } $reviewPrUrl = New-AvmPullRequestBranch -ApprovalMode 'github' -BranchName $branchNameReview -Items $higherSplitItems ` -Title "chore(avm): update AVM module versions (needs review)" -BodyFilePath $reviewReportPath -ExistingPr $existingReviewPr ` -CommitMessage "chore(avm): update AVM module versions needing review [$(Get-Date -Format 'yyyy-MM-dd')]" if ($reviewPrUrl) { $prUrls.Add($reviewPrUrl) } } finally { Pop-Location } } else { $branchName = Get-AvmUpdateBranchName -BranchPrefix $branchPrefix -Items $Plan.candidates $existingPr = $openPrs | Where-Object { $_.branchName -eq $branchName } | Select-Object -First 1 $stalePrs = @($openPrs | Where-Object { $_.branchName -ne $branchName }) if ($DryRun) { if ($existingPr) { Write-Host "[DRY RUN] Would update existing PR in place: $($existingPr.url) (branch '$branchName')." -ForegroundColor Cyan } else { Write-Host "[DRY RUN] Would create branch '$branchName', apply updates, and open a new PR." -ForegroundColor Cyan } foreach ($stale in $stalePrs) { Write-Warning "Stale AVM update PR for a different update set is still open: $($stale.url) (branch '$($stale.branchName)'). Close it manually if it's no longer needed." } break } foreach ($stale in $stalePrs) { Write-Warning "Stale AVM update PR for a different update set is still open: $($stale.url) (branch '$($stale.branchName)'). Close it manually if it's no longer needed." } # Apply all approved + manual items to a branch, then open/update the PR. # All candidates go in — PR merge is the approval gate. foreach ($item in $manualItems) { $approved.Add($item) } # Resolve report path to absolute before changing directory $absReportPath = if ($ReportPath) { (Resolve-Path $ReportPath -ErrorAction SilentlyContinue)?.Path ?? $ReportPath } else { $null } Push-Location $WorkingDirectory try { # Create/update PR — labels are intentionally omitted to avoid failures on # repos that do not have the label pre-created. $singlePrUrl = New-AvmPullRequestBranch -ApprovalMode 'github' -BranchName $branchName -Items $approved.ToArray() ` -Title "chore(avm): update AVM module versions" -BodyFilePath $absReportPath -ExistingPr $existingPr if ($singlePrUrl) { $prUrls.Add($singlePrUrl) } } finally { Pop-Location } } } 'azuredevops' { $token = $env:SYSTEM_ACCESSTOKEN ?? $env:AZURE_DEVOPS_EXT_PAT if (-not $token) { throw "SYSTEM_ACCESSTOKEN or AZURE_DEVOPS_EXT_PAT is required for azuredevops approval mode." } $env:AZURE_DEVOPS_EXT_PAT = $token # Resolve org/project up front so a misconfiguration fails fast with an actionable # message instead of surfacing as an opaque 'az repos' error later. $adoContext = Resolve-AvmAzureDevOpsContext -Config $cfg.azuredevops $branchPrefix = $cfg.github.branchPrefix $splitEnabled = [bool]$cfg.github.splitLowFromHigherRisk $lowSplitItems = @($Plan.candidates | Where-Object { $_.riskTier -eq 'LOW' }) $higherSplitItems = @($Plan.candidates | Where-Object { $_.riskTier -ne 'LOW' }) $doSplit = $splitEnabled -and $lowSplitItems.Count -gt 0 -and $higherSplitItems.Count -gt 0 # Idempotent PR flow: the branch name is derived from the sorted set of # module+targetVersion pairs, so the same pending updates always resolve to the # same branch/PR instead of a new one on every run. $openPrs = Find-AvmOpenPullRequests -ApprovalMode 'azuredevops' -BranchPrefix $branchPrefix -AzureDevOpsConfig $adoContext if ($doSplit) { $branchNameLow = Get-AvmUpdateBranchName -BranchPrefix $branchPrefix -Items $lowSplitItems -Suffix 'low' $branchNameReview = Get-AvmUpdateBranchName -BranchPrefix $branchPrefix -Items $higherSplitItems -Suffix 'review' $existingLowPr = $openPrs | Where-Object { $_.branchName -eq $branchNameLow } | Select-Object -First 1 $existingReviewPr = $openPrs | Where-Object { $_.branchName -eq $branchNameReview } | Select-Object -First 1 $stalePrs = @($openPrs | Where-Object { $_.branchName -ne $branchNameLow -and $_.branchName -ne $branchNameReview }) if ($DryRun) { if ($existingLowPr) { Write-Host "[DRY RUN] Would update existing LOW-risk PR in place: $($existingLowPr.url) (branch '$branchNameLow')." -ForegroundColor Cyan } else { Write-Host "[DRY RUN] Would create branch '$branchNameLow' with $($lowSplitItems.Count) LOW item(s) and open a new Azure DevOps PR." -ForegroundColor Cyan } if ($existingReviewPr) { Write-Host "[DRY RUN] Would update existing review PR in place: $($existingReviewPr.url) (branch '$branchNameReview')." -ForegroundColor Cyan } else { Write-Host "[DRY RUN] Would create branch '$branchNameReview' with $($higherSplitItems.Count) MEDIUM/HIGH/UNKNOWN item(s) and open a new Azure DevOps PR." -ForegroundColor Cyan } foreach ($stale in $stalePrs) { Write-Warning "Stale AVM update PR for a different update set is still open: $($stale.url) (branch '$($stale.branchName)'). Close it manually if it's no longer needed." } break } foreach ($stale in $stalePrs) { Write-Warning "Stale AVM update PR for a different update set is still open: $($stale.url) (branch '$($stale.branchName)'). Close it manually if it's no longer needed." } # Note: autoItems (LOW, by default config) were already added to $approved # above the switch statement. Add manualItems here so $approved ends up as the # full candidate set exactly once — matching non-split behavior — regardless of # how items are distributed across the low/review branches below. foreach ($item in $manualItems) { $approved.Add($item) } $absReportPath = if ($ReportPath) { (Resolve-Path $ReportPath -ErrorAction SilentlyContinue)?.Path ?? $ReportPath } else { $null } $reportDir = if ($absReportPath) { Split-Path $absReportPath -Parent } else { $null } Push-Location $WorkingDirectory try { $baseRef = (git rev-parse --abbrev-ref HEAD 2>&1 | Select-Object -Last 1).Trim() $lowReportPath = $null if ($reportDir) { $lowReport = New-AvmUpdateReport -Plan $Plan -OutputDirectory (Join-Path $reportDir 'low') -Items $lowSplitItems if (Test-Path $lowReport.reportPath) { $lowReportPath = $lowReport.reportPath } } $lowPrUrl = New-AvmPullRequestBranch -ApprovalMode 'azuredevops' -BranchName $branchNameLow -Items $lowSplitItems ` -Title "chore(avm): update AVM module versions (LOW risk)" -BodyFilePath $lowReportPath -ExistingPr $existingLowPr ` -CommitMessage "chore(avm): update LOW risk AVM module versions [$(Get-Date -Format 'yyyy-MM-dd')]" -AzureDevOpsConfig $adoContext if ($lowPrUrl) { $prUrls.Add($lowPrUrl) } git checkout $baseRef 2>&1 | Write-Verbose $reviewReportPath = $null if ($reportDir) { $reviewReport = New-AvmUpdateReport -Plan $Plan -OutputDirectory (Join-Path $reportDir 'review') -Items $higherSplitItems if (Test-Path $reviewReport.reportPath) { $reviewReportPath = $reviewReport.reportPath } } $reviewPrUrl = New-AvmPullRequestBranch -ApprovalMode 'azuredevops' -BranchName $branchNameReview -Items $higherSplitItems ` -Title "chore(avm): update AVM module versions (needs review)" -BodyFilePath $reviewReportPath -ExistingPr $existingReviewPr ` -CommitMessage "chore(avm): update AVM module versions needing review [$(Get-Date -Format 'yyyy-MM-dd')]" -AzureDevOpsConfig $adoContext if ($reviewPrUrl) { $prUrls.Add($reviewPrUrl) } } finally { Pop-Location } } else { $branchName = Get-AvmUpdateBranchName -BranchPrefix $branchPrefix -Items $Plan.candidates $existingPr = $openPrs | Where-Object { $_.branchName -eq $branchName } | Select-Object -First 1 $stalePrs = @($openPrs | Where-Object { $_.branchName -ne $branchName }) if ($DryRun) { if ($existingPr) { Write-Host "[DRY RUN] Would update existing PR in place: $($existingPr.url) (branch '$branchName')." -ForegroundColor Cyan } else { Write-Host "[DRY RUN] Would create branch '$branchName', apply updates, and open a new Azure DevOps PR." -ForegroundColor Cyan } foreach ($stale in $stalePrs) { Write-Warning "Stale AVM update PR for a different update set is still open: $($stale.url) (branch '$($stale.branchName)'). Close it manually if it's no longer needed." } break } foreach ($stale in $stalePrs) { Write-Warning "Stale AVM update PR for a different update set is still open: $($stale.url) (branch '$($stale.branchName)'). Close it manually if it's no longer needed." } foreach ($item in $manualItems) { $approved.Add($item) } $absReportPath = if ($ReportPath) { (Resolve-Path $ReportPath -ErrorAction SilentlyContinue)?.Path ?? $ReportPath } else { $null } Push-Location $WorkingDirectory try { $singlePrUrl = New-AvmPullRequestBranch -ApprovalMode 'azuredevops' -BranchName $branchName -Items $approved.ToArray() ` -Title "chore(avm): update AVM module versions" -BodyFilePath $absReportPath -ExistingPr $existingPr -AzureDevOpsConfig $adoContext if ($singlePrUrl) { $prUrls.Add($singlePrUrl) } } finally { Pop-Location } } } } $auditRecord.approvedItems = $approved.ToArray() $auditRecord.skippedItems = $skipped.ToArray() if ($prUrls.Count -gt 0) { $prUrl = $prUrls[0] } return [PSCustomObject]@{ approvedItems = $approved.ToArray() skippedItems = $skipped.ToArray() auditRecord = $auditRecord prUrl = $prUrl prUrls = $prUrls.ToArray() } } |