Templates/migrations/migration-tools/Run-Migrate-Artifacts.ps1

<#
.SYNOPSIS
    Runs the shared Migrate-Artifacts.ps1 engine for this migration using
    settings loaded from migrate-repos-config.json in this folder (the same
    config file the repository migration uses).
 
.DESCRIPTION
    Reads the JSON configuration next to this script, converts it into
    parameter splatting for the Migrate-Artifacts.ps1 engine in the automation
    module's own Engines\ folder and invokes it once per entry in the
    'Runs' array. Only properties that map to a parameter on the engine are
    forwarded, so repository-only settings in the shared config (e.g.
    KeepClones) are ignored. Null/empty values are omitted so the engine falls
    back to its own defaults.
 
    Pass -WhatIf to preview the migration without making changes.
 
.PARAMETER ConfigPath
    Optional path to the configuration file. Defaults to
    migrate-repos-config.json alongside this script.
 
.PARAMETER Inventory
    Read-only discovery: report what would move (feeds, packages, sizes)
    without migrating anything.
 
.PARAMETER SkipArtifacts
    Only create/sync the feeds (upstream sources and permissions) without
    downloading or publishing any packages.
#>

[CmdletBinding(SupportsShouldProcess = $true)]
param(
    [string]$ConfigPath = (Join-Path $PSScriptRoot 'migrate-repos-config.json'),

    [switch]$Inventory,

    [switch]$SkipArtifacts,

    [string]$CsvPath = (Join-Path $PSScriptRoot 'output\artifacts-inventory.csv')
)

$ErrorActionPreference = 'Stop'
# Proven on the United-Machine engagement: strict mode turns a typo'd or unset
# variable into an error instead of a silent empty string mid-migration.
Set-StrictMode -Version Latest

if (-not (Get-Module -Name 'NKDAgility.AzureDevOps.AutomationTools')) {
    . "$PSScriptRoot\..\..\init.ps1"
}

if (-not (Test-Path -LiteralPath $ConfigPath)) {
    throw "Configuration file not found: $ConfigPath"
}

# The engine ships INSIDE the module, so it travels with it into .system\ and stays
# locked to the module version that drives it. Resolve it from ModuleBase - never
# by walking up, because above the module is the customer's own repo.
$moduleBase = (Get-Module -Name 'NKDAgility.AzureDevOps.AutomationTools').ModuleBase
$migrateScript = Join-Path $moduleBase 'Engines\Migrate-Artifacts.ps1'
if (-not (Test-Path -LiteralPath $migrateScript)) {
    throw "Migrate-Artifacts.ps1 not found at: $migrateScript"
}

Write-Host "==> Loading config: $ConfigPath" -ForegroundColor Cyan
$config = Get-Content -LiteralPath $ConfigPath -Raw | ConvertFrom-Json

# Discover the parameters the engine actually accepts so repository-only
# settings in the shared config are dropped instead of erroring.
$validParams = (Get-Command -Name $migrateScript).Parameters.Keys

# Expands "$ENV:NAME" / "${ENV:NAME}" placeholders in a config string using the
# process environment (tokens loaded by Set-AutomationSecrets). PATs are
# fallbacks behind ambient identity (Entra), so -AllowMissing returns $null for
# an unset variable instead of throwing.
function Expand-EnvPlaceholder {
    param([string]$Value, [switch]$AllowMissing)
    try {
        [regex]::Replace($Value, '\$(?:ENV:(?<n>\w+)|\{ENV:(?<n>\w+)\})', {
            param($m)
            $name = $m.Groups['n'].Value
            $resolved = [Environment]::GetEnvironmentVariable($name)
            if ([string]::IsNullOrEmpty($resolved)) {
                throw "Environment variable '$name' referenced in config is not set. Run Set-AutomationSecrets first (Sync.ps1 does this automatically)."
            }
            $resolved
        }, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
    }
    catch {
        if ($AllowMissing) { return $null }
        throw
    }
}

# The engine prefers ambient identity (Entra) and only uses these as fallbacks -
# so a missing token is not an error here.
$fallbackTokenParams = @('SourcePat', 'TargetPat')

# Switch parameters on Migrate-Artifacts.ps1: only include when set to $true.
$switchParams = @('KeepDownloads')

function ConvertTo-MigrateParams {
    param([System.Collections.IDictionary]$Settings)

    $params = @{}
    foreach ($entry in $Settings.GetEnumerator()) {
        $name = $entry.Key
        $value = $entry.Value

        if ($validParams -notcontains $name) { continue }
        if ($null -eq $value -or ($value -is [string] -and [string]::IsNullOrWhiteSpace($value))) {
            continue
        }

        if ($switchParams -contains $name) {
            if ($value) { $params[$name] = [switch]::Present }
            continue
        }

        if ($value -is [string]) {
            $value = Expand-EnvPlaceholder -Value $value -AllowMissing:($fallbackTokenParams -contains $name)
            if ($null -eq $value) { continue }
        }

        $params[$name] = $value
    }
    $params
}

# Collect shared settings (everything except the per-run 'Runs' array) so they
# can be applied to every run and overridden by per-run properties.
$shared = [ordered]@{}
$runs = @()
foreach ($prop in $config.PSObject.Properties) {
    if ($prop.Name -eq 'Runs') {
        $runs = @($prop.Value)
        continue
    }
    $shared[$prop.Name] = $prop.Value
}
if (-not $runs) {
    $runs = @([pscustomobject]@{})
}

# Default the working root under the workspace output folder (machine-local,
# gitignored) instead of a hardcoded absolute path.
$sharedWorkPath = $shared['WorkPath']
if (-not $sharedWorkPath) {
    $sharedWorkPath = Join-Path (Get-AutomationWorkspace).OutputFolder "work\$(Split-Path -Leaf $PSScriptRoot)"
    $shared['WorkPath'] = $sharedWorkPath
}

$runIndex = 0
$runCount = @($runs).Count

# The engine appends to the CSV so multiple runs accumulate into one file;
# remove any previous report first so each invocation starts clean. A feed-only
# sync (-SkipArtifacts) produces no package summary, so leave any existing
# inventory CSV untouched rather than wiping it.
if (-not $SkipArtifacts -and $CsvPath -and (Test-Path -LiteralPath $CsvPath)) {
    Remove-Item -LiteralPath $CsvPath -Force
}

foreach ($run in $runs) {
    $runIndex++

    # Merge shared settings with this run's overrides.
    $settings = [ordered]@{}
    foreach ($k in $shared.Keys) { $settings[$k] = $shared[$k] }
    foreach ($prop in $run.PSObject.Properties) { $settings[$prop.Name] = $prop.Value }

    # Give each run its own working directory so downloads from different
    # projects never collide.
    if ($sharedWorkPath -and $settings['SourceProject']) {
        $settings['WorkPath'] = Join-Path (Join-Path $sharedWorkPath $settings['SourceProject']) 'artifacts'
    }

    $params = ConvertTo-MigrateParams -Settings $settings
    if ($Inventory) { $params['Inventory'] = [switch]::Present }
    if ($SkipArtifacts) { $params['SkipArtifacts'] = [switch]::Present }
    # A feed-only sync has no package summary to write, so don't hand the CSV
    # to the engine (it would otherwise overwrite the inventory report).
    if ($CsvPath -and -not $SkipArtifacts) { $params['CsvPath'] = $CsvPath }

    $label = if ($settings['SourceProject']) { $settings['SourceProject'] } else { '(default)' }
    Write-Host ("==> [{0}/{1}] Invoking Migrate-Artifacts.ps1 for source project '{2}'" -f $runIndex, $runCount, $label) -ForegroundColor Cyan
    & $migrateScript @params
}