Private/GitHub/Test-CgrGitHubReleaseMigration.ps1

function Test-CgrGitHubReleaseMigration {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [psobject] $SourceRepository,

        [Parameter(Mandatory)]
        [psobject] $DestinationRepository,

        [string[]] $ReleaseTag,

        [string[]] $ReleaseExcludeTag,

        [switch] $IncludePrerelease,

        [switch] $IncludeDraftReleases,

        [ValidateRange(1, [int]::MaxValue)]
        [int] $ReleaseCount,

        [psobject] $ApprovedSelection,

        [object[]] $DestinationTagTargets,

        [switch] $RequireExactDestinationReleaseSet,

        [ValidateNotNullOrEmpty()]
        [string] $HostName = 'github.com'
    )

    $sourceSelection = if ($ApprovedSelection) {
        $ApprovedSelection
    }
    else {
        $selectionParameters = @{
            Repository = $SourceRepository
            ReleaseTag = $ReleaseTag
            ReleaseExcludeTag = $ReleaseExcludeTag
            IncludePrerelease = $IncludePrerelease
            IncludeDraftReleases = $IncludeDraftReleases
            HostName = $HostName
        }
        if ($PSBoundParameters.ContainsKey('ReleaseCount')) {
            $selectionParameters.ReleaseCount = $ReleaseCount
        }
        Get-CgrGitHubReleaseSelection @selectionParameters
    }

    $approvedReleases = @(Get-CgrObjectProperty -InputObject $sourceSelection -Name 'Releases')
    $expectedDestinationTargetByTag = @{}
    if ($PSBoundParameters.ContainsKey('DestinationTagTargets')) {
        $targetEvidence = @($DestinationTagTargets)
        $approvedTags = @($approvedReleases | ForEach-Object { [string] (Get-CgrObjectProperty -InputObject $_ -Name 'TagName') } | Sort-Object)
        $targetTags = @($targetEvidence | ForEach-Object { [string] (Get-CgrObjectProperty -InputObject $_ -Name 'TagName') } | Sort-Object)
        if (($approvedTags -join "`n") -ne ($targetTags -join "`n")) {
            $message = 'Destination release-tag target evidence does not exactly match the approved release selection.'
            $exception = [System.InvalidOperationException]::new($message)
            $errorRecord = [System.Management.Automation.ErrorRecord]::new(
                $exception,
                'ApprovedReleaseDestinationTagEvidenceInvalid',
                [System.Management.Automation.ErrorCategory]::InvalidData,
                $DestinationRepository.FullName
            )
            $PSCmdlet.ThrowTerminatingError($errorRecord)
        }

        foreach ($target in $targetEvidence) {
            $tagName = [string] (Get-CgrObjectProperty -InputObject $target -Name 'TagName')
            $destinationCommitSha = [string] (Get-CgrObjectProperty -InputObject $target -Name 'DestinationCommitSha')
            if ([string]::IsNullOrWhiteSpace($tagName) -or
                [string]::IsNullOrWhiteSpace($destinationCommitSha) -or
                $expectedDestinationTargetByTag.ContainsKey($tagName)) {
                $message = "Destination release-tag target evidence for '$tagName' is missing or ambiguous."
                $exception = [System.InvalidOperationException]::new($message)
                $errorRecord = [System.Management.Automation.ErrorRecord]::new(
                    $exception,
                    'ApprovedReleaseDestinationTagEvidenceInvalid',
                    [System.Management.Automation.ErrorCategory]::InvalidData,
                    $tagName
                )
                $PSCmdlet.ThrowTerminatingError($errorRecord)
            }
            $expectedDestinationTargetByTag[$tagName] = $destinationCommitSha
        }
    }

    $releaseResults = [System.Collections.Generic.List[object]]::new()
    $checks = [System.Collections.Generic.List[object]]::new()

    if ($RequireExactDestinationReleaseSet) {
        $listResult = Invoke-CgrGitHubApiReadRequest -ArgumentList @(
            'api', '--hostname', $HostName,
            '--paginate', '--slurp',
            "repos/$($DestinationRepository.FullName)/releases?per_page=100"
        )
        if ($listResult.ExitCode -ne 0) {
            $diagnostic = Protect-CgrDiagnosticText -Text ([string] $listResult.ErrorText)
            $message = "Unable to enumerate destination GitHub Releases during verification. $diagnostic"
            $exception = [System.InvalidOperationException]::new($message.Trim())
            $errorRecord = [System.Management.Automation.ErrorRecord]::new(
                $exception,
                'DestinationGitHubReleaseEnumerationFailed',
                [System.Management.Automation.ErrorCategory]::ReadError,
                $DestinationRepository.FullName
            )
            $PSCmdlet.ThrowTerminatingError($errorRecord)
        }

        $destinationReleaseJson = ($listResult.Output | ForEach-Object { [string] $_ }) -join "`n"
        try {
            $pages = if ([string]::IsNullOrWhiteSpace($destinationReleaseJson)) { @() } else { @($destinationReleaseJson | ConvertFrom-Json -Depth 100) }
        }
        catch {
            $diagnostic = Protect-CgrDiagnosticText -Text $_.Exception.Message
            $message = "Destination GitHub Release enumeration returned invalid JSON. $diagnostic"
            $exception = [System.InvalidOperationException]::new($message)
            $errorRecord = [System.Management.Automation.ErrorRecord]::new(
                $exception,
                'DestinationGitHubReleaseEnumerationResponseInvalid',
                [System.Management.Automation.ErrorCategory]::InvalidData,
                $DestinationRepository.FullName
            )
            $PSCmdlet.ThrowTerminatingError($errorRecord)
        }

        $actualReleaseTags = [System.Collections.Generic.List[string]]::new()
        foreach ($page in $pages) {
            foreach ($release in @($page)) {
                if ($null -ne $release) {
                    $actualReleaseTags.Add([string] $release.tag_name)
                }
            }
        }
        $expectedReleaseTags = @($approvedReleases | ForEach-Object { [string] (Get-CgrObjectProperty -InputObject $_ -Name 'TagName') } | Sort-Object)
        $actualReleaseTagsSorted = @($actualReleaseTags.ToArray() | Sort-Object)
        $checks.Add([pscustomobject] @{
                Name = 'GitHubReleaseSetMatchesReviewedSelection'
                Passed = ($actualReleaseTagsSorted -join "`n") -eq ($expectedReleaseTags -join "`n")
                Expected = $expectedReleaseTags
                Actual = $actualReleaseTagsSorted
            })
    }

    foreach ($sourceRelease in $approvedReleases) {
        $tagName = [string] (Get-CgrObjectProperty -InputObject $sourceRelease -Name 'TagName')
        $escapedTag = [uri]::EscapeDataString($tagName)
        $mismatches = [System.Collections.Generic.List[string]]::new()

        $destinationCommitResult = Invoke-CgrGitHubApiReadRequest -ArgumentList @(
            'api', '--hostname', $HostName,
            "repos/$($DestinationRepository.FullName)/commits/$escapedTag", '--jq', '.sha'
        )
        $destinationCommitSha = if ($destinationCommitResult.ExitCode -eq 0) {
            [string] @($destinationCommitResult.Output)[0]
        }
        elseif ([string] $destinationCommitResult.ErrorText -match '(?i)404|not found') {
            $null
        }
        else {
            $diagnostic = Protect-CgrDiagnosticText -Text ([string] $destinationCommitResult.ErrorText)
            $message = "Unable to read destination tag commit '$tagName' while verifying GitHub Releases. $diagnostic"
            $exception = [System.InvalidOperationException]::new($message.Trim())
            $errorRecord = [System.Management.Automation.ErrorRecord]::new(
                $exception,
                'DestinationGitHubReleaseTagReadFailed',
                [System.Management.Automation.ErrorCategory]::ReadError,
                $tagName
            )
            $PSCmdlet.ThrowTerminatingError($errorRecord)
        }

        $expectedDestinationCommitSha = if ($expectedDestinationTargetByTag.ContainsKey($tagName)) {
            [string] $expectedDestinationTargetByTag[$tagName]
        }
        else {
            [string] (Get-CgrObjectProperty -InputObject $sourceRelease -Name 'TargetCommitSha')
        }

        if ([string]::IsNullOrWhiteSpace($destinationCommitSha)) {
            $mismatches.Add('DestinationTagMissing')
        }
        elseif ($destinationCommitSha -ne $expectedDestinationCommitSha) {
            $mismatches.Add('TagTarget')
        }

        $destinationReleaseResult = Invoke-CgrGitHubApiReadRequest -ArgumentList @(
            'api', '--hostname', $HostName,
            "repos/$($DestinationRepository.FullName)/releases/tags/$escapedTag"
        )

        $destinationRelease = $null
        if ($destinationReleaseResult.ExitCode -ne 0) {
            if ([string] $destinationReleaseResult.ErrorText -match '(?i)404|not found') {
                $mismatches.Add('DestinationReleaseMissing')
            }
            else {
                $diagnostic = Protect-CgrDiagnosticText -Text ([string] $destinationReleaseResult.ErrorText)
                $message = "Unable to read destination GitHub Release '$tagName' during verification. $diagnostic"
                $exception = [System.InvalidOperationException]::new($message.Trim())
                $errorRecord = [System.Management.Automation.ErrorRecord]::new(
                    $exception,
                    'DestinationGitHubReleaseVerificationReadFailed',
                    [System.Management.Automation.ErrorCategory]::ReadError,
                    $tagName
                )
                $PSCmdlet.ThrowTerminatingError($errorRecord)
            }
        }
        else {
            $destinationReleaseJson = ($destinationReleaseResult.Output | ForEach-Object { [string] $_ }) -join "`n"
            try {
                $destinationRelease = $destinationReleaseJson | ConvertFrom-Json -Depth 100
            }
            catch {
                $diagnostic = Protect-CgrDiagnosticText -Text $_.Exception.Message
                $message = "Destination GitHub Release '$tagName' returned invalid JSON during verification. $diagnostic"
                $exception = [System.InvalidOperationException]::new($message)
                $errorRecord = [System.Management.Automation.ErrorRecord]::new(
                    $exception,
                    'DestinationGitHubReleaseVerificationResponseInvalid',
                    [System.Management.Automation.ErrorCategory]::InvalidData,
                    $tagName
                )
                $PSCmdlet.ThrowTerminatingError($errorRecord)
            }
        }

        if ($destinationRelease) {
            if ([string] $destinationRelease.tag_name -ne $tagName) { $mismatches.Add('TagName') }
            if ([string] $destinationRelease.name -ne [string] (Get-CgrObjectProperty -InputObject $sourceRelease -Name 'Name')) { $mismatches.Add('Name') }
            if ([string] $destinationRelease.body -ne [string] (Get-CgrObjectProperty -InputObject $sourceRelease -Name 'Body')) { $mismatches.Add('Body') }
            if ([bool] $destinationRelease.draft -ne [bool] (Get-CgrObjectProperty -InputObject $sourceRelease -Name 'Draft')) { $mismatches.Add('Draft') }
            if ([bool] $destinationRelease.prerelease -ne [bool] (Get-CgrObjectProperty -InputObject $sourceRelease -Name 'Prerelease')) { $mismatches.Add('Prerelease') }

            $sourceAssets = @(Get-CgrObjectProperty -InputObject $sourceRelease -Name 'Assets')
            $destinationAssets = @($destinationRelease.assets)
            if ($destinationAssets.Count -ne $sourceAssets.Count) {
                $mismatches.Add('AssetCount')
            }

            foreach ($sourceAsset in $sourceAssets) {
                $assetName = [string] (Get-CgrObjectProperty -InputObject $sourceAsset -Name 'Name')
                $destinationAsset = @($destinationAssets | Where-Object { [string] $_.name -eq $assetName }) | Select-Object -First 1
                if ($null -eq $destinationAsset) {
                    $mismatches.Add("AssetMissing:$assetName")
                    continue
                }
                if ([string] $destinationAsset.label -ne [string] (Get-CgrObjectProperty -InputObject $sourceAsset -Name 'Label')) { $mismatches.Add("AssetLabel:$assetName") }
                if ([long] $destinationAsset.size -ne [long] (Get-CgrObjectProperty -InputObject $sourceAsset -Name 'Size')) { $mismatches.Add("AssetSize:$assetName") }
                if ([string] $destinationAsset.content_type -ne [string] (Get-CgrObjectProperty -InputObject $sourceAsset -Name 'ContentType')) { $mismatches.Add("AssetContentType:$assetName") }
                $sourceDigest = [string] (Get-CgrObjectProperty -InputObject $sourceAsset -Name 'Digest')
                if (-not [string]::IsNullOrWhiteSpace($sourceDigest) -and [string] $destinationAsset.digest -ne $sourceDigest) {
                    $mismatches.Add("AssetDigest:$assetName")
                }
            }
        }

        $releasePassed = $mismatches.Count -eq 0
        $sourceAssetsForResult = @(Get-CgrObjectProperty -InputObject $sourceRelease -Name 'Assets')
        $releaseResults.Add([pscustomobject] @{
                TagName = $tagName
                SourceReleaseId = Get-CgrObjectProperty -InputObject $sourceRelease -Name 'ReleaseId'
                DestinationReleaseId = if ($destinationRelease) { $destinationRelease.id } else { $null }
                SourceCommitSha = Get-CgrObjectProperty -InputObject $sourceRelease -Name 'TargetCommitSha'
                ExpectedDestinationCommitSha = $expectedDestinationCommitSha
                DestinationCommitSha = $destinationCommitSha
                IsLatest = [bool] (Get-CgrObjectProperty -InputObject $sourceRelease -Name 'IsLatest')
                SourceAssetCount = $sourceAssetsForResult.Count
                DestinationAssetCount = if ($destinationRelease) { @($destinationRelease.assets).Count } else { 0 }
                Mismatches = $mismatches.ToArray()
                IsSuccessful = $releasePassed
            })
        $checks.Add([pscustomobject] @{
                Name = "GitHubRelease:$tagName"
                Passed = $releasePassed
                Expected = 'Reviewed release metadata, expected destination tag target, and assets'
                Actual = if ($releasePassed) { 'Matched' } else { $mismatches.ToArray() }
            })
    }

    $sourceLatestSelected = [bool] (Get-CgrObjectProperty -InputObject $sourceSelection -Name 'SourceLatestSelected')
    $sourceLatestTag = [string] (Get-CgrObjectProperty -InputObject $sourceSelection -Name 'SourceLatestTag')
    $destinationLatestTag = $null
    $latestPassed = $true
    if ($sourceLatestSelected) {
        $destinationLatestResult = Invoke-CgrGitHubApiReadRequest -ArgumentList @(
            'api', '--hostname', $HostName,
            "repos/$($DestinationRepository.FullName)/releases/latest"
        )
        if ($destinationLatestResult.ExitCode -eq 0) {
            $destinationLatestJson = ($destinationLatestResult.Output | ForEach-Object { [string] $_ }) -join "`n"
            try {
                $destinationLatest = $destinationLatestJson | ConvertFrom-Json -Depth 100
                $destinationLatestTag = [string] $destinationLatest.tag_name
            }
            catch {
                $diagnostic = Protect-CgrDiagnosticText -Text $_.Exception.Message
                $message = "Destination latest GitHub Release returned invalid JSON during verification. $diagnostic"
                $exception = [System.InvalidOperationException]::new($message)
                $errorRecord = [System.Management.Automation.ErrorRecord]::new(
                    $exception,
                    'DestinationGitHubLatestReleaseVerificationResponseInvalid',
                    [System.Management.Automation.ErrorCategory]::InvalidData,
                    $DestinationRepository.FullName
                )
                $PSCmdlet.ThrowTerminatingError($errorRecord)
            }
        }
        elseif ([string] $destinationLatestResult.ErrorText -notmatch '(?i)404|not found') {
            $diagnostic = Protect-CgrDiagnosticText -Text ([string] $destinationLatestResult.ErrorText)
            $message = "Unable to read the destination latest GitHub Release during verification. $diagnostic"
            $exception = [System.InvalidOperationException]::new($message.Trim())
            $errorRecord = [System.Management.Automation.ErrorRecord]::new(
                $exception,
                'DestinationGitHubLatestReleaseVerificationReadFailed',
                [System.Management.Automation.ErrorCategory]::ReadError,
                $DestinationRepository.FullName
            )
            $PSCmdlet.ThrowTerminatingError($errorRecord)
        }

        $latestPassed = $destinationLatestTag -eq $sourceLatestTag
        $checks.Add([pscustomobject] @{
                Name = 'GitHubLatestReleaseMatches'
                Passed = $latestPassed
                Expected = $sourceLatestTag
                Actual = $destinationLatestTag
            })
    }

    $successfulReleaseCount = @($releaseResults | Where-Object { $_.IsSuccessful }).Count
    [pscustomobject] @{
        PSTypeName = 'CopyGitHubRepo.ReleaseVerificationResult'
        SchemaVersion = 1
        SourceRepository = $SourceRepository.FullName
        DestinationRepository = $DestinationRepository.FullName
        UsedApprovedSelection = [bool] $ApprovedSelection
        AvailableSourceReleaseCount = Get-CgrObjectProperty -InputObject $sourceSelection -Name 'AvailableReleaseCount'
        SelectedReleaseCount = Get-CgrObjectProperty -InputObject $sourceSelection -Name 'SelectedReleaseCount'
        VerifiedReleaseCount = $successfulReleaseCount
        SelectedAssetCount = Get-CgrObjectProperty -InputObject $sourceSelection -Name 'SelectedAssetCount'
        SourceLatestTag = $sourceLatestTag
        SourceLatestSelected = $sourceLatestSelected
        DestinationLatestTag = $destinationLatestTag
        LatestReleaseMatches = if ($sourceLatestSelected) { $latestPassed } else { $null }
        IncludePatterns = @(Get-CgrObjectProperty -InputObject $sourceSelection -Name 'IncludePatterns')
        ExcludePatterns = @(Get-CgrObjectProperty -InputObject $sourceSelection -Name 'ExcludePatterns')
        IncludePrerelease = [bool] (Get-CgrObjectProperty -InputObject $sourceSelection -Name 'IncludePrerelease')
        IncludeDraftReleases = [bool] (Get-CgrObjectProperty -InputObject $sourceSelection -Name 'IncludeDraftReleases')
        ReleaseCount = Get-CgrObjectProperty -InputObject $sourceSelection -Name 'ReleaseCount'
        Releases = $releaseResults.ToArray()
        Checks = $checks.ToArray()
        IsSuccessful = -not ($checks | Where-Object { -not $_.Passed })
    }
}