Private/TfvcApi.ps1
|
<#
.SYNOPSIS Azure DevOps TFVC REST API wrapper for migration tooling. .DESCRIPTION Provides functions to query changesets, list items, and download file content from TFVC repositories via the Azure DevOps Server REST API (v7.0). Designed for Azure DevOps Server 2022 on-premises. Supports PAT or Windows Auth. These functions are loaded into the module's private scope and are NOT exported. #> # --- Connection --- function New-TfvcConnection { [CmdletBinding()] param( [Parameter(Mandatory)][string]$ServerUrl, [Parameter(Mandatory)][string]$Collection, [Parameter(Mandatory)][string]$Project, [string]$Pat = "", [string]$ApiVersion = "7.0" ) $server = $ServerUrl.TrimEnd('/') $headers = @{} if ($Pat) { $base64 = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(":$Pat")) $headers.Authorization = "Basic $base64" } $col = [uri]::EscapeDataString($Collection) $proj = "" if ($Project) { $proj = "/" + [uri]::EscapeDataString($Project) } @{ BaseUrl = "$server/$col" ApiVersion = $ApiVersion Headers = $headers UseDefaultCredentials = (-not $Pat) } } # --- Low-level API --- function Invoke-TfvcApi { [CmdletBinding()] param( [Parameter(Mandatory)][hashtable]$Connection, [Parameter(Mandatory)][string]$Endpoint, [hashtable]$QueryParams = @{}, [int]$MaxRetries = 3 ) $params = @{} + $QueryParams $params['api-version'] = $Connection.ApiVersion $qs = ($params.GetEnumerator() | Where-Object { $null -ne $_.Value } | ForEach-Object { [uri]::EscapeDataString($_.Key) + '=' + [uri]::EscapeDataString("$($_.Value)") }) -join '&' $url = "$($Connection.BaseUrl)/_apis/tfvc/${Endpoint}?${qs}" for ($i = 1; $i -le $MaxRetries; $i++) { Write-Verbose "GET $url" try { if ($Connection.UseDefaultCredentials) { return Invoke-RestMethod -Uri $url -Headers $Connection.Headers -Method Get -UseDefaultCredentials } else { return Invoke-RestMethod -Uri $url -Headers $Connection.Headers -Method Get } } catch { $code = if ($_.Exception.Response) { [int]$_.Exception.Response.StatusCode } else { 0 } if ($code -in 400, 401, 403, 404 -or $i -eq $MaxRetries) { $errBody = "" if ($_.Exception.Response) { try { $stream = $_.Exception.Response.GetResponseStream() $reader = New-Object System.IO.StreamReader($stream) $errBody = $reader.ReadToEnd() } catch {} } if ($errBody) { throw "$($_.Exception.Message) - Body: $errBody" } throw } $delay = [Math]::Min([Math]::Pow(2, $i), 30) Write-Warning "API call failed (attempt $i/$MaxRetries), retrying in ${delay}s..." Start-Sleep -Seconds $delay } } } # --- Changesets --- function Get-TfvcChangesets { <# .SYNOPSIS Fetches a single page of changesets, optionally filtered by path. #> [CmdletBinding()] param( [Parameter(Mandatory)][hashtable]$Connection, [string]$ItemPath, [int]$Top = 100, [int]$Skip = 0, [int]$FromId, [int]$ToId ) $qp = @{ '$top' = $Top } if ($Skip -gt 0) { $qp['$skip'] = $Skip } if ($ItemPath) { $qp['searchCriteria.itemPath'] = $ItemPath } if ($FromId -gt 0) { $qp['searchCriteria.fromId'] = $FromId } if ($ToId -gt 0) { $qp['searchCriteria.toId'] = $ToId } $result = Invoke-TfvcApi -Connection $Connection -Endpoint 'changesets' -QueryParams $qp if ($result.value) { $result.value } else { @() } } function Get-TfvcAllChangesets { <# .SYNOPSIS Fetches ALL changesets for a path using efficient ID-range pagination. Returns in ascending order (oldest first). .PARAMETER ResumeAfterId If specified, only fetches changesets with ID > this value (for resume). #> [CmdletBinding()] param( [Parameter(Mandatory)][hashtable]$Connection, [Parameter(Mandatory)][string]$ItemPath, [int]$ResumeAfterId = 0 ) $all = [System.Collections.Generic.List[object]]::new() $ceiling = 0 do { $p = @{ Connection = $Connection; ItemPath = $ItemPath; Top = 100 } if ($ceiling -gt 0) { $p.ToId = $ceiling } if ($ResumeAfterId -gt 0) { $p.FromId = $ResumeAfterId } $batch = @(Get-TfvcChangesets @p) if ($batch.Count -eq 0) { break } $all.AddRange($batch) $min = ($batch | Measure-Object -Property changesetId -Minimum).Minimum $ceiling = $min - 1 Write-Host " Fetched $($all.Count) changesets so far..." if ($ceiling -le 0) { break } } while ($batch.Count -eq 100) $all | Sort-Object changesetId } function Get-TfvcChangesetChanges { <# .SYNOPSIS Gets all file changes in a changeset (paginated). #> [CmdletBinding()] param( [Parameter(Mandatory)][hashtable]$Connection, [Parameter(Mandatory)][int]$ChangesetId ) $all = [System.Collections.Generic.List[object]]::new() $skip = 0 do { $result = Invoke-TfvcApi -Connection $Connection ` -Endpoint "changesets/$ChangesetId/changes" ` -QueryParams @{ '$top' = 100; '$skip' = $skip } if (-not $result.value -or $result.value.Count -eq 0) { break } $all.AddRange($result.value) $skip += $result.value.Count } while ($result.value.Count -eq 100) $all } function Get-TfvcChangesetWorkItems { <# .SYNOPSIS Gets work items linked to a changeset. #> [CmdletBinding()] param( [Parameter(Mandatory)][hashtable]$Connection, [Parameter(Mandatory)][int]$ChangesetId ) $result = Invoke-TfvcApi -Connection $Connection -Endpoint "changesets/$ChangesetId/workItems" if ($result.value) { $result.value } else { @() } } # --- Items --- function Get-TfvcItems { <# .SYNOPSIS Lists files and folders at a specific path and version. #> [CmdletBinding()] param( [Parameter(Mandatory)][hashtable]$Connection, [Parameter(Mandatory)][string]$ScopePath, [ValidateSet('None', 'OneLevel', 'Full')] [string]$RecursionLevel = 'Full', [int]$ChangesetVersion = 0 ) $qp = @{ scopePath = $ScopePath recursionLevel = $RecursionLevel } if ($ChangesetVersion -gt 0) { $qp['versionDescriptor.versionType'] = 'changeset' $qp['versionDescriptor.version'] = $ChangesetVersion } $result = Invoke-TfvcApi -Connection $Connection -Endpoint 'items' -QueryParams $qp if ($result.value) { $result.value } else { @() } } function Save-TfvcItemContent { <# .SYNOPSIS Downloads a file from TFVC and saves it to disk. Uses Invoke-WebRequest with -OutFile for correct binary handling. #> [CmdletBinding()] param( [Parameter(Mandatory)][hashtable]$Connection, [Parameter(Mandatory)][string]$ServerPath, [Parameter(Mandatory)][string]$OutputPath, [int]$ChangesetVersion = 0, [int]$MaxRetries = 3 ) $qp = @{ path = $ServerPath 'api-version' = $Connection.ApiVersion } if ($ChangesetVersion -gt 0) { $qp['versionDescriptor.versionType'] = 'changeset' $qp['versionDescriptor.version'] = $ChangesetVersion } $qs = ($qp.GetEnumerator() | ForEach-Object { [uri]::EscapeDataString($_.Key) + '=' + [uri]::EscapeDataString("$($_.Value)") }) -join '&' $url = "$($Connection.BaseUrl)/_apis/tfvc/items?$qs" $dir = Split-Path $OutputPath -Parent if ($dir -and -not (Test-Path $dir)) { New-Item -Path $dir -ItemType Directory -Force | Out-Null } for ($i = 1; $i -le $MaxRetries; $i++) { Write-Verbose "GET $url" try { if ($Connection.UseDefaultCredentials) { Invoke-WebRequest -Uri $url -Headers $Connection.Headers -OutFile $OutputPath -UseBasicParsing -UseDefaultCredentials } else { Invoke-WebRequest -Uri $url -Headers $Connection.Headers -OutFile $OutputPath -UseBasicParsing } return } catch { $code = if ($_.Exception.Response) { [int]$_.Exception.Response.StatusCode } else { 0 } if ($code -in 400, 401, 403, 404 -or $i -eq $MaxRetries) { throw } Start-Sleep -Seconds ([Math]::Pow(2, $i)) } } } # --- Helpers --- function ConvertTo-RelativePath { <# .SYNOPSIS Converts a TFVC server path to a relative path for the Git repo. .EXAMPLE ConvertTo-RelativePath -ServerPath '$/Project/App/src/file.cs' -TfvcBase '$/Project/App' -DestinationPrefix 'App' # Returns: 'App/src/file.cs' #> param( [Parameter(Mandatory)][string]$ServerPath, [Parameter(Mandatory)][string]$TfvcBase, [string]$DestinationPrefix = '' ) $s = $ServerPath.Replace('\', '/').TrimEnd('/') $b = $TfvcBase.Replace('\', '/').TrimEnd('/') if (-not $s.StartsWith($b, [StringComparison]::OrdinalIgnoreCase)) { return $null } $rel = $s.Substring($b.Length).TrimStart('/') if (-not $rel) { return $null } # path IS the base (folder itself) if ($DestinationPrefix) { $rel = "$($DestinationPrefix.TrimEnd('/'))/$rel" } $rel } function Write-MigrationLog { param( [Parameter(Mandatory)][string]$Message, [ValidateSet('INFO', 'WARN', 'ERROR')][string]$Level = 'INFO', [string]$LogFile ) $ts = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' $line = "[$ts] [$Level] $Message" Write-Host $line if ($LogFile) { $line | Add-Content -Path $LogFile -Encoding UTF8 } } |