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