Public/Export-TfvcChangeset.ps1

function Export-TfvcChangeset {
    <#
    .SYNOPSIS
        Exports TFVC changeset metadata for configured source paths.
    .DESCRIPTION
        Connects to Azure DevOps TFVC via REST API, fetches all changesets touching
        the configured source paths, enriches each with file-change details and
        linked work items, then writes a consolidated changesets.json file.
        Supports checkpoint/resume for large repositories.
    .PARAMETER ConfigPath
        Path to the migration config.json file. Defaults to ./config.json.
    .PARAMETER Resume
        Resume export from the last export-checkpoint.json.
    .EXAMPLE
        Export-TfvcChangeset -ConfigPath ./config.json
    .EXAMPLE
        Export-TfvcChangeset -ConfigPath ./config.json -Resume
    #>

    [CmdletBinding()]
    param(
        [string]$ConfigPath = "./config.json",
        [switch]$Resume
    )

    Set-StrictMode -Version Latest
    $ErrorActionPreference = 'Stop'

    # --- Bootstrap ---
    if (-not $ConfigPath) { $ConfigPath = "./config.json" }
    if (Test-Path -LiteralPath $ConfigPath -PathType Container) { $ConfigPath = Join-Path $ConfigPath 'config.json' }
    $config = Get-Content -Path $ConfigPath -Raw | ConvertFrom-Json
    $outputDir = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($config.outputDir)
    if (-not (Test-Path $outputDir)) {
        New-Item -Path $outputDir -ItemType Directory -Force | Out-Null
    }
    $logFile = Join-Path $outputDir 'migration-log.txt'
    $checkpointFile = Join-Path $outputDir 'export-checkpoint.json'

    $exportConcurrency = $([int]1)
    if ($null -ne $config.psobject.Properties['exportConcurrency'] -and $config.exportConcurrency) {
        $exportConcurrency = [int]$config.exportConcurrency
    }

    Write-MigrationLog -Message "=== TFVC Export started ===" -LogFile $logFile
    Write-MigrationLog -Message "Config: $ConfigPath | Resume: $Resume | Concurrency: $exportConcurrency" -LogFile $logFile

    # --- Connect ---

    $connArgs = @{
        ServerUrl  = $config.adoServerUrl
        Collection = $config.collection
        Project    = $config.project
        Pat        = $config.pat
        ApiVersion = $(if ($config.apiVersion) { $config.apiVersion } else { '7.0' })
    }
    $conn = New-TfvcConnection @connArgs

    Write-MigrationLog -Message "Connected to $($config.adoServerUrl)/$($config.collection)/$($config.project)" -LogFile $logFile

    # --- Determine resume point ---

    $resumeAfterId = 0
    if ($Resume -and (Test-Path $checkpointFile)) {
        $checkpoint = Get-Content $checkpointFile -Raw | ConvertFrom-Json
        $resumeAfterId = $checkpoint.lastChangesetId
        Write-MigrationLog -Message "Resuming after changeset $resumeAfterId" -LogFile $logFile
    }

    # --- Fetch changesets for each source mapping ---

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

    foreach ($mapping in $config.sourceMappings) {
        Write-MigrationLog -Message "Fetching changesets for path: $($mapping.tfvcPath)" -LogFile $logFile
        $params = @{ Connection = $conn; ItemPath = $mapping.tfvcPath }
        if ($resumeAfterId -gt 0) { $params.ResumeAfterId = $resumeAfterId }
        $cs = @(Get-TfvcAllChangesets @params)
        Write-MigrationLog -Message " Found $($cs.Count) changeset(s) for $($mapping.tfvcPath)" -LogFile $logFile
        $allChangesets.AddRange($cs)
    }

    # Deduplicate and sort ascending
    $changesets = $allChangesets |
        Sort-Object changesetId |
        Select-Object -Property * -Unique |
        Group-Object changesetId |
        ForEach-Object { $_.Group[0] }

    $totalCount = @($changesets).Count
    Write-MigrationLog -Message "Total unique changesets to export: $totalCount" -LogFile $logFile

    if ($totalCount -eq 0) {
        Write-MigrationLog -Message "No changesets to export." -LogFile $logFile
        Write-MigrationLog -Message "=== TFVC Export finished ===" -LogFile $logFile
        return
    }

    # --- Build tfvcPath list for filtering ---

    $tfvcPaths = @($config.sourceMappings | ForEach-Object { $_.tfvcPath.Replace('\', '/').TrimEnd('/') })

    # --- Worker Block ---
    $worker = {
        param($cs, $conn, $config, $ModuleRoot)

        if ($ModuleRoot) {
            . (Join-Path $ModuleRoot 'Private\TfvcApi.ps1')
            . (Join-Path $ModuleRoot 'Private\BranchHelpers.ps1')
            . (Join-Path $ModuleRoot 'Private\ExportWorker.ps1')
        }

        return Invoke-ExportWorker -Changeset $cs -Connection $conn -Config $config
    }

    # --- Execution ---
    $exportedChangesets = [System.Collections.Generic.List[object]]::new()
    $index = 0

    if ($exportConcurrency -gt 1) {
        $moduleRoot = Split-Path $PSScriptRoot -Parent
        
        if ($PSVersionTable.PSVersion.Major -ge 7) {
            Write-MigrationLog -Message "Running parallel export using ForEach-Object -Parallel (PS7+)" -LogFile $logFile
            $results = $changesets | ForEach-Object -Parallel {
                $params = @{
                    cs = $_
                    conn = $using:conn
                    config = $using:config
                    ModuleRoot = $using:moduleRoot
                }
                & $using:worker @params
            } -ThrottleLimit $exportConcurrency
            
            foreach ($res in $results) {
                if ($res.Error) {
                    Write-MigrationLog -Message $res.Error -Level ERROR -LogFile $logFile
                    throw $res.Error
                }
                $res.psobject.properties.remove('Error')
                $exportedChangesets.Add($res)
            }
            $exportedChangesets = [System.Collections.Generic.List[object]]($exportedChangesets | Sort-Object changesetId)
        } else {
            Write-MigrationLog -Message "Running parallel export using RunspacePool (PS5.1)" -LogFile $logFile
            $pool = [runspacefactory]::CreateRunspacePool(1, $exportConcurrency)
            $pool.Open()
            try {
                $jobs = [System.Collections.Generic.List[object]]::new()
                foreach ($cs in $changesets) {
                    $ps = [powershell]::Create()
                    $ps.RunspacePool = $pool
                    [void]$ps.AddScript($worker).
                        AddArgument($cs).
                        AddArgument($conn).
                        AddArgument($config).
                        AddArgument($moduleRoot)
                    $jobs.Add([pscustomobject]@{ PS = $ps; Handle = $ps.BeginInvoke(); csId = $cs.changesetId })
                }
                
                $completed = 0
                foreach ($j in $jobs) {
                    $completed++
                    if ($completed % 100 -eq 0 -or $completed -eq 1 -or $completed -eq $totalCount) {
                        Write-MigrationLog -Message "Processing changeset $($j.csId) ($completed / $totalCount)" -LogFile $logFile
                    }
                    
                    $pct = if ($totalCount -gt 0) { [int](($completed / $totalCount) * 100) } else { 100 }
                    Write-Progress -Activity 'Exporting TFVC changesets' `
                        -Status "Changeset $($j.csId) ($completed / $totalCount)" `
                        -PercentComplete $pct

                    try {
                        $res = $j.PS.EndInvoke($j.Handle)
                        if ($res) {
                            if ($res.Error) {
                                Write-MigrationLog -Message $res.Error -Level ERROR -LogFile $logFile
                                throw $res.Error
                            }
                            $res[0].psobject.properties.remove('Error')
                            $exportedChangesets.Add($res[0])
                        }
                    }
                    finally { $j.PS.Dispose() }
                }
                $exportedChangesets = [System.Collections.Generic.List[object]]($exportedChangesets | Sort-Object changesetId)
            }
            finally {
                $pool.Close()
                $pool.Dispose()
            }
        }
    } else {
        # Sequential Execution
        foreach ($cs in $changesets) {
            $index++
            $pct = if ($totalCount -gt 0) { [int](($index / $totalCount) * 100) } else { 100 }
            Write-Progress -Activity 'Exporting TFVC changesets' `
                -Status "Changeset $($cs.changesetId) ($index / $totalCount)" `
                -PercentComplete $pct

            if ($index % 100 -eq 0 -or $index -eq 1 -or $index -eq $totalCount) {
                Write-MigrationLog -Message "Processing changeset $($cs.changesetId) ($index / $totalCount)" -LogFile $logFile
            }

            $res = & $worker -cs $cs -conn $conn -config $config -ModuleRoot $null
            if ($res.Error) {
                Write-MigrationLog -Message $res.Error -Level ERROR -LogFile $logFile
                throw $res.Error
            }
            $res.psobject.properties.remove('Error')
            $exportedChangesets.Add($res)

            if ($index % 100 -eq 0) {
                @{ lastChangesetId = $cs.changesetId; timestamp = (Get-Date -Format 'o') } |
                    ConvertTo-Json | Set-Content -Path $checkpointFile -Encoding UTF8
            }
        }
    }

    Write-Progress -Activity 'Exporting TFVC changesets' -Completed

    # --- Write output ---

    $output = [PSCustomObject]@{
        exportDate      = (Get-Date -Format 'o')
        sourceMappings  = @($config.sourceMappings)
        totalChangesets = $exportedChangesets.Count
        changesets      = @($exportedChangesets)
    }

    $outputFile = Join-Path $outputDir 'changesets.json'
    $output | ConvertTo-Json -Depth 10 | Set-Content -Path $outputFile -Encoding UTF8

    # Final checkpoint
    @{ lastChangesetId = ($exportedChangesets | Select-Object -Last 1).changesetId; timestamp = (Get-Date -Format 'o') } |
        ConvertTo-Json | Set-Content -Path $checkpointFile -Encoding UTF8

    Write-MigrationLog -Message "Export complete. $($exportedChangesets.Count) changesets written to $outputFile" -LogFile $logFile
    Write-MigrationLog -Message "=== TFVC Export finished ===" -LogFile $logFile
}