Public/New-AvmUpdateReport.ps1
|
function New-AvmUpdateReport { <# .SYNOPSIS Generates a Markdown overview and a JSON manifest from an AvmUpdater update plan. .DESCRIPTION Writes two files to the output directory: - avm-update-report.md — human-readable overview with summary table, per-update detail for MEDIUM/HIGH, interface diff, changelog evidence, and separate sections for LOW, needs-review, and could-not-check modules. - avm-update-plan.json — machine-readable full plan (consumed by the updater). Pure function: no network calls. .PARAMETER Plan The enriched plan object returned by Get-AvmUpdatePlan. .PARAMETER OutputDirectory Directory where report files are written. Defaults to './avm-report'. .PARAMETER Items Optional subset of $Plan.candidates to report on (e.g. only the LOW-risk items going into a split PR). When supplied, the report is scoped to these items only — lookup failures from the full plan are omitted since they don't belong to any specific split. When omitted, the full $Plan.candidates/$Plan.lookupFailed are reported (default behavior, unchanged). .OUTPUTS PSCustomObject with: reportPath, jsonPath. .EXAMPLE $plan = Get-AvmUpdatePlan -Path ./infra New-AvmUpdateReport -Plan $plan -OutputDirectory ./avm-report .EXAMPLE # Report scoped to only the LOW-risk items, for a split PR body New-AvmUpdateReport -Plan $plan -OutputDirectory ./avm-report/low -Items $lowItems #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Low')] [OutputType([PSCustomObject])] param( [Parameter(Mandatory)] [PSCustomObject]$Plan, [Parameter()] [string]$OutputDirectory = './avm-report', [Parameter()] [object[]]$Items ) # Ensure output directory exists if (-not (Test-Path $OutputDirectory)) { if ($PSCmdlet.ShouldProcess($OutputDirectory, 'Create output directory')) { New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null } } $reportPath = Join-Path $OutputDirectory 'avm-update-report.md' $jsonPath = Join-Path $OutputDirectory 'avm-update-plan.json' $s = $Plan.summary $isFiltered = $PSBoundParameters.ContainsKey('Items') # NOTE: the whole if/else must be wrapped in @(...) — assigning the bare if/else # expression collapses an empty-array branch result to $null instead of @(). $candidates = @(if ($isFiltered) { $Items } else { $Plan.candidates }) $lookupFailedItems = @(if ($isFiltered) { } else { $Plan.lookupFailed }) $ignoredItems = @(if ($isFiltered) { } else { $Plan.ignored }) $pinnedItems = @(if ($isFiltered) { } else { $Plan.pinned }) $upToDateByConstraintItems = @(if ($isFiltered) { } else { $Plan.upToDateByConstraint }) $skippedConstraintItems = @(if ($isFiltered) { } else { $Plan.skippedConstraints }) $all = @($candidates) + @($lookupFailedItems) $high = @($all | Where-Object riskTier -eq 'HIGH') $medium = @($all | Where-Object riskTier -eq 'MEDIUM') $low = @($all | Where-Object riskTier -eq 'LOW') $unknown = @($all | Where-Object riskTier -eq 'UNKNOWN') $failed = @($lookupFailedItems) # Item counts always reflect $candidates (whole plan by default, or the -Items subset) # so split-PR reports show accurate scoped numbers instead of whole-plan totals. $totalUpdatesCount = $candidates.Count $majorCount = @($candidates | Where-Object updateType -eq 'major').Count $minorCount = @($candidates | Where-Object updateType -eq 'minor').Count $patchCount = @($candidates | Where-Object updateType -eq 'patch').Count $md = [System.Text.StringBuilder]::new() # --- Header --- $null = $md.AppendLine("# AVM Update Report") $null = $md.AppendLine() $null = $md.AppendLine("**Scan date:** $($s.scannedAt) ") $null = $md.AppendLine("**Paths scanned:** $($s.pathsScanned -join ', ') ") $null = $md.AppendLine() $null = $md.AppendLine("## Summary") $null = $md.AppendLine() $null = $md.AppendLine("| Metric | Count |") $null = $md.AppendLine("|--------|-------|") $null = $md.AppendLine("| Modules scanned | $($s.totalRefs) |") $null = $md.AppendLine("| Updates available | $totalUpdatesCount |") $null = $md.AppendLine("| Major bumps | $majorCount |") $null = $md.AppendLine("| Minor bumps | $minorCount |") $null = $md.AppendLine("| Patch bumps | $patchCount |") $null = $md.AppendLine("| HIGH risk | $($high.Count) |") $null = $md.AppendLine("| MEDIUM risk | $($medium.Count) |") $null = $md.AppendLine("| LOW risk | $($low.Count) |") $null = $md.AppendLine("| UNKNOWN risk | $($unknown.Count) |") $null = $md.AppendLine("| Lookup failed | $($failed.Count) |") $null = $md.AppendLine("| Ignored (config) | $($ignoredItems.Count) |") $null = $md.AppendLine("| Pinned (config) | $($pinnedItems.Count) |") $null = $md.AppendLine("| Up to date by constraint | $($upToDateByConstraintItems.Count) |") $null = $md.AppendLine("| Constraints skipped | $($skippedConstraintItems.Count) |") $null = $md.AppendLine() # Compute 'Module' / 'Registry' + 'CHANGELOG' / 'Releases' deep links for a candidate, # rendered as a single Markdown cell. Falls back to an em dash when links can't be built # (e.g. missing reference metadata) rather than failing report generation. function Get-CandidateLinksMarkdown([PSCustomObject]$Item) { $targetVersion = if ($Item.PSObject.Properties['resolvedVersion'] -and $Item.resolvedVersion) { $Item.resolvedVersion } else { $Item.latestStable } $reference = if ($Item.PSObject.Properties['reference']) { $Item.reference } else { $null } $links = if ($reference -and $targetVersion) { Get-AvmModuleLink -Reference $reference -TargetVersion $targetVersion } else { $null } if (-not $links) { return '—' } return "[$($links.primaryLabel)]($($links.primaryUrl)) · [$($links.secondaryLabel)]($($links.secondaryUrl))" } # --- Summary table --- if ($candidates.Count -gt 0) { $null = $md.AppendLine("## All Update Candidates") $null = $md.AppendLine() $null = $md.AppendLine("| Module | Ecosystem | Current | Latest | Jump | Risk | File:Line | Links |") $null = $md.AppendLine("|--------|-----------|---------|--------|------|------|-----------|-------|") # Sort: HIGH first, then MEDIUM, LOW, UNKNOWN $sorted = $candidates | Sort-Object { switch ($_.riskTier) { 'HIGH' { 0 } 'MEDIUM' { 1 } 'LOW' { 2 } default { 3 } } }, module foreach ($c in $sorted) { $fileRef = "$([System.IO.Path]::GetFileName($c.file)):$($c.lineNumber)" $linksMd = Get-CandidateLinksMarkdown -Item $c $null = $md.AppendLine("| $($c.module) | $($c.ecosystem) | $($c.currentVersion) | $($c.latestStable) | $($c.updateType) | $($c.riskTier) | $fileRef | $linksMd |") } $null = $md.AppendLine() } # --- Needs Review section (HIGH + MEDIUM) --- $needsReview = @($high) + @($medium) if ($needsReview.Count -gt 0) { $null = $md.AppendLine("## Needs Review (HIGH / MEDIUM)") $null = $md.AppendLine() foreach ($c in $needsReview) { $null = $md.AppendLine("### $($c.module) ($($c.ecosystem)) — $($c.riskTier)") $null = $md.AppendLine() $null = $md.AppendLine("- **File:** ``$($c.file):$($c.lineNumber)``") $null = $md.AppendLine("- **Change:** $($c.currentVersion) → $($c.latestStable) ($($c.updateType))") $null = $md.AppendLine("- **Links:** $(Get-CandidateLinksMarkdown -Item $c)") $null = $md.AppendLine() if ($c.riskReasons.Count -gt 0) { $null = $md.AppendLine("**Risk Reasons:**") $null = $md.AppendLine() foreach ($r in $c.riskReasons) { $null = $md.AppendLine("- $r") } $null = $md.AppendLine() } $diff = $c.interfaceDiff if ($diff -and $diff.available) { $null = $md.AppendLine("**Interface Diff:**") $null = $md.AppendLine() if ($diff.addedRequired.Count) { $null = $md.AppendLine("- Added required inputs: $($diff.addedRequired -join ', ')") } if ($diff.removedInputs.Count) { $null = $md.AppendLine("- Removed inputs: $($diff.removedInputs -join ', ')") } if ($diff.typeChanged.Count) { $null = $md.AppendLine("- Type changes: $($diff.typeChanged -join ', ')") } if ($diff.addedOptional.Count) { $null = $md.AppendLine("- Added optional inputs: $($diff.addedOptional -join ', ')") } if ($diff.removedOutputs.Count) { $null = $md.AppendLine("- Removed outputs: $($diff.removedOutputs -join ', ')") } if ($diff.addedOutputs.Count) { $null = $md.AppendLine("- Added outputs: $($diff.addedOutputs -join ', ')") } $null = $md.AppendLine() } if ($c.changelogLines.Count -gt 0) { $null = $md.AppendLine("**Changelog Evidence:**") $null = $md.AppendLine() foreach ($l in $c.changelogLines) { $null = $md.AppendLine("- $l") } $null = $md.AppendLine() } } } # --- Likely Safe section (LOW) --- if ($low.Count -gt 0) { $null = $md.AppendLine("## Likely Safe (LOW)") $null = $md.AppendLine() $null = $md.AppendLine("| Module | Ecosystem | Current | Latest | File:Line | Links |") $null = $md.AppendLine("|--------|-----------|---------|--------|-----------|-------|") foreach ($c in $low) { $fileRef = "$([System.IO.Path]::GetFileName($c.file)):$($c.lineNumber)" $linksMd = Get-CandidateLinksMarkdown -Item $c $null = $md.AppendLine("| $($c.module) | $($c.ecosystem) | $($c.currentVersion) | $($c.latestStable) | $fileRef | $linksMd |") } $null = $md.AppendLine() } # --- Could Not Check (UNKNOWN + lookup-failed) --- $cantCheck = @($unknown) + @($failed) if ($cantCheck.Count -gt 0) { $null = $md.AppendLine("## Could Not Check") $null = $md.AppendLine() foreach ($c in $cantCheck) { $modName = try { if ($c.PSObject.Properties['module'] -and $null -ne $c.module) { $c.module } elseif ($c.PSObject.Properties['reference'] -and $null -ne $c.reference) { $c.reference.module } else { 'unknown' } } catch { 'unknown' } $null = $md.AppendLine("- **$modName**: $($c.riskReasons -join '; ')") } $null = $md.AppendLine() } # --- Up to date by constraint (Terraform "~>"/range constraints already satisfied) --- if ($upToDateByConstraintItems.Count -gt 0) { $null = $md.AppendLine("## Up To Date (Constraint Already Satisfied)") $null = $md.AppendLine() $null = $md.AppendLine('These Terraform module blocks use a version constraint that already permits the latest stable release — no file change is proposed and no PR is opened for them. Running `terraform init -upgrade` will resolve to the latest version within the constraint.') $null = $md.AppendLine() $null = $md.AppendLine("| Module | Constraint | Latest Stable | File:Line |") $null = $md.AppendLine("|--------|------------|----------------|-----------|") foreach ($u in $upToDateByConstraintItems) { $fileRef = "$([System.IO.Path]::GetFileName($u.file)):$($u.lineNumber)" $null = $md.AppendLine("| $($u.module) | $($u.currentVersion) | $($u.latestStable) | $fileRef |") } $null = $md.AppendLine() } # --- Skipped constraints (unparseable or complex/range constraints not auto-rewritten) --- if ($skippedConstraintItems.Count -gt 0) { $null = $md.AppendLine("## Skipped Constraints") $null = $md.AppendLine() $null = $md.AppendLine('These version constraints do not permit the latest stable release, but were left untouched rather than risk mangling a hand-written pin. Review and update manually.') $null = $md.AppendLine() $null = $md.AppendLine("| Module | Constraint | Latest Stable | Reason | File:Line |") $null = $md.AppendLine("|--------|------------|----------------|--------|-----------|") foreach ($sk in $skippedConstraintItems) { $fileRef = "$([System.IO.Path]::GetFileName($sk.file)):$($sk.lineNumber)" $reason = ($sk.riskReasons -join '; ') $null = $md.AppendLine("| $($sk.module) | $($sk.currentVersion) | $($sk.latestStable) | $reason | $fileRef |") } $null = $md.AppendLine() } # --- Ignored / Pinned (config policy) --- if ($ignoredItems.Count -gt 0 -or $pinnedItems.Count -gt 0) { $null = $md.AppendLine("## Ignored / Pinned") $null = $md.AppendLine() $null = $md.AppendLine('Modules excluded or capped by `ignoreModules` / `pinnedVersions` in `avmupdater.config.json`.') $null = $md.AppendLine() if ($ignoredItems.Count -gt 0) { $null = $md.AppendLine("### Ignored") $null = $md.AppendLine() $null = $md.AppendLine("| Module | Ecosystem | Current | Matched Pattern | File:Line |") $null = $md.AppendLine("|--------|-----------|---------|------------------|-----------|") foreach ($i in $ignoredItems) { $fileRef = "$([System.IO.Path]::GetFileName($i.file)):$($i.lineNumber)" $null = $md.AppendLine("| $($i.module) | $($i.ecosystem) | $($i.currentVersion) | $($i.matchedPattern) | $fileRef |") } $null = $md.AppendLine() } if ($pinnedItems.Count -gt 0) { $null = $md.AppendLine("### Pinned") $null = $md.AppendLine() $null = $md.AppendLine("| Module | Ecosystem | Current | Pin | Capped Target | Matched Pattern | File:Line |") $null = $md.AppendLine("|--------|-----------|---------|-----|----------------|------------------|-----------|") foreach ($p in $pinnedItems) { $fileRef = "$([System.IO.Path]::GetFileName($p.file)):$($p.lineNumber)" $cappedDisplay = if ($p.cappedVersion) { $p.cappedVersion } else { '*(no version available at/below pin)*' } $null = $md.AppendLine("| $($p.module) | $($p.ecosystem) | $($p.currentVersion) | $($p.pin) | $cappedDisplay | $($p.matchedPattern) | $fileRef |") } $null = $md.AppendLine() } } # Write files if ($PSCmdlet.ShouldProcess($reportPath, 'Write AVM update report')) { Set-Content -Path $reportPath -Value $md.ToString() -Encoding UTF8 } if ($PSCmdlet.ShouldProcess($jsonPath, 'Write AVM update plan JSON')) { $Plan | ConvertTo-Json -Depth 10 | Set-Content -Path $jsonPath -Encoding UTF8 } return [PSCustomObject]@{ reportPath = $reportPath jsonPath = $jsonPath } } |