Find-PowerShellRelease.ps1

<#
.SYNOPSIS
    Find PowerShell releases.
#>

function Find-PowerShellRelease {
    [CmdletBinding(DefaultParameterSetName = 'Default')]
    param (
        [Parameter(ParameterSetName = 'Version', Mandatory = $true)]
        [SemVer]$Version,
        [Parameter(ParameterSetName = 'Default')]
        [string]$VersionRange,
        [Parameter(ParameterSetName = 'VersionTag', Mandatory = $true)]
        [string]$VersionTag,
        [Parameter(ParameterSetName = 'Latest', Mandatory = $true)]
        [Switch]$Latest,
        [Parameter(ParameterSetName = 'Latest')]
        [ReleaseTypes]$Release = [ReleaseTypes]::Stable,
        [Parameter(ParameterSetName = 'Default')]
        [Switch]$IncludePreRelease = $false,
        [Parameter(ParameterSetName = 'Default')]
        [int]$MaxItems = [int]::MaxValue,
        [Parameter(ParameterSetName = 'Default')]
        [Switch]$NoCache,
        [Parameter(ParameterSetName = 'Default')]
        [Parameter(ParameterSetName = 'Version')]
        [Parameter(ParameterSetName = 'VersionTag')]
        [Parameter(ParameterSetName = 'Latest')]
        [string]$Token
    )
    begin {
        # validate parameters
        $_AbortProcess = $false
        switch ($PSCmdlet.ParameterSetName) {
            'VersionTag' {
                if ( -not (GetVersionFromTag -VersionTag $VersionTag) ) {
                    Write-Error ($Messages.Find_PowerShellRelease_002 -f $VersionTag)
                    $_AbortProcess = $true
                    return
                }
            }
            'Default' {
                # validate max items
                switch ($MaxItems) {
                    { $_ -le 0 } {
                        $_AbortProcess = $true
                        return
                    }
                    Default {
                        # do nothing
                        Write-Verbose "Set -MaxItems = $MaxItems"
                    }
                }
                # validate version range
                $parseResult = ParseVersionQuery -Query $VersionRange
                if (-not $parseResult.Result) {
                    Write-Error ($Messages.Find_PowerShellRelease_003 -f $VersionRange)
                    $_AbortProcess = $true
                    return
                }
                $MinVersion = $parseResult.MinVersion
                $IsMinInclusive = $parseResult.IsMinInclusive
                $MaxVersion = $parseResult.MaxVersion
                $IsMaxInclusive = $parseResult.IsMaxInclusive
                Write-Verbose "Set FromVersion $(if($IsMinInclusive){'=>'}else{('>')}) $MinVersion, ToVersion $(if($IsMaxInclusive){'=<'}else{('<')}) $MaxVersion"
                # validate NoCache
                if ($NoCache.IsPresent) {
                    Write-Verbose "Set cache is expired."
                    $global:g_GitHubReleaseCache.ExpireAt = [datetime]::MinValue
                    $global:g_GitHubReleaseCache.Pages = $null
                }
            }
        }
    }
    process {
        if ($_AbortProcess) {
            return
        }

        $ghReseponses = switch ($PSCmdlet.ParameterSetName) {
            'Latest' {
                $specifiedVersionTag = (Find-PowerShellBuildStatus -Release $Release).ReleaseTag
                Write-Verbose "current -Latest version tag is $($specifiedVersionTag)"
                GetGitHubResponseByTag -VersionTagName $specifiedVersionTag -Token $Token
            }
            'VersionTag' {
                GetGitHubResponseByTag -VersionTagName $VersionTag -Token $Token
            }
            'Version' {
                $specifiedVersionTag = GetTagNameFromVersion -Version $Version
                Write-Verbose "current -Version version tag is $($specifiedVersionTag)"
                GetGitHubResponseByTag -VersionTagName $specifiedVersionTag -Token $Token
            }
            Default {
                GetGitHubResponseByRange -FromVer $MinVersion -IsFromInclusive $IsMinInclusive `
                    -ToVer $MaxVersion -IsToInclusive $IsMaxInclusive -Token $Token
            }
        }
        if (-not $ghReseponses) {
            Write-Warning $Messages.Find_PowerShellRelease_001
            return
        }

        # output object
        $objectsForOutput = [System.Collections.ArrayList]::new()
        foreach ($r in $ghReseponses) {
            $obj = ConvertResponseItemToObject -ResponseItem $r -SpecifiedVersion $Version
            # excude pre release version by default
            if ($PSCmdlet.ParameterSetName -eq 'Default') {
                if (-not $IncludePreRelease -and $obj.PreRelease) {
                    Write-Verbose "-IncludePreRelease filter excludes version $($obj.Version)"
                    continue
                }
            }
            [void]$objectsForOutput.Add($obj)
        }
    }
    end {
        switch ($objectsForOutput.Count) {
            0 {
                # do nothing
            }
            1 {
                $objectsForOutput[0]
            }
            Default {
                $objectsForOutput | Sort-Object -Property Version -Descending | Select-Object -First $MaxItems
            }
        }
    }
}

function ParseVersionQuery ([string]$Query) {
    # To avoid Nuget.Versioning.dll assembly load conflict, we call test script as a job (as a external process).
    # Note : Don't use ThreadJob.
    try {
        $job = Start-Job (Join-Path $PSScriptRoot 'Test-NugetVersionRange.ps1') -ArgumentList ($Query) -WorkingDirectory $PSScriptRoot
        $result = $job | Receive-Job -Wait
    } finally {
        if ($job) {
            $job | Remove-Job
        }
    }
    foreach ($log in $result.VerboseLogs) {
        Write-Verbose $log
    }
    return [PSCustomObject]@{
        Result         = $result.Result
        MinVersion     = [semver]$result.MinVersionString
        IsMinInclusive = $result.IsMinInclusive
        MaxVersion     = [semver]$result.MaxVersionString
        IsMaxInclusive = $result.IsMaxInclusive
    }
}

function GetTagNameFromVersion ([semver]$Version) {
    return ('v{0}' -f $Version)
} 

function GetVersionFromTag ([string]$VersionTag) {
    try {
        if ($VersionTag -match "^v(?<Major>\d+)\.(?<Minor>\d+)\.(?<Patch>\d+)($|-(?<Label>.+$))") {
            return [SemVer]::new($Matches.Major, $Matches.Minor, $Matches.Patch, $Matches.Label)
        }
        return $null
    } catch {
        return $null
    }
}

function SetHttpHeaders ([string]$Token) {
    if ([string]::IsNullOrEmpty($Token)) {
        return @{ Accept = 'application/vnd.github.v3+json'; 'X-GitHub-Api-Version' = '2022-11-28' }
    }
    return @{ Accept = 'application/vnd.github.v3+json'; 'X-GitHub-Api-Version' = '2022-11-28'; Authorization = "token $Token" }
}

function GetGitHubResponseByTag ([string]$VersionTagName, [string]$Token) {
    $uri = if ($VersionTagName -eq 'latest') {
        # treat 'latest' as special
        'https://api.github.com/repos/PowerShell/PowerShell/releases/latest'
    } else {
        ('https://api.github.com/repos/PowerShell/PowerShell/releases/tags/{0}' -f $VersionTagName)
    }
    try {
        return (Invoke-RestMethod -Uri $uri -Headers (SetHttpHeaders -Token $Token))
    } catch [Microsoft.PowerShell.Commands.HttpResponseException] {
        Write-Verbose ('GetGitHubResponseByTag request error : {0}' -f $_)
        # Ignore 404 error for the case of searching tags
        if ($_.Exception.Response.StatusCode -ne 404) {
            Write-Warning ("GitHub API error : {0}" -f $_.Exception.Message)
        }
        return $null
    } catch {
        Write-Error $_
        return $null
    }
}


# very simple cache
$global:g_GH_CACHE_MINUTES = 10
$global:g_GitHubReleaseCache = [PSCustomObject]@{
    ExpireAt = [datetime]::MinValue;
    Pages    = $null;
}

function GetGitHubResponseByRange ([semver]$FromVer, [bool]$IsFromInclusive,
    [semver]$ToVer, [bool]$IsToInclusive, [string]$Token) {

    if ([datetime]::Now -ge $global:g_GitHubReleaseCache.ExpireAt) {
        # per_page=100 is max value...
        $uri = 'https://api.github.com/repos/PowerShell/PowerShell/releases?per_page=100'
        $resPages = @()
        try {
            $resPages += (Invoke-RestMethod -Uri $uri -FollowRelLink -Headers (SetHttpHeaders -Token $Token))
        } catch [Microsoft.PowerShell.Commands.HttpResponseException] {
            Write-Verbose ('GetGitHubResponseByRange request error : {0}' -f $_)
            Write-Warning ("GitHub API error : {0}" -f $_.Exception.Message)
            return $null
        } catch {
            Write-Error $_
            return $null
        }
        # set cache
        $global:g_GitHubReleaseCache.ExpireAt = [datetime]::Now.AddMinutes($global:g_GH_CACHE_MINUTES)
        $global:g_GitHubReleaseCache.Pages = $resPages
        Write-Verbose "Set cache response : ExpireAt = $($global:g_GitHubReleaseCache.ExpireAt), Pages = $($global:g_GitHubReleaseCache.Pages.Count)"
    } else {
        Write-Verbose "Use cache response : ExpireAt = $($global:g_GitHubReleaseCache.ExpireAt), Pages = $($global:g_GitHubReleaseCache.Pages.Count)"
        $resPages = $global:g_GitHubReleaseCache.Pages
    }
    foreach ($page in $resPages) {
        foreach ($res in @($page)) {
            # filter version
            $currentVer = GetVersionFromTag -VersionTag ($res.tag_name)
            if (-not $currentVer) {
                Write-Verbose "Failed to get version from $($res.tag_name)"
                continue
            }
            if ($FromVer -and ($currentVer -lt $FromVer)) {
                Write-Verbose "FromVer filter exculedes version $currentVer"
                continue          
            }
            if ($FromVer -and ($currentVer -eq $FromVer) -and (-not $IsFromInclusive)) {
                Write-Verbose "FromVer filter exculedes version $currentVer"
                continue          
            }
            if ($ToVer -and ($currentVer -gt $ToVer)) {
                Write-Verbose "ToVer filter exculedes version $currentVer"
                continue
            }
            if ($ToVer -and ($currentVer -eq $ToVer) -and (-not $IsToInclusive)) {
                Write-Verbose "ToVer filter exculedes version $currentVer"
                continue
            }
            Write-Output $res
        }
    }
}

function ConvertResponseItemToObject ([PSCustomObject]$ResponseItem, [semver]$SpecifiedVersion) {
    if (-not $ResponseItem) {
        return $null
    }
    # convert to class
    $obj = [PowerShellCoreRelease]::new()
    $obj.ReleaseId = $ResponseItem.Id
    $obj.Tag = $ResponseItem.tag_name
    $obj.Version = if ($specifiedVersion) {
        $specifiedVersion
    } else {
        # detect version from tag
        GetVersionFromTag -VersionTag ($ResponseItem.tag_name)
    }
    $obj.Name = $ResponseItem.name
    $obj.Url = $ResponseItem.url
    $obj.HtmlUrl = $ResponseItem.html_url
    $obj.PreRelease = $ResponseItem.prerelease
    # treat some special versions as pre-release (before GitHub pre-release management versions)
    switch ($obj.Version) {
        { $_ -in ('6.1.0-preview.1', '6.1.0-preview.2', '6.1.0-preview.3') } { 
            $obj.PreRelease = $true
        }
        { $_ -lt '6.0.0' } {
            $obj.PreRelease = $true
        }
        Default {
            # do nothing
        }
    }
    $obj.Published = $ResponseItem.published_at
    $obj.Description = $ResponseItem.body
    # set assets
    $obj.Assets = [System.Collections.Generic.List[PowerShellCoreAsset]]::new()
    foreach ($asset in $ResponseItem.assets) {
        $item = [PowerShellCoreAsset]::new()
        $item.Name = $asset.name
        $item.Url = $asset.url
        $item.Label = $asset.label
        $item.Created = $asset.created_at
        $item.Size = $asset.size
        $item.DownloadUrl = $asset.browser_download_url
        $obj.Assets.Add($item)
    }
    return $obj
}