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 '&', '&' -replace '<', '<' -replace '>', '>' $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" } |