Module/MyGetPackageMigration.psm1

<#
    .SYNOPSIS
        Migrate NuGet Packages From MyGet to Azure DevOps
 
    .DESCRIPTION
        This function copies all NuGet packages from a MyGet.org public feed
        to an Azure DevOps project feed. This requires a password from your
        Azure DevOps organization. Passowrd can be in the form of a PAT (Personal Access Token)
 
    .PARAMETER SourceIndexUrl
        The Index URL from your MyGet Package feed.
 
    .PARAMETER DestinationIndexUrl
        The Index URL of your Azure DevOps feed.
 
    .PARAMETER DestinationPAT
        Azure DevOps Personal Access Token (PAT) string
 
    .PARAMETER TempFilePath
        A file path where a .nupkg will be created during migration.
        This is automatically cleaned up.
     
    .PARAMETER SourceUsername
        The username of your Source pacakgeing provider
 
    .PARAMETER SourcePassword
        A string password to your package source. Password is encrypted before
        being used in any webrequests.
 
    .PARAMETER NumVersions
        Max number of versions to migrate
 
    .EXAMPLE
        # Create a Hashtable to splat to your 'Move-MyGetNuGetPackages'
        $params = @{
            SourceIndexUrl = 'https://www.myget.org/F/mytestfeed/api/v3/index.json'
            DestinationIndexUrl = 'https://pkgs.dev.azure.com/mytestorg/_packaging/mynewtestfeed/nuget/v3/index.json'
            DestinationPassword = 'thisisafakepassword'
            TempFilePath = 'C:/Temp/'
            FeedName = 'mynewtestfeed'
        }
 
        Move-MyGetNuGetPackages @params
 
    .NOTES
        For more information on Personal Access Tokens - https://docs.microsoft.com/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate
#>

function Move-MyGetNuGetPackages
{
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory = $true)]
        [string]
        $SourceIndexUrl,

        [Parameter(Mandatory = $true)]
        [string]
        $DestinationIndexUrl,

        [Parameter(Mandatory = $true)]
        [string]
        $DestinationPAT,

        [Parameter()]
        [string]
        $TempFilePath = $env:Temp,

        [Parameter()]
        [string]
        $SourceUsername,

        [Parameter()]
        [securestring]
        $SourcePassword,

        [Parameter()]
        [string]
        $DestinationFeedName,

        [Parameter()]
        [int]
        $NumVersions = -1
    )

    if ($null -eq $TempFilePath)
    {
        $TempFilePath = [System.IO.Path]::GetTempPath()

        if ($null -eq $TempFilePath)
        {
            Write-Error 'Temp filepath not found. Please provide value for -TempFilePath'
            throw
        }
    }

    if ($Verbose)
    {
        $oldVerbosePreference = $VerbosePreference
        $VerbosePreference = 'Continue'
    }

    if ($DestinationFeedName)
    {
        # Adds Azure DevOps feed to NuGet sources/ update password to access feed.
        # It also prevent's further progress if NuGet is not set up correctly for migration on the users machine.
        Update-NuGetSource -FeedName $DestinationFeedName -DevOpsSourceUrl $DestinationIndexUrl -Password $DestinationPAT
    }

    if ($SourcePassword)
    {
        $sourceCredential = New-Object -TypeName pscredential -ArgumentList $SourceUsername, $secureSourcePassword
    }

    $securePassword = ConvertTo-SecureString -String $DestinationPAT -AsPlainText -Force
    $destinationCredential = New-Object -TypeName pscredential -ArgumentList 'PackageMigration', $securePassword

    # Collects and compares packages from source to Azure DevOps feed
    $sourceVersions = Get-ContentUrls -IndexUrl $SourceIndexUrl -Credential $sourceCredential
    $destinationVersions = Get-Packages -IndexUrl $DestinationIndexUrl -Credential $destinationCredential
    $versionsMissingInDestination = Get-MissingVersions -SourceVersions $sourceVersions -DestinationVersions $destinationVersions
    Write-Host "Found $($sourceVersions.Count) package versions in source, $($destinationVersions.Count) package versions in destination, and $($versionsMissingInDestination.Count) packages versions need to be copied"

    if ($NumVersions -gt -1 -and $NumVersions -lt $versionsMissingInDestination.Length)
    {
        $versionsMissingInDestination = $versionsMissingInDestination | Select-Object -First $NumVersions
    }

    if ($versionsMissingInDestination.Length -gt 0) {

        Write-Host "Migrating $($versionsMissingInDestination.Length) package versions."

        # Migrates packages from sources to Azure DevOps feed
        $versionContentUrls = $versionsMissingInDestination.Url
        $results = Start-MigrationSingleThreaded -ContentUrls $versionContentUrls -DestinationIndexUrl $DestinationIndexUrl -TempFilePath $TempFilePath -SourceCredential $sourceCredential

        Out-Results $results
    }
    $VerbosePreference = $oldVerbosePreference
}

<#
    .SYNOPSIS
        Returns the NuGet connection URLs
 
    .PARAMETER IndexUrl
        The Index URL from your MyGet Package feed.
 
    .PARAMETER Credential
        The credential object to connect to packaging source.
#>

function Get-ContentUrls
{
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory = $true)]
        [string]
        $IndexUrl,

        [Parameter()]
        [AllowNull()]
        [pscredential]
        $Credential
    )

    # Var is used to ensure there aren't multiple requests to package urls
    $Script:registrationRequests = [System.Collections.ArrayList]::new()
    $packages = (Get-Packages -IndexUrl $IndexUrl -Credential $Credential).id | Select-Object -Unique

    #TODO need to edit the received packages to make sure I'm only getting the unique packages
    $registrationBaseUrl = Get-RegistrationBase -IndexUrl $IndexUrl -Credential $Credential
    $result = [System.Collections.ArrayList]::new()

    # Collect source package URLs to migrate
    foreach ($packageName in $packages)
    {
        $registrationUrl = "$registrationBaseUrl/$packageName/index.json"
        $versions = Read-CatalogUrl -RegistrationUrl $registrationUrl -Credential $Credential

        $null = $result.AddRange($versions)
    }

    return $result
}

<#
    .SYNOPSIS
        Filters and returns the NuGet v3 URL
 
    .PARAMETER IndexUrl
        The Index URL from your MyGet Package feed.
 
    .PARAMETER Credential
        The credential object to connect to packaging source.
#>

function Get-V3SearchBaseURL
{
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory = $true)]
        [string]
        $IndexUrl,

        [Parameter()]
        [pscredential]
        $Credential
    )

    $indexJson = Get-Index -IndexUrl $IndexUrl -Credential $Credential
    $entry = ($indexJson | Where-Object -FilterScript {$_.'@type' -match 'SearchQueryService.*'})[0]

    return $entry.'@id'
}

<#
    .SYNOPSIS
        This is an empty function right now for future development.
 
    .PARAMETER IndexUrl
        The Index URL from your desired Package feed.
#>

function Get-V3FlatBaseURL
{
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory = $true)]
        [string[]]
        $IndexUrl
    )
}

<#
.SYNOPSIS
    Returns the base registration URL
 
.PARAMETER IndexUrl
    The Index URL from the desired Package feed.
 
.PARAMETER Credential
        The credential object to connect to packaging source.
#>

function Get-RegistrationBase
{
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory = $true)]
        [string]
        $IndexUrl,

        [Parameter()]
        [AllowNull()]
        [pscredential]
        $Credential
    )

    $indexJson = Get-Index -IndexUrl $IndexUrl -Credential $Credential
    $entry = $entry = ($indexJson | Where-Object -FilterScript {$_.'@type' -eq 'RegistrationsBaseUrl/Versioned'})[0]

    return $entry.'@id'
}

<#
    .SYNOPSIS
        Returns the resources for different NuGet services
 
    .PARAMETER IndexUrl
        The Index URL from your desired Package feed.
 
    .PARAMETER Credential
        The Credential object to access a URL
#>

function Get-Index
{
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory = $true)]
        [string]
        $IndexUrl,

        [Parameter()]
        [AllowNull()]
        [pscredential]
        $Credential
    )

    return (Invoke-RestMethod -Uri $IndexUrl -Credential $Credential).resources
}

<#
    .SYNOPSIS
        Returns package information from the desired source
 
    .PARAMETER IndexUrl
        Base Url to query from.
 
    .PARAMETER Credential
        Credential to access base URL where packages are stored.
 
    .PARAMETER Take
        Identifies packages in a query
#>

function Get-Packages
{
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory = $true)]
        [string]
        $IndexUrl,

        [Parameter()]
        [AllowNull()]
        [pscredential]
        $Credential,

        [Parameter()]
        [int]
        $Take = 100
    )

    $searchBaseUrl = Get-V3SearchBaseURL -IndexUrl $IndexUrl -Credential $Credential
    $result = [System.Collections.ArrayList]::new()
    $i = 0

    # Create the body of the query
    $payLoad = [ordered]@{
        Prerelease = $true
        SemverLevel = '2.0'
        Skip = 0
        Take = $Take
    }

    While ($true)
    {
        # Adjust the skip portion of the query to get all packages associated with URL
        $payLoad.Skip = $i * $Take
        $message = "Request: $searchBaseUrl, Prerelease = $($payload.Prerelease), SemverLevel = $($payload.SemverLevel), Skip =$($payload.Skip), Take = $($payload.Take)"
        Write-Verbose $message
        try
        {
            $response = Invoke-RestMethod -Uri $searchBaseUrl -Body $payLoad -Credential $Credential
            $packages = $response.data
            if ($packages.Count -eq 0)
            {
                break
            }

            foreach ($package in $packages)
            {
                foreach ($version in $package.versions)
                {
                    $packageObject = [PSCustomObject]@{
                        Id      = $package.id
                        Version = $version.version
                    }
                    $null = $result.add($packageObject)
                }
            }

            $i++
        }
        catch
        {
            break
        }
    }

    return $result
}

<#
    .SYNOPSIS
        Reads a catalog of packages
 
    .PARAMETER RegistrationUrl
        The base registration URL to query with.
 
    .PARAMETER Credential
        The credential object to connect to packaging source.
#>

function Read-CatalogUrl
{
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory = $true)]
        [string]
        $RegistrationUrl,

        [Parameter()]
        [AllowNull()]
        [pscredential]
        $Credential
    )

    $result = [System.Collections.ArrayList]::new()

    if ($RegistrationUrl -in $Script:registrationRequests)
    {
        Write-Warning -Message "Skipping duplicate request to $RegistrationUrl" -InformationAction Continue
    }
    else
    {
        Write-Verbose "Request: $RegistrationUrl"
        $response = Invoke-RestMethod -Uri $RegistrationUrl -Credential $Credential

        # Adds to track Registration requests to identify duplicate requests
        $null = $Script:registrationRequests.Add($RegistrationUrl)
        foreach ($item in $response.items)
        {
            $null = $result.AddRange((Read-CatalogEntry -Item $item))
        }

        Write-Output -NoEnumerate $result
    }
}

<#
    .SYNOPSIS
        Returns a pacakge entry.
 
    .DESCRIPTION
        This is a recursive function to reach the catalog
        entries of a given Catalog URL.
 
    .PARAMETER Item
        The package entry from the parent catalog
#>

function Read-CatalogEntry
{
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory = $true)]
        $Item
    )

    $result = [System.Collections.ArrayList]::new()
    $itemType = $Item.'@type'

    if ($itemType -eq 'catalog:CatalogPage' -and $null -eq $item.items)
    {
        $catalogUrl = $Item.'@id'
        $null = $result.AddRange((Read-CatalogUrl -RegistrationUrl $catalogUrl))
    }
    elseif ($itemType -eq 'catalog:CatalogPage')
    {
        foreach ($subItem in $Item.items)
        {
            $null = $result.AddRange((Read-CatalogEntry -Item $subItem))
        }
    }
    elseif ($itemType -eq 'Package')
    {
        $returnItem = [PSCustomObject]@{
            Name    = $Item.catalogEntry.id
            Version = $Item.catalogEntry.version
            Url     = $Item.packageContent
        }

        $null = $result.Add($returnItem)
    }

    Write-Output -NoEnumerate $result
}

<#
    .SYNOPSIS
        Migrates packages using NuGet.exe
 
    .DESCRIPTION
        This function migrates packages one at a time.
        In future development, there will be an option to use multi-threading
        to migrate packages faster.
 
    .PARAMETER ContentUrls
        URL's of migrating packages
 
    .PARAMETER DestinationIndexUrl
        Destination index URL where packages are migrating to.
 
    .PARAMETER TempFilePath
        The local folder path for temporary NuGet packages during migration.
 
    .PARAMETER SourceCredential
        The credential object to connect to the source packaging repository.
#>

function Start-MigrationSingleThreaded
{
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory = $true)]
        [string[]]
        $ContentUrls,

        [Parameter(Mandatory = $true)]
        [string]
        $DestinationIndexUrl,

        [Parameter(Mandatory = $true)]
        [string]
        $TempFilePath,

        [Parameter()]
        [AllowNull()]
        [pscredential]
        $SourceCredential
    )

    $results = [System.Collections.ArrayList]::new()
    $TempFilePath = "$TempFilePath/temp.nupkg"

    foreach ($url in $ContentUrls)
    {
        $result = Start-Migration -ContentUrl $url -DestinationIndexUrl $DestinationIndexUrl -TempFilePath $TempFilePath -Credential $SourceCredential
        Out-Result @result
        $null = $results.Add($result)
    }

    # Clean up temp .nupkg file created during migration.
    Remove-Item -Path $TempFilePath -Force
    return $results
}

<#
    .SYNOPSIS
        Uses NuGet.exe to migrate packages from source to destination.
 
    .PARAMETER ContentUrl
        URL of the package to migrate.
 
    .PARAMETER DestinationIndexUrl
        Destination index URL where package is migrating to.
     
    .PARAMETER Credential
        The credential object to connect to packaging source.
 
    .PARAMETER TempFilePath
        The local folder path for temporary NuGet packages during migration.
#>

function Start-Migration
{
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory = $true)]
        [string]
        $ContentUrl,

        [Parameter(Mandatory = $true)]
        [string]
        $DestinationIndexUrl,

        [Parameter()]
        [AllowNull()]
        [pscredential]
        $Credential,

        [Parameter()]
        [string]
        $TempFilePath
    )
    
    try
    {
        $response = Invoke-WebRequest -Uri $ContentUrl -Credential $Credential
    }
    catch
    {
        $return = @{
            Url         = $ContentUrl
            HttpStatus  = -1
            NuGetStatus = -1
            Stdout      = $null
        }

        return $return
    }

    if ($response.StatusCode -ne 200)
    {
        $return = @{
            Url         = $ContentUrl
            HttpStatus  = $response.StatusCode
            NuGetStatus = -1
            Stdout      = $null
        }

        return $return
    }
    
    # Writes packacke content bytes to temporary .nupkg file during migration
    [io.file]::WriteAllBytes($TempFilePath, $response.Content)
    $arguments = "push -Source $DestinationIndexUrl -ApiKey Migration $TempFilePath"

    $result = Start-Command -CommandTitle 'nuget.exe' -CommandArguments $arguments
    $return = @{
        Url         = $ContentUrl
        HttpStatus  = $response.StatusCode
        NuGetStatus = $result.ExitCode
        StdOut      = $result.StdOut
        StdErr      = $result.StdErr
    }

    return $return
}

<#
    .SYNOPSIS
        Writes the result of individual package migrations.
 
    .PARAMETER Url
        Url of the migrating package
 
    .PARAMETER HttpsStatus
        Http Status code returned from web request.
 
    .PARAMETER NugetStatus
        NuGet migration status code
 
    .PARAMETER StdOut
        Standard Output
 
    .PARAMETER StdErr
        Standard Error Output
#>

function Out-Result
{
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory = $true)]
        [string[]]
        $Url,

        [Parameter()]
        [string]
        $HttpStatus,

        [Parameter()]
        [string]
        $NugetStatus,

        [Parameter()]
        [string]
        $StdOut,

        [Parameter()]
        [string]
        $StdErr
    )

    Write-Verbose "$Url, --> fetchContent $HttpStatus, publish $NugetStatus"
    if ($NugetStatus -ne 0 -and $null -ne $Stdout)
    {
        Write-Verbose $StdOut
    }

    if ($StdErr)
    {
        Write-Error $StdErr -InformationAction Continue
    }
}

<#
    .SYNOPSIS
        Writes the results of package migration process as a whole.
 
    .PARAMETER Results
        Results of the package migration
#>

function Out-Results
{
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory = $true)]
        $Results
    )

    $errors = $Results | Where-Object -FilterScript {$_.HttpStatus -ne 200 -or $_.NuGetStatus -ne 0}
    Write-Information "$($Results.Count - $errors.Count) packages pushed successfully" -InformationAction Continue

    if ($errors.Count -gt 0)
    {
        Write-Warning "$($errors.Count) errors. See error variable for more information." -InformationAction Continue
    }
}

<#
    .SYNOPSIS
        Returns package ID's from the source not located in the destination.
 
    .PARAMETER SourceVersions
        Source pacakges to be filtered
 
    .PARAMETER DestinationVersions
        Destination packages to be filtered against.
#>

function Get-MissingVersions
{
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory = $true)]
        $SourceVersions,

        [Parameter(Mandatory = $true)]
        [AllowNull()]
        $DestinationVersions
    )

    # hashtable of name____id for fast lookup. Powershell hashtables are not case sensitive.
    $destHash = @{}
    $sep = "_____"
    $DestinationVersions | ForEach-Object {$destHash.Add("$($_.Id)$sep$($_.Version)", $null)}

    $missingPackages = [System.Collections.ArrayList]@()
    foreach ($sourceVersion in $SourceVersions)
    {
        $key = "$($sourceVersion.Name)$sep$($sourceVersion.Version)"
        if (-not $destHash.ContainsKey($key))
        {
            $null = $missingPackages.Add($sourceVersion);
        }
    }

    return $missingPackages;
}

<#
    .SYNOPSIS
        Runs NeGet using .Net to get all required information.
 
    .PARAMETER CommandTitle
        The .exe to run
 
    .PARAMETER CommandArguments
        Argument string to run with specified .exe
#>

function Start-Command
{
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory = $true)]
        [string]
        $CommandTitle,
        
        [Parameter()]
        $CommandArguments
    )

    $processInfo = New-Object System.Diagnostics.ProcessStartInfo
    $processInfo.FileName = $CommandTitle
    $processInfo.RedirectStandardError = $true
    $processInfo.RedirectStandardOutput = $true
    $processInfo.UseShellExecute = $false
    $processInfo.CreateNoWindow = $true
    $processInfo.Arguments = $CommandArguments

    $process = New-Object System.Diagnostics.Process
    $process.StartInfo = $processInfo
    $process.Start() | Out-Null
    $process.WaitForExit()

    $return = [pscustomobject]@{
        StdOut = $process.StandardOutput.ReadToEnd()
        StdErr = $process.StandardError.ReadToEnd()
        ExitCode = $process.ExitCode
    }

    return $return
}

<#
    .SYNOPSIS
        Updates NuGet config file to access Azure Artifacts feed
 
    .PARAMETER FeedName
        Azure Artifact feed name
 
    .PARAMETER DevOpsSourceUrl
        Azure DevOps feed source index Url
 
    .PARAMETER Password
        PAT to connect to Azure DevOps Feed
#>

function Update-NuGetSource
{
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory = $true)]
        [string]
        $FeedName,

        [Parameter(Mandatory = $true)]
        [string]
        $DevOpsSourceUrl,

        [Parameter(Mandatory = $true)]
        [string]
        $Password
    )

    $sourceAdd = Start-Command -CommandTitle 'nuget.exe' -CommandArguments "sources Add -Name $FeedName -Source $DevOpsSourceUrl"
    if ($sourceAdd.ExitCode -eq 1)
    {
        if ($sourceAdd.StdErr -match ".*name specified has already been added to the list of available package sources.*")
        {
            Write-Verbose $sourceAdd.StdErr
        }
        else
        {
            Write-Error $sourceAdd.StdErr
            throw
        }
    }

    $sourceUpdate = Start-Command -CommandTitle nuget.exe -CommandArguments "sources Update -Name $FeedName -UserName 'username' -Password $password"

    if ($sourceUpdate.ExitCode -eq 1)
    {
        Write-Error $sourceUpdate.StdErr
        throw
    }
}

Export-ModuleMember -Function 'Move-MyGetNuGetPackages'