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()
    }
}