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 ---

    $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'

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

    # --- Connect ---

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

    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

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

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

    # --- Helper: find the mapping for a server path ---

    function Find-SourceMapping {
        param([string]$ServerPath)
        $sp = $ServerPath.Replace('\', '/').TrimEnd('/')
        foreach ($m in $config.sourceMappings) {
            $base = $m.tfvcPath.Replace('\', '/').TrimEnd('/')
            if ($sp.StartsWith($base, [StringComparison]::OrdinalIgnoreCase)) {
                return $m
            }
        }
        return $null
    }

    # --- Helper: normalise changeType to primary type ---

    function Get-PrimaryChangeType {
        param([string]$RawChangeType)
        $types = $RawChangeType -split ',' | ForEach-Object { $_.Trim() }

        if ($types -contains 'delete') { return 'delete' }
        if ($types -contains 'sourcerename') { return 'delete' }
        if ($types -contains 'rename') { return 'rename' }
        if ($types -contains 'add') { return 'add' }
        if ($types -contains 'branch') { return 'branch' }
        if ($types -contains 'undelete') { return 'undelete' }
        if ($types -contains 'merge') { return 'merge' }

        return 'edit'
    }

    # --- Enrich each changeset ---

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

    foreach ($cs in $changesets) {
        $index++

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

        try {
            $changes = @(Get-TfvcChangesetChanges -Connection $conn -ChangesetId $cs.changesetId)
            $workItems = @(Get-TfvcChangesetWorkItems -Connection $conn -ChangesetId $cs.changesetId)
        }
        catch {
            Write-MigrationLog -Message "ERROR fetching details for changeset $($cs.changesetId): $_" -Level ERROR -LogFile $logFile
            throw
        }

        # Filter to in-scope file changes
        $scopedChanges = [System.Collections.Generic.List[object]]::new()

        foreach ($change in $changes) {
            if ($null -eq $change.psobject.Properties['item']) { continue }
            if ($null -eq $change.item.psobject.Properties['path']) { continue }
            $serverPath = $change.item.path
            if (-not $serverPath) { continue }

            $isFolder = ($null -ne $change.item.psobject.Properties['isFolder'] -and $change.item.isFolder -eq $true)
            $isTree = ($null -ne $change.item.psobject.Properties['gitObjectType'] -and $change.item.gitObjectType -eq 'tree')
            $changeType = Get-PrimaryChangeType -RawChangeType $change.changeType

            if ($isFolder -or $isTree) {
                # Check if this folder change affects any of our mappings
                $affectedMappings = @()
                foreach ($m in $config.sourceMappings) {
                    if ($serverPath -eq $m.tfvcPath -or $serverPath.StartsWith("$($m.tfvcPath)/", 'CurrentCultureIgnoreCase')) {
                        $affectedMappings += $m
                    } elseif ($m.tfvcPath.StartsWith("$serverPath/", 'CurrentCultureIgnoreCase')) {
                        $affectedMappings += $m
                    }
                }
                if ($affectedMappings.Count -gt 0) {
                    if ($changeType -in @('add', 'branch', 'rename', 'undelete')) {
                        foreach ($m in $affectedMappings) {
                            $fetchPath = if ($m.tfvcPath.StartsWith("$serverPath/", 'CurrentCultureIgnoreCase')) { $m.tfvcPath } else { $serverPath }
                            $items = Get-TfvcItems -Connection $conn -ScopePath $fetchPath -ChangesetVersion $cs.changesetId -RecursionLevel 'Full'
                            foreach ($item in $items) {
                                if ($null -ne $item.psobject.Properties['isFolder'] -and $item.isFolder -eq $true) { continue }
                                if ($null -ne $item.psobject.Properties['gitObjectType'] -and $item.gitObjectType -eq 'tree') { continue }
                                $destPath = ConvertTo-RelativePath -ServerPath $item.path -TfvcBase $m.tfvcPath -DestinationPrefix $(if ($m.destinationPath) { $m.destinationPath } else { '' })
                                if ($destPath) {
                                    $scopedChanges.Add([PSCustomObject]@{ changeType = 'add'; serverPath = $item.path; destinationPath = $destPath; sourceServerPath = $null })
                                }
                            }
                        }
                    } elseif ($changeType -eq 'delete') {
                        foreach ($m in $affectedMappings) {
                            $fetchPath = if ($m.tfvcPath.StartsWith("$serverPath/", 'CurrentCultureIgnoreCase')) { $m.tfvcPath } else { $serverPath }
                            $prev = $cs.changesetId - 1
                            if ($prev -gt 0) {
                                $items = Get-TfvcItems -Connection $conn -ScopePath $fetchPath -ChangesetVersion $prev -RecursionLevel 'Full'
                                foreach ($item in $items) {
                                    if ($null -ne $item.psobject.Properties['isFolder'] -and $item.isFolder -eq $true) { continue }
                                    if ($null -ne $item.psobject.Properties['gitObjectType'] -and $item.gitObjectType -eq 'tree') { continue }
                                    $destPath = ConvertTo-RelativePath -ServerPath $item.path -TfvcBase $m.tfvcPath -DestinationPrefix $(if ($m.destinationPath) { $m.destinationPath } else { '' })
                                    if ($destPath) {
                                        $scopedChanges.Add([PSCustomObject]@{ changeType = 'delete'; serverPath = $item.path; destinationPath = $destPath; sourceServerPath = $null })
                                    }
                                }
                            }
                        }
                    }
                }
                continue
            }

            # Must be under one of our configured paths
            $mapping = Find-SourceMapping -ServerPath $serverPath
            if (-not $mapping) { continue }

            $destPath = ConvertTo-RelativePath -ServerPath $serverPath -TfvcBase $mapping.tfvcPath -DestinationPrefix $(if ($mapping.destinationPath) { $mapping.destinationPath } else { '' })
            if (-not $destPath) { continue }

            $sourceServerPath = $null
            if ($changeType -eq 'rename' -and $null -ne $change.psobject.Properties['sourceServerItem']) {
                if ($null -ne $change.sourceServerItem.psobject.Properties['path']) {
                    $sourceServerPath = $change.sourceServerItem.path
                }
            }

            $scopedChanges.Add([PSCustomObject]@{
                changeType       = $changeType
                serverPath       = $serverPath
                destinationPath  = $destPath
                sourceServerPath = $sourceServerPath
            })
        }

        # Deduplicate changes for the same file in the same changeset
        $uniqueChanges = @{}
        foreach ($c in $scopedChanges) {
            if ($c.changeType -eq 'delete') {
                $uniqueChanges[$c.destinationPath] = $c
            } elseif (-not $uniqueChanges.ContainsKey($c.destinationPath)) {
                $uniqueChanges[$c.destinationPath] = $c
            } else {
                if ($c.changeType -ne 'add') {
                    $uniqueChanges[$c.destinationPath] = $c
                }
            }
        }
        $scopedChanges = $uniqueChanges.Values

        $wiList = @($workItems | ForEach-Object {
            [PSCustomObject]@{ id = $_.id; title = $_.title }
        })

        $authorName = "$($cs.author)"
        if ($null -ne $cs.psobject.Properties['author']) {
            if ($null -ne $cs.author.psobject.Properties['displayName']) {
                $authorName = $cs.author.displayName
            } elseif ($null -ne $cs.author.psobject.Properties['uniqueName']) {
                $authorName = $cs.author.uniqueName
            }
        }

        $exportedChangesets.Add([PSCustomObject]@{
            changesetId = $cs.changesetId
            author      = $authorName
            createdDate = $cs.createdDate
            comment     = $cs.comment
            workItems   = $wiList
            changes     = @($scopedChanges)
        })

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

    # --- 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
}