Private/TestCaseManagement/Sync-TcmTestCaseToRemote.ps1

function Sync-TcmTestCaseToRemote {
    <#
        .SYNOPSIS
            Pushes a local test case to Azure DevOps.

        .PARAMETER InputObject
            The resolved test case object from ConvertTo-TcmTestCaseInput with PSTypeName 'PSTypeNames.AzureDevOpsApi.TcmTestCaseExtended'.

        .PARAMETER TestCasesRoot
            Root directory for test cases. If not specified, uses the default TestCases directory.

        .PARAMETER Force
            Force push even if there are remote changes (overwrite remote).

        .EXAMPLE
            "TC001" | ConvertTo-TcmTestCaseInput | Sync-TcmTestCaseToRemote

        .EXAMPLE
            "TestCases/area/TC001.yaml" | ConvertTo-TcmTestCaseInput | Sync-TcmTestCaseToRemote

        .EXAMPLE
            Get-ChildItem "TestCases/*.yaml" | ConvertTo-TcmTestCaseInput | Sync-TcmTestCaseToRemote

        .EXAMPLE
            "TC001" | ConvertTo-TcmTestCaseInput | Sync-TcmTestCaseToRemote -Force
    #>


    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [ValidateScript({ $_.PSTypeNames -contains $global:PSTypeNames.AzureDevOpsApi.TcmTestCaseExtended })]
        $InputObject,

        [string] $TestCasesRoot,

        [switch] $Force,

        [string] $Message,

        [string] $MessageColor = 'Cyan',

        [string] $ShouldProcessOperation = "Push to Azure DevOps"
    )

    begin {
        $Force = $Force.IsPresent -and ($true -eq $Force)

        # Get configuration
        $config = Get-TcmTestCaseConfig -TestCasesRoot $TestCasesRoot

        # Get credentials and project info
        $collectionUri = $config.azureDevOps.collectionUri
        $project = $config.azureDevOps.project

        if (-not $collectionUri -or -not $project) {
            throw "Azure DevOps collectionUri and project must be configured in config.yaml"
        }
    }

    process {
        # Validate input: function expects already-resolved test case input (LocalData present)
        if (-not $InputObject -or -not $InputObject.LocalData) {
            throw "Invalid input: InputObject must be a resolved TcmTestCaseExtended with LocalData populated"
        }

        $testCaseData = $InputObject.LocalData
        $Id = if ($InputObject.Id) { $InputObject.Id } else { $testCaseData.id }
        $localPath = $InputObject.FilePath

        try {
            # Display message if provided
            if ($Message) {
                Write-Host "→ $Message" -ForegroundColor $MessageColor
            }

            Write-Verbose "Pushing test case '$Id' to Azure DevOps..."
            Write-Verbose "CollectionUri: $collectionUri"
            Write-Verbose "Project: $project"

            # Get sync status
            $resolved = Resolve-TcmTestCaseSyncStatus -InputObject $InputObject -Config $config
            $syncStatus = $resolved.SyncStatus

            # Check for conflicts
            if ($syncStatus -eq 'conflict' -and -not $Force) {
                throw "Test case '$Id' has conflicts. Use -Force to overwrite remote or run Resolve-TcmTestCaseConflict first."
            }

            if ($syncStatus -eq 'synced' -and -not $Force) {
                Write-Host "Test case '$Id' is already synced. No push needed." -ForegroundColor Green
                return
            }

            # Convert test steps to Azure DevOps XML format
            $stepsXml = ConvertTo-TestStepsXml -Steps $testCaseData.steps

            # Prepare work item fields (LocalData contains the test case structure)
            $fields = @{
                'System.Title'                        = $testCaseData.title
                'System.AreaPath'                     = $testCaseData.areaPath
                'System.IterationPath'                = $testCaseData.iterationPath
                'System.State'                        = $testCaseData.state
                'Microsoft.VSTS.Common.Priority'      = $testCaseData.priority
                'System.Description'                  = $testCaseData.description
                'Microsoft.VSTS.TCM.LocalDataSource'  = $testCaseData.preconditions
                'Microsoft.VSTS.TCM.Steps'            = $stepsXml
                'Microsoft.VSTS.TCM.AutomationStatus' = $testCaseData.automationStatus
            }

            # Add tags if present
            if ($testCaseData.tags -and $testCaseData.tags.Count -gt 0) {
                $fields['System.Tags'] = $testCaseData.tags -join ';'
            }

            # Add assigned to if present
            if ($testCaseData.assignedTo) {
                $fields['System.AssignedTo'] = $testCaseData.assignedTo
            }

            # Add custom fields
            if ($testCaseData.customFields) {
                foreach ($key in $testCaseData.customFields.Keys) {
                    $fields[$key] = $testCaseData.customFields[$key]
                }
            }

            # Create or update work item
            # The $Id is the Work Item ID if numeric, otherwise treated as a local-only id
            $remoteWorkItem = $null
            if ($Id -match '^\d+$') {
                # Try to fetch existing work item (ID is numeric, so it might be a Work Item ID)
                try {
                    $remoteWorkItem = Get-WorkItem `
                        -WorkItem $Id `
                        -CollectionUri $collectionUri `
                        -Project $project `
                        -ErrorAction Stop
                } catch {
                    # Work item doesn't exist - will create it below
                    Write-Verbose "Work item $Id not found, will create new work item"
                    $remoteWorkItem = $null
                }
            }

            if ($remoteWorkItem) {
                # Update existing work item
                if ($PSCmdlet.ShouldProcess("Test case '$Id'", $ShouldProcessOperation)) {

                    # Create a minimal patch document for update (avoid copying source field objects)
                    $workItemType = $remoteWorkItem.fields.'System.WorkItemType'
                    $patchDoc = New-PatchDocument -WorkItemType $workItemType -Data $fields

                    # Ensure patch document contains WorkItemUrl so Update-WorkItem can resolve connection
                    $patchDoc.WorkItemUrl = $remoteWorkItem.url

                    # Add a test operation for current revision to avoid races
                    if ($remoteWorkItem.rev) {
                        $patchDoc.Operations += [PSCustomObject]@{
                            op    = 'test'
                            path  = '/rev'
                            value = "$($remoteWorkItem.rev)"
                        }
                    }

                    # Send patch document to Update-WorkItem
                    $workItem = $patchDoc | Update-WorkItem -ErrorAction Stop

                    Write-Host "Updated test case '$Id' in Azure DevOps (Work Item: $Id)" -ForegroundColor Green

                    # Update cache: after push, local and remote should match
                    $localHash = Get-TcmStringHash -InputObject $InputObject.LocalData
                    Update-TcmHashCacheEntry `
                        -TestCasesRoot $config.TestCasesRoot `
                        -TestCaseId $workItem.id `
                        -Hash $localHash
                }
            } else {
                # Create new work item (ID is not found remotely or not numeric)
                if ($PSCmdlet.ShouldProcess("Test case '$Id'", $ShouldProcessOperation)) {
                    Write-Verbose "Creating new work item with CollectionUri='$collectionUri' and Project='$project'"

                    # Build patch document for creation
                    $patchDoc = New-PatchDocumentCreate -WorkItemType "Test Case" -Data $fields

                    # Create the work item in the project and collection
                    $workItem = $patchDoc | New-WorkItem -Project $project -CollectionUri $collectionUri -ErrorAction Stop

                    Write-Host "Created test case '$Id' in Azure DevOps (Work Item: $($workItem.id))" -ForegroundColor Green

                    # Update testCase in YAML file with Azure DevOps ID and ensure title is preserved
                    # Create a fresh ordered testCase block with the new work item id
                    $updatedTestCase = [ordered]@{
                        id               = $workItem.id
                        title            = $fields.'System.Title'
                        areaPath         = $testCaseData.testCase.areaPath
                        iterationPath    = $testCaseData.testCase.iterationPath
                        tags             = $testCaseData.testCase.tags
                        assignedTo       = $testCaseData.testCase.assignedTo
                        description      = $testCaseData.testCase.description
                        state            = $testCaseData.testCase.state
                        customFields     = $testCaseData.testCase.customFields
                        preconditions    = $testCaseData.testCase.preconditions
                        priority         = $testCaseData.testCase.priority
                        automationStatus = $testCaseData.testCase.automationStatus
                        steps            = $testCaseData.testCase.steps
                    }

                    $updatedData = [ordered]@{ testCase = $updatedTestCase }
                    if ($testCaseData.PSObject.Properties.Name -contains 'history') {
                        $updatedData.history = $testCaseData.history
                    }

                    # Keep in-memory representation in sync
                    $testCaseData.testCase = $updatedTestCase

                    # Build new filename prefixed with server id
                    $newWorkItemId = [int]$workItem.id
                    $title = $updatedTestCase.title -replace '[^\w\s-]', '' -replace '\s+', '-'
                    $newFileName = "$newWorkItemId-$title".ToLower() + ".yaml"
                    $newFullPath = Join-Path (Split-Path -Parent $localPath) $newFileName

                    # Only rename if the filename is different
                    if ($localPath -ne $newFullPath) {
                        # Replace existing file by new content and rename without losing data on failure
                        $tmpPath = [System.IO.Path]::GetTempFileName()
                        try {
                            Save-TcmTestCaseYaml -FilePath $tmpPath -Data $updatedData -TestCasesRoot $config.TestCasesRoot

                            Move-Item -Path $localPath -Destination ($localPath + '.bak') -Force
                            Move-Item -Path $tmpPath -Destination $newFullPath -Force
                            Remove-Item -Path ($localPath + '.bak') -Force -ErrorAction SilentlyContinue
                        } catch {
                            Remove-Item -Path $tmpPath -Force -ErrorAction SilentlyContinue
                            throw
                        }

                        # Update variables to reflect renamed file
                        $localPath = $newFullPath
                    } else {
                        # File already has correct name, just update content
                        Save-TcmTestCaseYaml -FilePath $localPath -Data $updatedData
                    }

                    # Update cache: after push, local and remote should match
                    # Re-read from disk to get the actual file content hash
                    $savedTestCase = Get-TcmTestCaseFromFile -FilePath $localPath
                    $savedHash = Get-TcmStringHash -InputObject $savedTestCase
                    Update-TcmHashCacheEntry `
                        -TestCasesRoot $config.TestCasesRoot `
                        -TestCaseId $workItem.id `
                        -Hash $savedHash
                }
            }

            Write-Verbose "Test case '$($workItem.id)' pushed successfully"

        } catch {
            Write-Error "Failed to push test case '$Id': $($_.Exception.Message)"
            throw
        }
    }
}