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
 
.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
 
.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
        #$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)"
                    $item | Add-Member TemplateItemDescription "[$([string]::Join(',', $template.parentWorkItemTypes))]"
                }
                else {
                    $item | Add-Member TemplateItemName "$($template.Name) . $($item.Name)"
                    $item | Add-Member TemplateItemDescription "[ignore] $($item.notes)"
                }
                $item.TemplateItemDescription += " <<AUTO>> GENERATED. See WorkItemTemplates in GIT"
            }
        }

        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.EndsWith('.html>>')) {
                                        $htmlFilename = Join-Path $Path "html\$($fieldValue.Replace('<<','').Replace('>>',''))"
                                        $fieldValue = [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
    }
}