internal/functions/invoke-adoworkitemsprocessing.ps1
<# .SYNOPSIS Processes (creates/updates) a source work item in the target org and optionally migrates its attachments. .DESCRIPTION Idempotent: if work item already created (mapping exists) it is reused; attachments are deduplicated by filename. Steps: (1) ensure parent link, (2) create WI without state, (3) patch mapped state, (4) migrate attachments (rel='AttachedFile'). .PARAMETER SourceWorkItem Source work item object (fields + Relations if migrating attachments). .PARAMETER SourceOrganization Source organization name. .PARAMETER SourceProjectName Source project name. .PARAMETER SourceToken Source PAT. .PARAMETER TargetOrganization Target organization name. .PARAMETER TargetProjectName Target project name. .PARAMETER TargetToken Target PAT (needs write & attachment scopes). .PARAMETER TargetWorkItemList Reference to hashtable mapping SourceId -> target WI URL. .PARAMETER ApiVersion API version (default module value). .PARAMETER MigrateAttachments Enable attachment migration (default $true). .PARAMETER AttachmentTempFolder Temp folder for downloaded attachments. .EXAMPLE Invoke-ADOWorkItemsProcessing -SourceWorkItem $wi -SourceOrganization src -SourceProjectName proj -SourceToken $patSrc ` -TargetOrganization tgt -TargetProjectName proj2 -TargetToken $patTgt -TargetWorkItemList ([ref]$map) Processes a single work item, creating it in the target if not already mapped, migrating attachments if enabled. .NOTES Author: Oleksandr Nikolaiev (@onikolaiev) #> function Invoke-ADOWorkItemsProcessing { [CmdletBinding()] param( [Parameter(Mandatory)][pscustomobject]$SourceWorkItem, [Parameter(Mandatory)][string]$SourceOrganization, [Parameter(Mandatory)][string]$SourceProjectName, [Parameter(Mandatory)][string]$SourceToken, [Parameter(Mandatory)][string]$TargetOrganization, [Parameter(Mandatory)][string]$TargetProjectName, [Parameter(Mandatory)][string]$TargetToken, [Parameter(Mandatory)][ref]$TargetWorkItemList, [Parameter()][string]$ApiVersion = $Script:ADOApiVersion, [Parameter()][bool]$MigrateAttachments = $true, [Parameter()][string]$AttachmentTempFolder = (Join-Path $env:TEMP 'ado.tools.attachments') ) begin { Write-PSFMessage -Level Host -Message "Processing work item ID: $($SourceWorkItem.'System.Id'). Title: $($SourceWorkItem.'System.Title')." if (-not $script:ADOValidWorkItemStatesCache) { $script:ADOValidWorkItemStatesCache = @{} } if (-not $script:ADOWorkItemProcessingAttempts) { $script:ADOWorkItemProcessingAttempts = @{} } } process { $buildPatchBody = { param($stateValue) $ops = @( @{ op='add'; path='/fields/System.Title'; value = "$($SourceWorkItem.'System.Title')" } @{ op='add'; path='/fields/System.Description'; value = "$($SourceWorkItem.'System.Description')" } @{ op='add'; path='/fields/Custom.SourceWorkitemId'; value = "$($SourceWorkItem.'System.Id')" } ) if ($stateValue) { $ops += @{ op='add'; path='/fields/System.State'; value=$stateValue } } $ops = $ops | Where-Object { if ($_.path -eq '/fields/System.Description' -and [string]::IsNullOrWhiteSpace($_.value)) { $false } else { $true } } if ($SourceWorkItem.'System.Parent') { if (-not $TargetWorkItemList.Value[$SourceWorkItem.'System.Parent']) { if (-not $script:ADOWorkItemProcessingAttempts.ContainsKey($SourceWorkItem.'System.Parent')) { $allSourceItems = Get-ADOSourceWorkItemsList -SourceOrganization $SourceOrganization -SourceProjectName $SourceProjectName -SourceToken $SourceToken $parentItem = $allSourceItems | Where-Object { $_.'System.Id' -eq $SourceWorkItem.'System.Parent' } if ($parentItem) { $script:ADOWorkItemProcessingAttempts[$SourceWorkItem.'System.Parent'] = 1 Invoke-ADOWorkItemsProcessing -SourceWorkItem $parentItem -SourceOrganization $SourceOrganization -SourceProjectName $SourceProjectName -SourceToken $SourceToken -TargetOrganization $TargetOrganization -TargetProjectName $TargetProjectName -TargetToken $TargetToken -TargetWorkItemList $TargetWorkItemList -ApiVersion $ApiVersion -MigrateAttachments:$MigrateAttachments -AttachmentTempFolder $AttachmentTempFolder } } } if ($TargetWorkItemList.Value[$SourceWorkItem.'System.Parent']) { $ops += @{ op='add'; path='/relations/-'; value=@{ rel='System.LinkTypes.Hierarchy-Reverse'; url=$TargetWorkItemList.Value[$SourceWorkItem.'System.Parent']; attributes=@{ comment='Parent link' } } } } } $ops | ConvertTo-Json -Depth 10 } $existingTargetUrl = $TargetWorkItemList.Value[$SourceWorkItem.'System.Id'] $createdItem = $null $originalState = $SourceWorkItem.'System.State' $witType = $SourceWorkItem.'System.WorkItemType' $autoMappedState = $null if ($script:ADOStateAutoMap) { $mappingKey = "$witType|$originalState" if ($script:ADOStateAutoMap.ContainsKey($mappingKey)) { $autoMappedState = $script:ADOStateAutoMap[$mappingKey] if ($autoMappedState -and $autoMappedState -ne $originalState) { Write-PSFMessage -Level Verbose -Message "Auto-mapped '$originalState' -> '$autoMappedState' for type '$witType'." } } } if (-not $existingTargetUrl) { $creationBody = & $buildPatchBody $null Write-PSFMessage -Level Verbose -Message "Phase 1: Creating work item (no explicit state) SourceID=$($SourceWorkItem.'System.Id')." try { $createdItem = Add-ADOWorkItem -Organization $TargetOrganization -Token $TargetToken -Project $TargetProjectName -Type "`$$($SourceWorkItem.'System.WorkItemType')" -Body $creationBody -ApiVersion $ApiVersion -ErrorAction Stop if ($createdItem.url) { $TargetWorkItemList.Value[$SourceWorkItem.'System.Id'] = $createdItem.url } } catch { Write-PSFMessage -Level Error -Message "Create failed SourceID=$($SourceWorkItem.'System.Id'): $($_.Exception.Message)" } } else { Write-PSFMessage -Level Verbose -Message "SourceID=$($SourceWorkItem.'System.Id') already mapped. Skipping creation." try { $existingId = [int]($existingTargetUrl.Split('/')[-1]); $createdItem = Get-ADOWorkItem -Organization $TargetOrganization -Project $TargetProjectName -Token $TargetToken -Id $existingId -Expand Relations -ApiVersion $ApiVersion } catch { Write-PSFMessage -Level Verbose -Message "Lookup of existing target work item failed SourceID=$($SourceWorkItem.'System.Id'): $($_.Exception.Message)" } } if ($createdItem -and $createdItem.url) { $isNew = (-not $existingTargetUrl) if ($isNew -and $autoMappedState) { $current = $createdItem.fields.'System.State' if ($autoMappedState -and $autoMappedState -ne $current) { try { $patchBody = @(@{ op='add'; path='/fields/System.State'; value="$autoMappedState" }) | ConvertTo-Json -Depth 4 Update-ADOWorkItem -Organization $TargetOrganization -Token $TargetToken -Project $TargetProjectName -Id $createdItem.id -Body [$patchBody] -ApiVersion $ApiVersion | Out-Null try { $createdItem = Get-ADOWorkItem -Organization $TargetOrganization -Project $TargetProjectName -Token $TargetToken -Id $createdItem.id -Expand Relations -ApiVersion $ApiVersion } catch { Write-PSFMessage -Level Verbose -Message "Post-state refresh failed SourceID=$($SourceWorkItem.'System.Id'): $($_.Exception.Message)" } } catch { Write-PSFMessage -Level Warning -Message "State patch failed (SourceID=$($SourceWorkItem.'System.Id')): $($_.Exception.Message)" } } } if ($MigrateAttachments) { try { $sourceAttachments = @($SourceWorkItem.Relations | Where-Object { $_.rel -eq 'AttachedFile' }) if ($sourceAttachments.Count -gt 0) { if (-not (Test-Path -LiteralPath $AttachmentTempFolder)) { $null = New-Item -ItemType Directory -Force -Path $AttachmentTempFolder } if (-not $createdItem.relations) { try { $createdItem = Get-ADOWorkItem -Organization $TargetOrganization -Project $TargetProjectName -Token $TargetToken -Id $createdItem.id -Expand Relations -ApiVersion $ApiVersion } catch { Write-PSFMessage -Level Verbose -Message "Initial relations fetch failed SourceID=$($SourceWorkItem.'System.Id'): $($_.Exception.Message)" } } $existingNames = @() if ($createdItem.relations) { $existingNames = @($createdItem.relations | Where-Object rel -eq 'AttachedFile' | ForEach-Object { $_.attributes.name; $_.attributes.resourceName } | Where-Object { $_ }) | Select-Object -Unique $existingNames = $existingNames | ForEach-Object { $_.ToLowerInvariant() } } foreach ($att in $sourceAttachments) { $fileName = $att.attributes.name; if (-not $fileName) { $fileName = $att.attributes.resourceName }; if (-not $fileName) { $fileName = 'attachment.bin' } $norm = $fileName.ToLowerInvariant() if ($existingNames -contains $norm) { continue } $attUrl = $att.url; if ($attUrl -notmatch '([0-9a-fA-F-]{36})') { continue }; $attId = $Matches[1] $downloadPath = Join-Path $AttachmentTempFolder $fileName try { Get-ADOWorkItemAttachment -Organization $SourceOrganization -Token $SourceToken -Id $attId -OutFile $downloadPath -ApiVersion $ApiVersion | Out-Null $uploaded = Add-ADOWorkItemAttachment -Organization $TargetOrganization -Project $TargetProjectName -Token $TargetToken -FilePath $downloadPath -FileName $fileName -ApiVersion $ApiVersion $rev = $createdItem.rev $patch = @( @{ op='test'; path='/rev'; value=$rev }, @{ op='add'; path='/relations/-'; value=@{ rel='AttachedFile'; url=$uploaded.url; attributes=@{ comment="Migrated from source $($SourceWorkItem.'System.Id')"; name=$fileName } } } ) | ConvertTo-Json -Depth 8 Update-ADOWorkItem -Organization $TargetOrganization -Token $TargetToken -Project $TargetProjectName -Id $createdItem.id -Body [$patch] -ApiVersion $ApiVersion | Out-Null try { $createdItem = Get-ADOWorkItem -Organization $TargetOrganization -Project $TargetProjectName -Token $TargetToken -Id $createdItem.id -Expand Relations -ApiVersion $ApiVersion } catch { Write-PSFMessage -Level Verbose -Message "Post-attachment refresh failed SourceID=$($SourceWorkItem.'System.Id'): $($_.Exception.Message)" } $existingNames += $norm } catch { Write-PSFMessage -Level Warning -Message "Attachment '$fileName' failed: $($_.Exception.Message)" } } } } catch { Write-PSFMessage -Level Warning -Message "Attachment phase failed SourceID=$($SourceWorkItem.'System.Id'): $($_.Exception.Message)" } } } else { Write-PSFMessage -Level Error -Message "Failed to create or retrieve target work item for SourceID=$($SourceWorkItem.'System.Id')" } } end { Write-PSFMessage -Level Host -Message "Completed processing of work item ID: $($SourceWorkItem.'System.Id')." } } |