internal/functions/invoke-adoworkitemstatemigration.ps1


<#
    .SYNOPSIS
        Migrates states for each inherited WIT.
    .DESCRIPTION
        Migrates states assigned to each inherited WIT in process.
        This includes copying states from the source WITs to the target WITs, ensuring that all customizations are preserved.
        Additionally, it builds an automatic state mapping to facilitate the migration of work items by mapping source states to the most appropriate target states based on name and category.
    .PARAMETER SourceOrganization
        The name of the source Azure DevOps organization.
    .PARAMETER TargetOrganization
        The name of the target Azure DevOps organization.
    .PARAMETER SourceToken
        The authentication token for accessing the source Azure DevOps organization.
    .PARAMETER TargetToken
        The authentication token for accessing the target Azure DevOps organization.
    .PARAMETER SourceProcess
        The source process object containing details about the process to migrate.
    .PARAMETER TargetProcess
        The target process object containing details about the process to migrate to.
    .PARAMETER SourceWitList
        The list of source work item types to migrate.
    .PARAMETER TargetWitList
        The list of target work item types to migrate to.
    .PARAMETER ApiVersion
        The version of the Azure DevOps REST API to use.
    .EXAMPLE
        $apiVersion = '7.1'
        $sourceOrg = 'srcOrg'
        $targetOrg = 'tgtOrg'
        $sourceToken = 'pat-src'
        $targetToken = 'pat-tgt'
        $sourceProjectName = 'Sample'
        $sourceProjectMeta = (Get-ADOProjectList -Organization $sourceOrg -Token $sourceToken -ApiVersion $apiVersion -StateFilter All) | Where-Object name -eq $sourceProjectName
        $sourceProject = Get-ADOProject -Organization $sourceOrg -Token $sourceToken -ProjectId $sourceProjectMeta.id -IncludeCapabilities -ApiVersion $apiVersion
        $proc = Invoke-ADOProcessMigration -SourceOrganization $sourceOrg -TargetOrganization $targetOrg -SourceToken $sourceToken -TargetToken $targetToken -SourceProject $sourceProject -ApiVersion $apiVersion
        $witResult = Invoke-ADOWorkItemTypeMigration -SourceOrganization $sourceOrg -TargetOrganization $targetOrg -SourceToken $sourceToken -TargetToken $targetToken -SourceProcess $proc.SourceProcess -TargetProcess $proc.TargetProcess -ApiVersion $apiVersion
         
        Invoke-ADOWorkItemStateMigration -SourceOrganization $sourceOrg -TargetOrganization $targetOrg `
            -SourceToken $sourceToken -TargetToken $targetToken -SourceProcess $proc.SourceProcess -TargetProcess $proc.TargetProcess `
            -SourceWitList $witResult.SourceList -TargetWitList $witResult.TargetList -ApiVersion $apiVersion
        # Migrates custom states and builds auto state mapping.
    .NOTES
        This function is part of the ADO Tools module and adheres to the conventions used in the module for logging, error handling, and API interaction.
        Author: Oleksandr Nikolaiev (@onikolaiev)
         
#>

function Invoke-ADOWorkItemStateMigration {
    [CmdletBinding()] param(
        [Parameter(Mandatory)][string]$SourceOrganization,
        [Parameter(Mandatory)][string]$TargetOrganization,
        [Parameter(Mandatory)][string]$SourceToken,
        [Parameter(Mandatory)][string]$TargetToken,
        [Parameter(Mandatory)][pscustomobject]$SourceProcess,
        [Parameter(Mandatory)][pscustomobject]$TargetProcess,
        [Parameter(Mandatory)][System.Collections.IEnumerable]$SourceWitList,
        [Parameter(Mandatory)][System.Collections.IEnumerable]$TargetWitList,
        [Parameter(Mandatory)][string]$ApiVersion
    )
    Convert-FSCPSTextToAscii -Text "Migrate states.." -Font "Standard"
    Write-PSFMessage -Level Host -Message "Starting to process states."
    if (-not $script:ADOStateAutoMap) { $script:ADOStateAutoMap = @{} }
    foreach ($wit in $SourceWitList) {
        Write-PSFMessage -Level Host -Message "Processing states for WIT '$($wit.name)'."
        $targetWit = $TargetWitList.Where({$_.name -eq $wit.name})
        if (-not $targetWit) { continue }
        $sourceStates = Get-ADOWorkItemTypeStateList -Organization $SourceOrganization -Token $SourceToken -ApiVersion $ApiVersion -ProcessId $SourceProcess.typeId -WitRefName $wit.referenceName
        $targetStates = Get-ADOWorkItemTypeStateList -Organization $TargetOrganization -Token $TargetToken -ApiVersion $ApiVersion -ProcessId $TargetProcess.typeId -WitRefName $targetWit.referenceName
        foreach ($state in $sourceStates) {
            Write-PSFMessage -Level Host -Message "Checking state '$($state.name)'."
            $existing = $targetStates.Where({$_.name -eq $state.name})
            $sourceState = Get-ADOWorkItemTypeState -Organization $SourceOrganization -Token $SourceToken -ApiVersion $ApiVersion -ProcessId $SourceProcess.typeId -WitRefName $wit.referenceName -StateId $state.id
            if (-not $existing) {
                Write-PSFMessage -Level Host -Message "State '$($state.name)' does not exist. Adding."
                
                # Calculate correct order based on existing states and categories
                $targetCategory = $sourceState.stateCategory
                
                # Find existing states in the same category
                $sameCategory = $targetStates | Where-Object { $_.stateCategory -eq $targetCategory }
                
                if ($sameCategory) {
                    # Add after the last state in the same category
                    $maxOrderInCategory = ($sameCategory | Measure-Object -Property order -Maximum).Maximum
                    $newOrder = $maxOrderInCategory + 1
                    Write-PSFMessage -Level Verbose -Message "Adding state to existing category '$targetCategory' with order $newOrder"
                } else {
                    # New category - try to find the right position by analyzing source order
                    $sourceStatesSorted = $sourceStates | Sort-Object order
                    $currentStateIndex = 0
                    for ($i = 0; $i -lt $sourceStatesSorted.Count; $i++) {
                        if ($sourceStatesSorted[$i].name -eq $sourceState.name) {
                            $currentStateIndex = $i
                            break
                        }
                    }
                    
                    # Look for existing target categories that come before this state in source
                    $insertAfterOrder = 0
                    for ($i = $currentStateIndex - 1; $i -ge 0; $i--) {
                        $prevSourceState = $sourceStatesSorted[$i]
                        $prevTargetStates = $targetStates | Where-Object { $_.stateCategory -eq $prevSourceState.stateCategory }
                        if ($prevTargetStates) {
                            $insertAfterOrder = ($prevTargetStates | Measure-Object -Property order -Maximum).Maximum
                            break
                        }
                    }
                    
                    # Look for existing target categories that come after this state in source
                    $insertBeforeOrder = [int]::MaxValue
                    for ($i = $currentStateIndex + 1; $i -lt $sourceStatesSorted.Count; $i++) {
                        $nextSourceState = $sourceStatesSorted[$i]
                        $nextTargetStates = $targetStates | Where-Object { $_.stateCategory -eq $nextSourceState.stateCategory }
                        if ($nextTargetStates) {
                            $insertBeforeOrder = ($nextTargetStates | Measure-Object -Property order -Minimum).Minimum
                            break
                        }
                    }
                    
                    if ($insertBeforeOrder -eq [int]::MaxValue) {
                        # Add at the end
                        $newOrder = $insertAfterOrder + 1
                    } else {
                        # Insert between categories
                        $newOrder = $insertAfterOrder + 1
                        if ($newOrder -ge $insertBeforeOrder) {
                            Write-PSFMessage -Level Warning -Message "Cannot insert state '$($sourceState.name)' between existing categories. Adding at end."
                            $maxOrder = ($targetStates | Measure-Object -Property order -Maximum).Maximum
                            $newOrder = $maxOrder + 1
                        }
                    }
                    Write-PSFMessage -Level Verbose -Message "Adding state to new category '$targetCategory' with order $newOrder (between $insertAfterOrder and $insertBeforeOrder)"
                }
                
                $body = @{ 
                    name = $sourceState.name
                    color = $sourceState.color
                    stateCategory = $sourceState.stateCategory
                    order = $newOrder
                } | ConvertTo-Json -Depth 10
                Write-PSFMessage -Level Verbose -Message "Adding state '$($sourceState.name)' to target process '$($TargetProcess.name)' with calculated order $newOrder (category: $($sourceState.stateCategory)). Body: $body"
                $new = Add-ADOWorkItemTypeState -Organization $TargetOrganization -Token $TargetToken -ApiVersion $ApiVersion -ProcessId $TargetProcess.typeId -WitRefName $targetWit.referenceName -Body $body
                # Refresh target states list after adding new state
                if ($new) {
                    $targetStates = Get-ADOWorkItemTypeStateList -Organization $TargetOrganization -Token $TargetToken -ApiVersion $ApiVersion -ProcessId $TargetProcess.typeId -WitRefName $targetWit.referenceName
                }
            } else { $new = $existing }
            if ($sourceState.hidden -and $sourceState.customizationType -eq "system") {
                try {
                    $null = Hide-ADOWorkItemTypeState -Organization $TargetOrganization -Token $TargetToken -ApiVersion $ApiVersion -ProcessId $TargetProcess.typeId -WitRefName $targetWit.referenceName -StateId $new.id -Hidden 'true' -WarningAction SilentlyContinue -ErrorAction SilentlyContinue
                } catch {
                    Write-PSFMessage -Level Warning -Message "Failed to hide state '$($sourceState.name)' (possibly already hidden)."
                }
            }
        }
        $targetStates = Get-ADOWorkItemTypeStateList -Organization $TargetOrganization -Token $TargetToken -ApiVersion $ApiVersion -ProcessId $TargetProcess.typeId -WitRefName $targetWit.referenceName
        $mapKeyPrefix = $wit.name + '|'
        $visibleTarget = $targetStates | Where-Object { -not $_.hidden }
        foreach ($s in $sourceStates) {
            $candidate = $null
            $exact = $visibleTarget | Where-Object { $_.name -eq $s.name }
            if ($exact) { $candidate = $exact | Select-Object -First 1 }
            else {
                if ($s.stateCategory) {
                    $catMatches = $visibleTarget | Where-Object { $_.stateCategory -eq $s.stateCategory }
                    if ($catMatches) { $candidate = ($catMatches | Sort-Object order | Select-Object -First 1) }
                }
                if (-not $candidate -and $visibleTarget) { $candidate = ($visibleTarget | Sort-Object order | Select-Object -First 1) }
            }
            if ($candidate) {
                $script:ADOStateAutoMap[$mapKeyPrefix + $s.name] = $candidate.name
            }
        }
        $stateMapDisplay = ($sourceStates | ForEach-Object { $_.name }) | ForEach-Object { $_ + '->' + ($script:ADOStateAutoMap[$mapKeyPrefix + $_]) }
        $stateMapJoined  = $stateMapDisplay -join '; '
        Write-PSFMessage -Level Verbose -Message ("State mapping for '{0}': {1}" -f $wit.name, $stateMapJoined)
    }
}