Private/azuredevops-core.ps1

<#
.SYNOPSIS
    Core, pure helper functions for Azure DevOps scripts.

.DESCRIPTION
    Small, side-effect-free helpers intended for unit testing and reuse across
    scripts. These helpers do not use CmdletBinding or advanced parameter sets
    to make them easy to call from tests.

    Copyright (c) Microsoft Corporation.
    Licensed under the MIT License.
#>


function Convert-ProjectUrlCore {
    param([string]$InputUrl)
    try {
        $u = [uri]$InputUrl
    } catch {
        throw "Invalid URL: $InputUrl"
    }
    $segments = $u.AbsolutePath.Trim('/').Split('/') | Where-Object { $_ -ne '' }
    if ($u.Host -match '\.visualstudio\.com$') {
        $org = $u.Host.Split('.')[0]
        $project = $segments[0]
            return "https://dev.azure.com/$org/$project"
    } elseif ($u.Host -ieq 'dev.azure.com') {
        if ($segments.Count -ge 2) { return "$($u.Scheme)://$($u.Host)/$($segments[0])/$($segments[1])" }
        elseif ($segments.Count -ge 1) { return "$($u.Scheme)://$($u.Host)/$($segments[0])" }
    }
    if ($segments.Count -ge 1) { return "$($u.Scheme)://$($u.Host)/$($segments[0])" }
    return "$($u.Scheme)://$($u.Host)"
}

function Format-TokenCore {
    param([string]$Token)
    if (-not $Token) { return '' }
    if ($Token.Length -le 12) { return ('*' * ($Token.Length)) }
    $start = $Token.Substring(0,6)
    $end = $Token.Substring($Token.Length-4)
    return "$start`*****$end"
}

# Attempt to obtain an Azure DevOps access token via az CLI, with bash fallback.
function Get-AzAccessTokenCore {
    param([string]$Resource)

    $errors = @()
    $bashPath = $env:BASH_PATH
    if ($bashPath) {
        if ((Test-Path $bashPath) -and (Get-Item $bashPath).PSIsContainer) {
            $candidate = Join-Path $bashPath 'bash.exe'
            if (Test-Path $candidate) { $bashPath = $candidate } else {
                $candidate2 = Join-Path $bashPath 'sh.exe'
                if (Test-Path $candidate2) { $bashPath = $candidate2 } else { $bashPath = $null }
            }
        }
    }

    if ($bashPath -and (Test-Path $bashPath)) {
        try {
            $cmd = "az account get-access-token --resource $Resource --query accessToken -o tsv"
            $out = & $bashPath -lc $cmd 2>&1
            if ($LASTEXITCODE -eq 0 -and -not [string]::IsNullOrWhiteSpace($out)) { return $out.Trim() }
            $errors += "bash attempt failed: $out"
        } catch {
            $errors += "bash exception: $_"
        }
    }

    try {
        $cmd = @('account','get-access-token','--resource',$Resource,'--query','accessToken','-o','tsv')
        $out = & az @cmd 2>&1
        if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($out)) { throw "az failed: $out" }
        return $out.Trim()
    } catch {
        $errors += $_.Exception.Message
        # fallback to known Azure DevOps AAD app id
        $alt = '499b84ac-1321-427f-aa17-267ca6975798'
        try {
            $cmd = @('account','get-access-token','--resource',$alt,'--query','accessToken','-o','tsv')
            $out2 = & az @cmd 2>&1
            if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($out2)) { throw "az fallback failed: $out2" }
            return $out2.Trim()
        } catch {
            $errors += $_.Exception.Message
            throw ($errors -join '; ')
        }
    }
}

# Testable function to add a comment to a work item. This uses core token logic.
function Add-AdoWorkItemCommentCore {
    param([int]$WorkItemId, [string]$Text)
    if (-not $WorkItemId) { throw 'WorkItemId is required' }
    if (-not $Text) { throw 'Text is required' }
    $base = $env:AzDevOpsUrl
    if (-not $base) { throw 'AzDevOpsUrl environment variable is not set' }

    if ($env:AzDevOpsAccessToken) {
        $token = $env:AzDevOpsAccessToken
    } else {
        $token = Get-AzAccessTokenCore $env:AzDevOpsAadAppId
    }

    $uri = "$base/_apis/wit/workItems/$WorkItemId/comments?api-version=$($env:AzDevOpsCommentsApiVersion)"
    $hdr = @{ Authorization = "Basic " + [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(":$token")); 'Content-Type' = 'application/json' }
    
    # Convert Markdown to HTML
    $htmlText = ConvertTo-HtmlCore -Markdown $Text
    
    # Send as HTML (default format)
    $body = @{ text = $htmlText } | ConvertTo-Json -Depth 5
    $resp = Invoke-RestMethod -Uri $uri -Headers $hdr -Method Post -Body $body -ContentType 'application/json'
    return [pscustomobject]@{ commentId = $resp.id; author = $resp.createdBy.displayName; createdDate = $resp.createdDate; workItemId = $WorkItemId; raw = $resp }
}

# Update work item fields (description, acceptance criteria) using JSON Patch
function Update-AdoWorkItemFieldsCore {
    param(
        [int]$WorkItemId,
        $Title,
        $Description,
        $AcceptanceCriteria,
        [double]$Effort,
        [double]$RemainingWork
    )

    if (-not $WorkItemId) { throw 'WorkItemId is required' }
    if (-not $Title -and -not $Description -and -not $AcceptanceCriteria -and -not $Effort -and -not $RemainingWork) { throw 'Either Title, Description, AcceptanceCriteria, Effort, or RemainingWork must be provided' }
    $base = $env:AzDevOpsUrl
    if (-not $base) { throw 'AzDevOpsUrl environment variable is not set' }

    if ($env:AzDevOpsAccessToken) {
        $token = $env:AzDevOpsAccessToken
    } else {
        $token = Get-AzAccessTokenCore $env:AzDevOpsAadAppId
    }

    $uri = "$base/_apis/wit/workitems/${WorkItemId}?api-version=$($env:AzDevOpsApiVersion)"
    $hdr = @{ Authorization = "Basic " + [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(":$token")); 'Content-Type' = 'application/json-patch+json' }

    $ops = @()
    if ($null -ne $Title) {
        $ops += @{ op = 'add'; path = '/fields/System.Title'; value = $Title }
    }
    if ($null -ne $Description) {
        $htmlDesc = ConvertTo-HtmlCore -Markdown $Description
        $ops += @{ op = 'add'; path = '/fields/System.Description'; value = $htmlDesc }
    }
    if ($null -ne $AcceptanceCriteria) {
        $htmlAC = ConvertTo-HtmlCore -Markdown $AcceptanceCriteria
        $ops += @{ op = 'add'; path = '/fields/Microsoft.VSTS.Common.AcceptanceCriteria'; value = $htmlAC }
    }
    if ($Effort -gt 0) {
        $ops += @{ op = 'add'; path = '/fields/Microsoft.VSTS.Scheduling.Effort'; value = $Effort }
    }
    if ($RemainingWork -gt 0) {
        $ops += @{ op = 'add'; path = '/fields/Microsoft.VSTS.Scheduling.RemainingWork'; value = $RemainingWork }
    }

    $body = ConvertTo-Json -InputObject $ops -Depth 5
    $resp = Invoke-RestMethod -Uri $uri -Headers $hdr -Method Patch -Body $body -ContentType 'application/json-patch+json'

    return [pscustomobject]@{ workItemId = $WorkItemId; raw = $resp }
}

function Add-AdoWorkItemTagCore {
    param([int]$WorkItemId, [string]$Tag)
    if (-not $WorkItemId) { throw 'WorkItemId is required' }
    if (-not $Tag) { throw 'Tag is required' }
    
    $wi = Get-AdoWorkItemCore -WorkItemId $WorkItemId
    $currentTags = $wi.fields.'System.Tags'
    if ($currentTags) {
        $tags = $currentTags -split ';' | ForEach-Object { $_.Trim() }
        if ($tags -contains $Tag) { return $wi } # Already exists
        $newTags = ($tags + $Tag) -join ';'
    } else {
        $newTags = $Tag
    }
    
    return Update-AdoWorkItemTagsCore -WorkItemId $WorkItemId -Tags $newTags
}

function Remove-AdoWorkItemTagCore {
    param([int]$WorkItemId, [string]$Tag)
    if (-not $WorkItemId) { throw 'WorkItemId is required' }
    if (-not $Tag) { throw 'Tag is required' }
    
    $wi = Get-AdoWorkItemCore -WorkItemId $WorkItemId
    $currentTags = $wi.fields.'System.Tags'
    if (-not $currentTags) { return $wi }
    
    $tags = $currentTags -split ';' | ForEach-Object { $_.Trim() }
    if ($tags -notcontains $Tag) { return $wi }
    
    $newTags = ($tags | Where-Object { $_ -ne $Tag }) -join ';'
    return Update-AdoWorkItemTagsCore -WorkItemId $WorkItemId -Tags $newTags
}

function Update-AdoWorkItemTagsCore {
    param([int]$WorkItemId, [string]$Tags)
    
    $base = $env:AzDevOpsUrl
    if (-not $base) { throw 'AzDevOpsUrl environment variable is not set' }
    if ($env:AzDevOpsAccessToken) { $token = $env:AzDevOpsAccessToken } else { $token = Get-AzAccessTokenCore $env:AzDevOpsAadAppId }
    
    $uri = "$base/_apis/wit/workitems/${WorkItemId}?api-version=$($env:AzDevOpsApiVersion)"
    $hdr = @{ Authorization = "Basic " + [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(":$token")); 'Content-Type' = 'application/json-patch+json' }
    
    $ops = @(@{ op = 'add'; path = '/fields/System.Tags'; value = $Tags })
    $body = ConvertTo-Json -InputObject $ops -Depth 5
    return Invoke-RestMethod -Uri $uri -Headers $hdr -Method Patch -Body $body -ContentType 'application/json-patch+json'
}

function New-AdoWorkItemCore {
    param(
        [string]$Project,
        [string]$Type,
        [string]$Title,
        $Description,
        [int]$ParentId,
        [string]$ParentUrl,
        [double]$OriginalEstimate,
        [double]$RemainingWork,
        [string]$AreaPath,
        [string]$IterationPath
    )

    if (-not $Project) { throw 'Project is required' }
    if (-not $Type) { throw 'Type is required' }
    if (-not $Title) { throw 'Title is required' }

    $base = $env:AzDevOpsUrl
    if (-not $base) { throw 'AzDevOpsUrl environment variable is not set' }
    if ($base.EndsWith('/')) { $base = $base.TrimEnd('/') }

    if ($env:AzDevOpsAccessToken) { $token = $env:AzDevOpsAccessToken } else { $token = Get-AzAccessTokenCore $env:AzDevOpsAadAppId }

    # If URL has project in it, we might need to adjust, but usually POST is to /{project}/_apis/wit/workitems/${type}
    # We need to extract Org from URL if it's not in the base
    # Assuming base is https://dev.azure.com/org/project or https://dev.azure.com/org
    # But the API requires /{project}/_apis...

    # Simple heuristic: if base ends with project name, use it. If not, append project.
    # Actually, let's assume $env:AzDevOpsUrl points to the project root as per other scripts.

    $uri = "$base/_apis/wit/workitems/`$$($Type)?api-version=$($env:AzDevOpsApiVersion)"
    $hdr = @{ Authorization = "Basic " + [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(":$token")); 'Content-Type' = 'application/json-patch+json' }

    $ops = @(
        @{ op = 'add'; path = '/fields/System.Title'; value = $Title },
        @{ op = 'add'; path = '/fields/System.TeamProject'; value = $Project }
    )

    if ($Description) {
        $htmlDesc = ConvertTo-HtmlCore -Markdown $Description
        $ops += @{ op = 'add'; path = '/fields/System.Description'; value = $htmlDesc }
    }

    if ($AreaPath) {
        $ops += @{ op = 'add'; path = '/fields/System.AreaPath'; value = $AreaPath }
    }

    if ($IterationPath) {
        $ops += @{ op = 'add'; path = '/fields/System.IterationPath'; value = $IterationPath }
    }

    if ($OriginalEstimate -gt 0) {
        $ops += @{ op = 'add'; path = '/fields/Microsoft.VSTS.Scheduling.OriginalEstimate'; value = $OriginalEstimate }
    }

    if ($RemainingWork -gt 0) {
        $ops += @{ op = 'add'; path = '/fields/Microsoft.VSTS.Scheduling.RemainingWork'; value = $RemainingWork }
    }

    if ($ParentUrl) {
        $ops += @{
            op = 'add'
            path = '/relations/-'
            value = @{
                rel = 'System.LinkTypes.Hierarchy-Reverse'
                url = $ParentUrl
            }
        }
    } elseif ($ParentId) {
        $ops += @{
            op = 'add'
            path = '/relations/-'
            value = @{
                rel = 'System.LinkTypes.Hierarchy-Reverse'
                url = "$base/_apis/wit/workitems/$ParentId"
            }
        }
    }

    $body = ConvertTo-Json -InputObject $ops -Depth 5
    return Invoke-RestMethod -Uri $uri -Headers $hdr -Method Post -Body $body -ContentType 'application/json-patch+json'
}

function Remove-AdoWorkItemCore {
    param([int]$WorkItemId)
    if (-not $WorkItemId) { throw 'WorkItemId is required' }
    
    $base = $env:AzDevOpsUrl
    if (-not $base) { throw 'AzDevOpsUrl environment variable is not set' }
    if ($env:AzDevOpsAccessToken) { $token = $env:AzDevOpsAccessToken } else { $token = Get-AzAccessTokenCore $env:AzDevOpsAadAppId }
    
    $uri = "$base/_apis/wit/workitems/${WorkItemId}?api-version=$($env:AzDevOpsApiVersion)"
    $hdr = @{ Authorization = "Basic " + [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(":$token")) }
    
    return Invoke-RestMethod -Uri $uri -Headers $hdr -Method Delete
}

function Get-AdoWorkItemsBatchCore {
    param([int[]]$WorkItemIds)
    if (-not $WorkItemIds -or $WorkItemIds.Count -eq 0) { return @() }
    
    $base = $env:AzDevOpsUrl
    if (-not $base) { throw 'AzDevOpsUrl environment variable is not set' }
    if ($env:AzDevOpsAccessToken) { $token = $env:AzDevOpsAccessToken } else { $token = Get-AzAccessTokenCore $env:AzDevOpsAadAppId }
    
    $ids = $WorkItemIds -join ','
    $uri = "$base/_apis/wit/workitems?ids=${ids}&api-version=$($env:AzDevOpsApiVersion)"
    $hdr = @{ Authorization = "Basic " + [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(":$token")) }
    
    $resp = Invoke-RestMethod -Uri $uri -Headers $hdr -Method Get
    return $resp.value
}

function Get-AdoWorkItemChildrenCore {
    param([int]$WorkItemId)
    
    $wi = Get-AdoWorkItemCore -WorkItemId $WorkItemId -ExpandRelations
    if (-not $wi.relations) { return @() }
    
    $childIds = @()
    foreach ($rel in $wi.relations) {
        if ($rel.rel -eq 'System.LinkTypes.Hierarchy-Forward') {
            # url is like .../_apis/wit/workItems/123
            if ($rel.url -match '/(\d+)$') {
                $childIds += [int]$matches[1]
            }
        }
    }
    
    return Get-AdoWorkItemsBatchCore -WorkItemIds $childIds
}

function Get-AdoWorkItemCore {
    param(
        [int]$WorkItemId,
        [switch]$ExpandRelations
    )
    if (-not $WorkItemId) { throw 'WorkItemId is required' }
    $base = $env:AzDevOpsUrl
    if (-not $base) { throw 'AzDevOpsUrl environment variable is not set' }
    
    if ($env:AzDevOpsAccessToken) {
        $token = $env:AzDevOpsAccessToken
    } else {
        $token = Get-AzAccessTokenCore $env:AzDevOpsAadAppId
    }

    $uri = "$base/_apis/wit/workitems/${WorkItemId}?api-version=$($env:AzDevOpsApiVersion)"
    if ($ExpandRelations) {
        $uri += '&$expand=relations'
    }
    $hdr = @{ Authorization = "Basic " + [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(":$token")) }
    return Invoke-RestMethod -Uri $uri -Headers $hdr -Method Get
}

function Get-AdoWorkItemCommentsCore {
    param([int]$WorkItemId)
    if (-not $WorkItemId) { throw 'WorkItemId is required' }
    $base = $env:AzDevOpsUrl
    if (-not $base) { throw 'AzDevOpsUrl environment variable is not set' }

    if ($env:AzDevOpsAccessToken) {
        $token = $env:AzDevOpsAccessToken
    } else {
        $token = Get-AzAccessTokenCore $env:AzDevOpsAadAppId
    }

    $uri = "$base/_apis/wit/workItems/${WorkItemId}/comments?api-version=$($env:AzDevOpsCommentsApiVersion)"
    $hdr = @{ Authorization = "Basic " + [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(":$token")) }
    try { return Invoke-RestMethod -Uri $uri -Headers $hdr -Method Get } catch { return $null }
}

function ConvertTo-HtmlCore {
    param([object]$Markdown)
    
    if ($null -eq $Markdown) { return '' }
    
    # Handle arrays (e.g. from JSON) by joining with newlines
    if ($Markdown -is [array] -or ($Markdown -is [System.Collections.IEnumerable] -and $Markdown -isnot [string])) {
        $Markdown = $Markdown -join "`n"
    }
    
    $Markdown = [string]$Markdown
    
    if ([string]::IsNullOrWhiteSpace($Markdown)) { return '' }
    
    # Handle literal \n sequences (common in JSON)
    $Markdown = $Markdown -replace '\\r\\n', "`n"
    $Markdown = $Markdown -replace '\\n', "`n"
    $Markdown = $Markdown -replace '\\r', "`n"
    
    # Normalize newlines
    $Markdown = $Markdown -replace "`r`n", "`n" -replace "`r", "`n"
    
    $lines = $Markdown -split "`n"
    $htmlLines = @()
    $inCodeBlock = $false
    $inList = $false
    
    foreach ($line in $lines) {
        $l = $line.Trim() # Trim both ends to handle indentation and trailing CR
        
        # Code Block
        if ($l -match '^```') {
            if ($inCodeBlock) {
                $htmlLines += "</pre>"
                $inCodeBlock = $false
            } else {
                $htmlLines += "<pre>"
                $inCodeBlock = $true
            }
            continue
        }
        
        if ($inCodeBlock) {
            # Preserve indentation in code blocks, so use original line (but TrimEnd for CR)
            $codeLine = $line.TrimEnd()
            $safe = $codeLine -replace '&', '&amp;' -replace '<', '&lt;' -replace '>', '&gt;'
            $htmlLines += $safe
            continue
        }
        
        # Headers
        if ($l -match '^# (.*)') { $htmlLines += "<h1>$($matches[1])</h1>"; continue }
        if ($l -match '^## (.*)') { $htmlLines += "<h2>$($matches[1])</h2>"; continue }
        if ($l -match '^### (.*)') { $htmlLines += "<h3>$($matches[1])</h3>"; continue }
        
        # List (support - and *)
        if ($l -match '^[-*] (.*)') {
            if (-not $inList) { $htmlLines += "<ul>"; $inList = $true }
            $content = $matches[1]
            $content = $content -replace '\*\*([^*]+)\*\*', '<b>$1</b>'
            $content = $content -replace '`([^`]+)`', '<code>$1</code>'
            $htmlLines += "<li>$content</li>"
            continue
        } else {
            if ($inList) { $htmlLines += "</ul>"; $inList = $false }
        }
        
        # Normal line
        if ([string]::IsNullOrWhiteSpace($l)) {
            $htmlLines += "<br/>"
        } else {
            $content = $l
            $content = $content -replace '\*\*([^*]+)\*\*', '<b>$1</b>'
            $content = $content -replace '`([^`]+)`', '<code>$1</code>'
            $htmlLines += "$content<br/>"
        }
    }
    
    if ($inList) { $htmlLines += "</ul>" }
    if ($inCodeBlock) { $htmlLines += "</pre>" }
    
    return $htmlLines -join "`n"
}