WorkItemTemplate/Set-AzureDevopsTemplate.ps1

#Requires -Version 5
<#
.SYNOPSIS
    Refreshes all templates in the given project from the path to the given Teams
    This is done in a way that supports the 1-click child tasks or "Create Child Tasks" ADO feature
 
.DESCRIPTION
    Identifies scripts it owns by name ignoring the number to assure creation in correct order
    e.g. "Dev Tasks - XX - Ensure requirement is fully understood"
    If already exists will replace otherwise will create
    Will delete any names that don't exist
 
    see example-created-template.json for the expected format of the json files
 
    if parentWorkItemTypes is set, then the template will be configured for "Create Child Tasks". Otherwise it is designed to be selected using the ADO native template application method, one by one
 
    If parentWorkItemTypes is set, you can also apply createIfProfile and createIf items to further restrict when child items get created.
 
    when and category are currently not used and there just for organisational purposes. Helpful when you have related tasks mixed with others for a single item
 
    prefixItemTitlesWith is a property you can set at the template level to add a prefix to all titles for that template (e.g. "Dev Tasks - " so you get "Dev Tasks - Ensure requirement is fully understood")
 
.NOTES
 
.EXAMPLE
 
Deploy everything to all teams (as configured)
.\script\AzureDevOps\WorkItemTemplates\Set-AzureDevopsTemplates.ps1
 
Deploy everything just for Vega team
.\script\AzureDevOps\WorkItemTemplates\Set-AzureDevopsTemplates.ps1 -Teams 'Vega'
 
Deploy just a single Template to Vega
.\script\AzureDevOps\WorkItemTemplates\Set-AzureDevopsTemplates.ps1 -Teams 'Vega' -TemplateName 'Feature Templates'
 
 
 
#>

function Set-AzureDevopsTemplate {
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory)]
        [string]$VstsAccount,

        [Parameter(Mandatory)]
        [string]$ProjectName,

        [Parameter()]
        [string]
        # will recursively load all templates in this folder
        $Path,

        [Parameter()]
        [string]
        # if you want to only load a single template, specify here
        $TemplateName,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string[]]
        # Execute for all teams in the array
        $Teams = @(),

        [Parameter()]
        [SecureString]
        # PAT Token to allow access to the Azure DevOps REST API. Must have Work Item Templates (Read & Write) permissions
        $PatToken
    )

    Set-StrictMode -Version 2
    $ErrorActionPreference = 'Stop'
    try {
        $RestCommon = @{
            VstsAccount = $VstsAccount
            ProjectName = $ProjectName
            ApiVersion  = '6.0-preview.1'
            Password    = $PatToken
        }
        $WhatifCommon = @{
            WhatIf = $WhatIfPreference
        }
        # . "$PSScriptRoot\private\Get-WorkItemTemplateModel.ps1"
        $TemplateConfig = Get-WorkItemTemplateModel -Path $Path
        $AutoGeneratedMarker = '<<AUTO>> GENERATED. See WorkItemTemplates in GIT'
        #$templateConfig = Get-Content $Path | ConvertFrom-Json

        # add some fields
        foreach ($template in $TemplateConfig.ToArray2()) {
            $i = 0
            foreach ($item in $template.items.ToArray2()) {
                if ($template.PSObject.Properties['parentWorkItemTypes']) {
                    $Ordinal = [string]::Format("{0:00}", $i++)
                    $item | Add-Member TemplateItemName "$($template.Name) . $Ordinal . $($item.Name)"

                    # Check if item uses createIf feature
                    if ($item.PSObject.Properties['createIf']) {
                        $profileName = $item.createIf

                        # Validate that the createIfProfile exists
                        if (-not $template.PSObject.Properties['createIfProfile']) {
                            throw "Item '$($item.Name)' references createIf profile '$profileName', but template '$($template.Name)' has no createIfProfile defined."
                        }
                        if (-not $template.createIfProfile.PSObject.Properties[$profileName]) {
                            throw "Item '$($item.Name)' references createIf profile '$profileName', but it does not exist in template '$($template.Name)'. Available profiles: $([string]::Join(', ', $template.createIfProfile.PSObject.Properties.Name))"
                        }

                        # Get parent work item types (item overrides template)
                        $parentTypes = if ($item.PSObject.Properties['parentWorkItemTypes']) {
                            $item.parentWorkItemTypes
                        } else {
                            $template.parentWorkItemTypes
                        }

                        # Build the applywhen array
                        $applyWhen = @()
                        foreach ($parentType in $parentTypes) {
                            # Create a combined condition object for each parent work item type
                            $profileConditions = $template.createIfProfile.$profileName
                            foreach ($condition in $profileConditions) {
                                # Clone the condition and add System.WorkItemType
                                $combinedCondition = [ordered]@{
                                    'System.WorkItemType' = $parentType
                                }
                                # Add all properties from the profile condition
                                foreach ($prop in $condition.PSObject.Properties) {
                                    $combinedCondition[$prop.Name] = $prop.Value
                                }
                                $applyWhen += $combinedCondition
                            }
                        }

                        # Create the JSON description object
                        $descriptionObject = [ordered]@{
                            profile   = $profileName
                            applywhen = $applyWhen
                            _comment  = $AutoGeneratedMarker
                        }

                        # Convert to JSON
                        $item | Add-Member TemplateItemDescription ($descriptionObject | ConvertTo-Json -Depth 10 -Compress)
                    }
                    else {
                        # Use existing simple format for backward compatibility
                        if ($item.PSObject.Properties['parentWorkItemTypes']) { ## item overrides template
                            $item | Add-Member TemplateItemDescription "[$([string]::Join(',', $item.parentWorkItemTypes))]"
                        }
                        else {
                            $item | Add-Member TemplateItemDescription "[$([string]::Join(',', $template.parentWorkItemTypes))]"
                        }
                        $item.TemplateItemDescription += " $AutoGeneratedMarker"
                    }
                }
                else {
                    # if template doesn't have parentWorkItemTypes then we mark as ignored so that 1-click doesn't pick it up (in-place vs children type)
                    $item | Add-Member TemplateItemName "$($template.Name) . $($item.Name)"
                    $item | Add-Member TemplateItemDescription "[ignore] $($item.notes)"
                    $item.TemplateItemDescription += " $AutoGeneratedMarker"
                }
            }
        }

        foreach ($team in $Teams) {
            # reset all previous id's to prevent linking across teams
            foreach ($template in $TemplateConfig.ToArray2()) {
                foreach ($item in $template.items.ToArray2()) {
                    if ($item.PSObject.Properties['currentTemplateId']) {
                        $item.PSObject.Properties.Remove('currentTemplateId')
                    }
                    if ($item.PSObject.Properties['currentTemplateName']) {
                        $item.PSObject.Properties.Remove('currentTemplateName')
                    }
                }
            }
            Write-Information "Getting current templates for Team $team from Azure Devops" -InformationAction Continue
            # get all templates for this team https://docs.microsoft.com/en-us/rest/api/azure/devops/wit/templates/list?view=azure-devops-rest-6.0
            # (only consider those with <<auto>> in description)
            # $currentTemplatesOutput = Invoke-AzureDevopsRest -Team $team -Api 'wit/templates?api-version=6.0-preview.1' -Method GET @RestCommon -WhatIf:$false
            $currentTemplatesOutput = Invoke-AdoRestMethod -Team $team -ApiUri 'wit/templates' -RestMethod GET @RestCommon -WhatIf:$false
            if ($currentTemplatesOutput.Count -gt 0) {
                $currentTemplates = $currentTemplatesOutput.value | Where-Object { $_.description -like '*<<auto>>*' }

                # work out which ones we no longer need and remove
                foreach ($currentTemplate in $currentTemplates) {
                    Write-Verbose "Trying to find $($currentTemplate.name) in our templates (ignoring the ordinal part)"

                    $NamePartsArray = $currentTemplate.Name.Split(' . ')
                    $NameParts = [PSCustomObject]@{
                        Team     = $NamePartsArray[0]
                        Template = $NamePartsArray[1]
                        Ordinal  = "$(if ( $NamePartsArray[2] -match '^\d+$') {$NamePartsArray[2]} else {''})"
                        Item     = "$(if ( $NamePartsArray[2] -match '^\d+$') {$NamePartsArray[3]} else {$NamePartsArray[2]})"
                    }

                    $NameParts | Format-List | Out-String | Write-Debug
                    ## always consider delete unless TemplateName param given and it doesn't match the current from name parts
                    if (-not ($TemplateName -and $TemplateName -ne $NameParts.Template)) {
                        $found = $false
                        foreach ($template in $TemplateConfig.ToArray2()) {
                            foreach ($item in $template.items.ToArray2()) {
                                if ($NameParts.Template -eq $template.Name -and
                                    $NameParts.Item -eq $item.Name -and
                                    #$NameParts.Team -eq $team -and
                                    $team -in $template.forTeams -and
                                    -not ($item.PSObject.Properties['disabled'] -and $item.disabled) # not disabled
                                ) {
                                    Write-Verbose "Found $($currentTemplate.name)"
                                    # we force as we are now on a different team but reusing the same set of templates
                                    $item | Add-Member currentTemplateId $currentTemplate.id -force
                                    $item | Add-Member currentTemplateName $currentTemplate.name -force
                                    $found = $true
                                    break
                                }
                            }
                            if ($found) {
                                break
                            }
                        }
                        if (-not $found) {
                            $actionDescription = "Delete ""$($currentTemplate.Name)"" from Team $($team)"
                            Write-Verbose "Couldn't find $($currentTemplate.name) (ignoring the ordinal part) in team $($team)"
                            if ($PSCmdlet.ShouldProcess($actionDescription)) {
                                Write-Information $actionDescription -InformationAction Continue
                                # $restOutput = Invoke-AzureDevopsRest -Team $team -Method DELETE -Api "wit/templates/$($currentTemplate.id)?api-version=6.0-preview.1" @RestCommon @WhatIfCommon
                                $restOutput = Invoke-AdoRestMethod -Team $team -ApiUri "wit/templates/$($currentTemplate.id)" -RestMethod DELETE @RestCommon @WhatIfCommon
                                Write-Verbose $restOutput
                            }
                        }
                    }
                }
            }

            # Create or replace existing ones
            Write-Information "Getting current templates for Team $team from our config" -InformationAction Continue
            foreach ($template in $TemplateConfig.ToArray2()) {
                if (-not ($TemplateName -and $TemplateName -ne $template.Name)) {
                    # all templates or only the one given in params
                    if ($team -in $template.forTeams) {
                        foreach ($item in $template.items.ToArray2() | where-object { -not ($_.PSObject.Properties['disabled'] -and $_.disabled) }) {
                            $body = [PSCustomObject]@{
                                name             = "$Team . $($item.templateItemName)"
                                description      = $item.TemplateItemDescription
                                workItemTypeName = "$(if (-not $item.PSObject.Properties['workItemType']) {'Task'} else {$item.workItemType})"
                                fields           = [PSCustomObject]@{}
                            }

                            # add title if we don't have one explicitly
                            if (-not $item.fields.PSObject.Properties['System.Title'] ) {
                                $titlePrefix = ''
                                if ($template.PSObject.Properties['prefixItemTitlesWith']) {
                                    $titlePrefix = $template.prefixItemTitlesWith
                                }
                                $body.fields | Add-Member "System.Title" "$($titlePrefix)$($item.Name)"
                            }

                            $blankInheritFields = @('System.AssignedTo')

                            foreach ($field in $item.fields.PSObject.Properties | Select-Object name, value) {
                                $fieldValue = $field.Value
                                if ($fieldValue -ne "<<UNCHANGED>>") {
                                    # if explicitly said as unchanged, don't add (e.g. System.Title if we don't want to set/change it)
                                    if ($field.Name -in $blankInheritFields -and
                                        $fieldValue -eq "{$($field.Name)}"
                                    ) {
                                        # 1 click expects blank to assign the parent value ### actually not sure this is true so commented out this block
                                        $fieldValue = ''
                                    }

                                    if ($fieldValue -match '<<[^>]+\.html>>') {
                                        $fieldValue = [regex]::Replace($fieldValue, '<<([^>]+\.html)>>', {
                                            param($match)
                                            $htmlFilename = Join-Path $Path "html\$($match.Groups[1].Value)"
                                            [string](Get-Content -Path $htmlFilename -Raw)
                                        })
                                        Write-Debug "fieldValue is type $($fieldValue.GetType())"
                                        $fieldValue | Format-List | Out-String | Write-Debug
                                    }
                                    $body.fields | Add-Member $field.Name $fieldValue
                                }
                            }

                            $body | Format-List | Out-String | Write-Debug
                            $body | convertTo-Json | Write-Debug

                            if ($item.PSObject.Properties['currentTemplateId']) {
                                # replace
                                if ($item.currentTemplateName -eq $body.name) {
                                    $actionDescription = "Re-set ""$($item.currentTemplateName)"" in team $($team)"

                                }
                                else {
                                    $actionDescription = "Replace ""$($item.currentTemplateName)"" with ""$($body.name)"" in team $($team)"
                                }
                                if ($PSCmdlet.ShouldProcess($actionDescription)) {
                                    Write-Information $actionDescription -InformationAction Continue
                                    #$restOutput = Invoke-AzureDevopsRest -Team $team -Method PUT -Api "wit/templates/$($item.currentTemplateId)?api-version=6.0-preview.1" -Body $body @RestCommon @WhatIfCommon
                                    $restOutput = Invoke-AdoRestMethod -Team $team -ApiUri "wit/templates/$($item.currentTemplateId)" -RestMethod PUT -Body $body @RestCommon @WhatIfCommon
                                    $restOutput | Out-String | Write-Debug
                                }
                            }
                            else {
                                # create
                                $actionDescription = "Create ""$($body.name)"" in team $($team)"
                                if ($PSCmdlet.ShouldProcess($actionDescription)) {
                                    Write-Information $actionDescription -InformationAction Continue
                                    #$restOutput = Invoke-AzureDevopsRest -Team $team -Method POST -Api "wit/templates?api-version=6.0-preview.1" -Body $body @RestCommon @WhatIfCommon
                                    $restOutput = Invoke-AdoRestMethod -Team $team -ApiUri "wit/templates" -RestMethod POST -Body $body @RestCommon @WhatIfCommon
                                    $restOutput | Out-String | Write-Debug
                                }
                            }
                        }
                    }
                }
            }
        }

    }
    catch {
        throw
    }
}