Public/TestCaseManagement/Sync-TcmTestCaseToRemote.ps1

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

        .PARAMETER InputObject
            The test case to push. Can be a test case ID (string), file path (string), or resolved object from Resolve-TcmTestCaseFilePathInput.

        .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
            Sync-TcmTestCaseToRemote -InputObject "TC001"

        .EXAMPLE
            Sync-TcmTestCaseToRemote -InputObject "TestCases/area/TC001.yaml"

        .EXAMPLE
            Get-ChildItem "TestCases/*.yaml" | Resolve-TcmTestCaseFilePathInput | Sync-TcmTestCaseToRemote

        .EXAMPLE
            Sync-TcmTestCaseToRemote -InputObject "TC001" -Force
    #>


    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [Alias("Path", "FilePath", "Id", "TestCaseId", "WorkItemId")]
        $InputObject,

        [string] $TestCasesRoot,

        [switch] $Force
    )

    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 {
        # Resolve input to get consistent format
        $resolved = $InputObject | Resolve-TcmTestCaseFilePathInput
        if (-not $resolved -or -not $resolved.Id) {
            throw "Invalid input: Could not resolve test case from input '$InputObject'"
        }

        $Id = $resolved.Id

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

            # Get sync status
            $syncStatus = Get-TcmTestCaseSyncStatus -Id $Id -Config $config

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

            # Load local test case by scanning files
            $localPath = $null

            # First try: Search for file with this ID prefix in the filename (fast)
            $pattern = "$Id-*.yaml"
            $foundFiles = Get-ChildItem -Path $config.TestCasesRoot -Filter $pattern -Recurse -File

            # Second try: If not found by filename and ID is not numeric, search file contents
            if ($foundFiles.Count -eq 0 -and $Id -notmatch '^\d+$') {
                Write-Verbose "Searching for test case '$Id' by scanning YAML file contents..."
                $allYamlFiles = Get-ChildItem -Path $config.TestCasesRoot -Filter "*.yaml" -Recurse -File

                foreach ($file in $allYamlFiles) {
                    try {
                        $content = Get-TcmTestCaseFromFile -FilePath $file.FullName -IncludeMetadata -ErrorAction SilentlyContinue
                        if ($content.testCase.id -eq $Id) {
                            $foundFiles = @($file)
                            Write-Verbose "Found test case '$Id' in file: $($file.FullName)"
                            break
                        }
                    } catch {
                        # Skip files that can't be parsed
                        continue
                    }
                }
            }

            if ($foundFiles.Count -eq 0) {
                throw "Test case '$Id' not found. Searched by filename pattern '$pattern' and file contents. Ensure the test case YAML file exists and has 'testCase.id: $Id' set."
            } elseif ($foundFiles.Count -gt 1) {
                throw "Multiple files found matching test case ID '$Id'. Please ensure unique IDs."
            }

            $localPath = $foundFiles[0].FullName
            Write-Verbose "Found local file for test case '$Id': $localPath"

            if (-not (Test-Path $localPath)) {
                throw "Local test case file not found: $localPath"
            }

            $testCaseData = Get-TcmTestCaseFromFile -FilePath $localPath -IncludeMetadata

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

            # Prepare work item fields (title and id now live under testCase)
            $fields = @{
                'System.Title'                        = $testCaseData.testCase.title
                'System.AreaPath'                     = $testCaseData.testCase.areaPath
                'System.IterationPath'                = $testCaseData.testCase.iterationPath
                'System.State'                        = $testCaseData.testCase.state
                'Microsoft.VSTS.Common.Priority'      = $testCaseData.testCase.priority
                'System.Description'                  = $testCaseData.testCase.description
                'Microsoft.VSTS.TCM.LocalDataSource'  = $testCaseData.testCase.preconditions
                'Microsoft.VSTS.TCM.Steps'            = $stepsXml
                'Microsoft.VSTS.TCM.AutomationStatus' = $testCaseData.testCase.automationStatus
            }

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

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

            # Add custom fields
            foreach ($key in $testCaseData.testCase.customFields.Keys) {
                $fields[$key] = $testCaseData.testCase.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("Work Item $Id", "Update test case")) {

                    # 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
                }
            } else {
                # Create new work item (ID is not found remotely or not numeric)
                if ($PSCmdlet.ShouldProcess($project, "Create new test case")) {
                    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 = $fields.'System.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

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

            Write-Verbose "Test case '$($workItem.id)' pushed successfully"
        } catch {
            Write-Error "Failed to push test case '$Id': $($_.Exception.Message)"
            throw
        }
    }
}