$ReleaseDevOpsOrgParameters = @("--organization", "https://dev.azure.com/azure-sdk") $ReleaseDevOpsCommonParameters = $ReleaseDevOpsOrgParameters + @("--output", "json") $ReleaseDevOpsCommonParametersWithProject = $ReleaseDevOpsCommonParameters + @("--project", "Release") function Invoke-AzBoardsCmd($subCmd, $parameters, $output = $true) { $azCmdStr = "az boards ${subCmd} $($parameters -join ' ')" if ($output) { Write-Host $azCmdStr } return Invoke-Expression "$azCmdStr" | ConvertFrom-Json -AsHashTable } function LoginToAzureDevops([string]$devop_pat) { if (!$devop_pat) { return } $azCmdStr = "'$devops_pat' | az devops login $($ReleaseDevOpsOrgParameters -join ' ')" Invoke-Expression $azCmdStr } function BuildHashKeyNoNull() { $filterNulls = $args | Where-Object { $_ } # if we had any nulls then return null if (!$filterNulls -or $args.Count -ne $filterNulls.Count) { return $null } return BuildHashKey $args } function BuildHashKey() { # if no args or the first arg is null return null if ($args.Count -lt 1 -or !$args[0]) { return $null } # exclude null values $keys = $args | Where-Object { $_ } return $keys -join "|" } $parentWorkItems = @{} function FindParentWorkItem($serviceName, $packageDisplayName, $outputCommand = $true) { $key = BuildHashKey $serviceName $packageDisplayName if ($key -and $parentWorkItems.ContainsKey($key)) { return $parentWorkItems[$key] } if ($serviceName) { $serviceCondition = "[ServiceName] = '${serviceName}'" if ($packageDisplayName) { $serviceCondition += " AND [PackageDisplayName] = '${packageDisplayName}'" } else { $serviceCondition += " AND [PackageDisplayName] = ''" } } else { $serviceCondition = "[ServiceName] <> ''" } $parameters = $ReleaseDevOpsCommonParametersWithProject $parameters += "--wiql" $parameters += "`"SELECT [ID], [ServiceName], [PackageDisplayName], [Parent] FROM WorkItems WHERE [Work Item Type] = 'Epic' AND ${serviceCondition}`"" $workItems = Invoke-AzBoardsCmd "query" $parameters $outputCommand foreach ($wi in $workItems) { $localKey = BuildHashKey $wi.fields["Custom.ServiceName"] $wi.fields["Custom.PackageDisplayName"] if (!$localKey) { continue } if ($parentWorkItems.ContainsKey($localKey)) { Write-Warning "Already found parent [$($parentWorkItems[$localKey].id)] with key [$localKey], using that one instead of [$($wi.id)]." } else { Write-Verbose "[$($wi.id)]$localKey - Cached" $parentWorkItems[$localKey] = $wi } } if ($key -and $parentWorkItems.ContainsKey($key)) { return $parentWorkItems[$key] } return $null } $packageWorkItems = @{} $packageWorkItemWithoutKeyFields = @{} function FindLatestPackageWorkItem($lang, $packageName, $outputCommand = $true) { # Cache all the versions of this package and language work items $null = FindPackageWorkItem $lang $packageName -includeClosed $true -outputCommand $outputCommand $latestWI = $null foreach ($wi in $packageWorkItems.Values) { if ($wi.fields["Custom.Language"] -ne $lang) { continue } if ($wi.fields["Custom.Package"] -ne $packageName) { continue } if (!$latestWI) { $latestWI = $wi continue } # Note this only does string sorting which is enough for our current usages # if we need absolute sorting at some point we would need to parse these versions if ($wi.fields["Custom.PackageVersionMajorMinor"] -gt $latestWI.fields["Custom.PackageVersionMajorMinor"]) { $latestWI = $wi } } return $latestWI } function FindPackageWorkItem($lang, $packageName, $version, $outputCommand = $true, $includeClosed = $false) { $key = BuildHashKeyNoNull $lang $packageName $version if ($key -and $packageWorkItems.ContainsKey($key)) { return $packageWorkItems[$key] } $fields = @() $fields += "ID" $fields += "State" $fields += "System.AssignedTo" $fields += "Microsoft.VSTS.Common.StateChangeDate" $fields += "Parent" $fields += "Language" $fields += "Package" $fields += "PackageDisplayName" $fields += "Title" $fields += "PackageType" $fields += "PackageTypeNewLibrary" $fields += "PackageVersionMajorMinor" $fields += "PackageRepoPath" $fields += "ServiceName" $fields += "Planned Packages" $fields += "Shipped Packages" $fields += "PackageBetaVersions" $fields += "PackageGAVersion" $fields += "PackagePatchVersions" $fieldList = ($fields | ForEach-Object { "[$_]"}) -join ", " $query = "SELECT ${fieldList} FROM WorkItems WHERE [Work Item Type] = 'Package'" if (!$includeClosed -and !$lang) { $query += " AND [State] <> 'No Active Development'" } if ($lang) { $query += " AND [Language] = '${lang}'" } if ($packageName) { $query += " AND [Package] = '${packageName}'" } if ($version) { $query += " AND [PackageVersionMajorMinor] = '${version}'" } $parameters = $ReleaseDevOpsCommonParametersWithProject $parameters += "--wiql", "`"${query}`"" $workItems = Invoke-AzBoardsCmd "query" $parameters $outputCommand foreach ($wi in $workItems) { $localKey = BuildHashKeyNoNull $wi.fields["Custom.Language"] $wi.fields["Custom.Package"] $wi.fields["Custom.PackageVersionMajorMinor"] if (!$localKey) { $packageWorkItemWithoutKeyFields[$wi.id] = $wi Write-Host "Skipping package [$($wi.id)]$($wi.fields['System.Title']) which is missing required fields language, package, or version." continue } if ($packageWorkItems.ContainsKey($localKey)) { Write-Warning "Already found package [$($packageWorkItems[$localKey].id)] with key [$localKey], using that one instead of [$($wi.id)]." } else { Write-Verbose "Caching package [$($wi.id)] for [$localKey]" $packageWorkItems[$localKey] = $wi } } if ($key -and $packageWorkItems.ContainsKey($key)) { return $packageWorkItems[$key] } return $null } function InitializeWorkItemCache($outputCommand = $true, $includeClosed = $false) { # Pass null to cache all service parents $null = FindParentWorkItem -serviceName $null -packageDisplayName $null -outputCommand $outputCommand # Pass null to cache all the package items $null = FindPackageWorkItem -lang $null -packageName $null -version $null -outputCommand $outputCommand -includeClosed $includeClosed } function GetCachedPackageWorkItems() { return $packageWorkItems.Values } function UpdateWorkItemParent($childWorkItem, $parentWorkItem, $outputCommand = $true) { $childId = $childWorkItem.id $existingParentId = $childWorkItem.fields["System.Parent"] $newParentId = $parentWorkItem.id if ($existingParentId -eq $newParentId) { return } CreateWorkItemParent $childId $newParentId $existingParentId -outputCommand $outputCommand $childWorkItem.fields["System.Parent"] = $newParentId } function CreateWorkItemParent($id, $parentId, $oldParentId, $outputCommand = $true) { # Have to remove old parent first if you want to add a new parent. if ($oldParentId) { $parameters = $ReleaseDevOpsCommonParameters $parameters += "--yes" $parameters += "--id", $id $parameters += "--relation-type", "parent" $parameters += "--target-id", $oldParentId Invoke-AzBoardsCmd "work-item relation remove" $parameters $outputCommand | Out-Null } $parameters = $ReleaseDevOpsCommonParameters $parameters += "--id", $id $parameters += "--relation-type", "parent" $parameters += "--target-id", $parentId Invoke-AzBoardsCmd "work-item relation add" $parameters $outputCommand | Out-Null } function CreateWorkItem($title, $type, $iteration, $area, $fields, $assignedTo, $parentId, $outputCommand = $true) { $parameters = $ReleaseDevOpsCommonParametersWithProject $parameters += "--title", "`"${title}`"" $parameters += "--type", "`"${type}`"" $parameters += "--iteration", "`"${iteration}`"" $parameters += "--area", "`"${area}`"" if ($assignedTo) { $parameters += "--assigned-to", "`"${assignedTo}`"" } if ($fields) { $parameters += "--fields" $parameters += $fields } $workItem = Invoke-AzBoardsCmd "work-item create" $parameters $outputCommand if ($parentId) { $parameters = $ReleaseDevOpsCommonParameters $parameters += "--id", $workItem.id $parameters += "--relation-type", "parent" $parameters += "--target-id", $parentId Invoke-AzBoardsCmd "work-item relation add" $parameters $outputCommand | Out-Null } return $workItem } function ResetWorkItemState($workItem, $resetState = $null, $outputCommand = $true) { if (!$resetState -or $resetState -eq "New") { $resetState = "Next Release Unknown" } if ($workItem.fields["System.State"] -ne $resetState) { Write-Verbose "Resetting state for [$($workItem.id)] from '$($workItem.fields['System.State'])' to '$resetState'" return UpdateWorkItem $workItem.id -state $resetState -outputCommand $outputCommand } return $workItem } function UpdateWorkItem($id, $fields, $title, $state, $assignedTo, $outputCommand = $true) { $parameters = $ReleaseDevOpsCommonParameters $parameters += "--id", $id if ($title) { $parameters += "--title", "`"${title}`"" } if ($state) { $parameters += "--state", "`"${state}`"" } if ($assignedTo) { $parameters += "--assigned-to", "`"${assignedTo}`"" } if ($fields) { $parameters += "--fields" $parameters += $fields } return Invoke-AzBoardsCmd "work-item update" $parameters $outputCommand } function UpdatePackageWorkItemReleaseState($id, $state, $releaseType, $outputCommand = $true) { $fields = "`"Custom.ReleaseType=${releaseType}`"" return UpdateWorkItem -id $id -state $state -fields $fields -outputCommand $outputCommand } function CreateOrUpdatePackageWorkItem($lang, $pkg, $verMajorMinor, $existingItem, $assignedTo = $null, $outputCommand = $true) { if (!$lang -or !$pkg -or !$verMajorMinor) { Write-Host "Cannot create or update because one of lang, pkg or verMajorMinor aren't set. [$lang|$($pkg.Package)|$verMajorMinor]" return } $pkgName = $pkg.Package $pkgDisplayName = $pkg.DisplayName $pkgType = $pkg.Type $pkgNewLibrary = $pkg.New $pkgRepoPath = $pkg.RepoPath $serviceName = $pkg.ServiceName $title = $lang + " - " + $pkg.DisplayName + " - " + $verMajorMinor $fields = @() $fields += "`"Language=${lang}`"" $fields += "`"Package=${pkgName}`"" $fields += "`"PackageDisplayName=${pkgDisplayName}`"" $fields += "`"PackageType=${pkgType}`"" $fields += "`"PackageTypeNewLibrary=${pkgNewLibrary}`"" $fields += "`"PackageVersionMajorMinor=${verMajorMinor}`"" $fields += "`"ServiceName=${serviceName}`"" $fields += "`"PackageRepoPath=${pkgRepoPath}`"" if ($existingItem) { $changedField = $null if ($lang -ne $existingItem.fields["Custom.Language"]) { $changedField = "Custom.Language" } if ($pkgName -ne $existingItem.fields["Custom.Package"]) { $changedField = "Custom.Package" } if ($verMajorMinor -ne $existingItem.fields["Custom.PackageVersionMajorMinor"]) { $changedField = "Custom.PackageVersionMajorMinor" } if ($pkgDisplayName -ne $existingItem.fields["Custom.PackageDisplayName"]) { $changedField = "Custom.PackageDisplayName" } if ($pkgType -ne $existingItem.fields["Custom.PackageType"]) { $changedField = "Custom.PackageType" } if ($pkgNewLibrary -ne $existingItem.fields["Custom.PackageTypeNewLibrary"]) { $changedField = "Custom.PackageTypeNewLibrary" } if ($pkgRepoPath -ne $existingItem.fields["Custom.PackageRepoPath"]) { $changedField = "Custom.PackageRepoPath" } if ($serviceName -ne $existingItem.fields["Custom.ServiceName"]) { $changedField = "Custom.ServiceName" } if ($title -ne $existingItem.fields["System.Title"]) { $changedField = "System.Title" } if ($changedField) { Write-Host "At least field $changedField ($($existingItem.fields[$field])) changed so updating." } $beforeState = $existingItem.fields["System.State"] if ($changedField) { # Need to set to New to be able to update $existingItem = UpdateWorkItem -id $existingItem.id -fields $fields -title $title -state "New" -assignedTo $assignedTo -outputCommand $outputCommand Write-Host "[$($existingItem.id)]$lang - $pkgName($verMajorMinor) - Updated" } $existingItem = ResetWorkItemState $existingItem $beforeState -outputCommand $outputCommand $newparentItem = FindOrCreatePackageGroupParent $serviceName $pkgDisplayName -outputCommand $outputCommand UpdateWorkItemParent $existingItem $newParentItem -outputCommand $outputCommand return $existingItem } $parentItem = FindOrCreatePackageGroupParent $serviceName $pkgDisplayName -outputCommand $outputCommand $workItem = CreateWorkItem $title "Package" "Release" "Release" $fields $assignedTo $parentItem.id -outputCommand $outputCommand $workItem = ResetWorkItemState $workItem -outputCommand $outputCommand Write-Host "[$($workItem.id)]$lang - $pkgName($verMajorMinor) - Created" return $workItem } function FindOrCreatePackageGroupParent($serviceName, $packageDisplayName, $outputCommand = $true) { $existingItem = FindParentWorkItem $serviceName $packageDisplayName -outputCommand $outputCommand if ($existingItem) { $newparentItem = FindOrCreateServiceParent $serviceName -outputCommand $outputCommand UpdateWorkItemParent $existingItem $newParentItem return $existingItem } $fields = @() $fields += "`"PackageDisplayName=${packageDisplayName}`"" $fields += "`"ServiceName=${serviceName}`"" $serviceParentItem = FindOrCreateServiceParent $serviceName -outputCommand $outputCommand $workItem = CreateWorkItem $packageDisplayName "Epic" "Release" "Release" $fields $null $serviceParentItem.id $localKey = BuildHashKey $serviceName $packageDisplayName Write-Host "[$($workItem.id)]$localKey - Created Parent" $parentWorkItems[$localKey] = $workItem return $workItem } function FindOrCreateServiceParent($serviceName, $outputCommand = $true) { $serviceParent = FindParentWorkItem $serviceName -outputCommand $outputCommand if ($serviceParent) { return $serviceParent } $fields = @() $fields += "`"PackageDisplayName=`"" $fields += "`"ServiceName=${serviceName}`"" $parentId = $null $workItem = CreateWorkItem $serviceName "Epic" "Release" "Release" $fields $null $parentId -outputCommand $outputCommand $localKey = BuildHashKey $serviceName Write-Host "[$($workItem.id)]$localKey - Created" $parentWorkItems[$localKey] = $workItem return $workItem } function ParseVersionSetFromMDField([string]$field) { $MDTableRegex = "\|\s*(?\S*)\s*\|\s*(?\S*)\s*\|\s*(?\S*)\s*\|" $versionSet = @{} $tableMatches = [Regex]::Matches($field, $MDTableRegex) foreach ($match in $tableMatches) { if ($match.Groups["t"].Value -eq "Type" -or $match.Groups["t"].Value -eq "-") { continue } $version = New-Object PSObject -Property @{ Type = $match.Groups["t"].Value Version = $match.Groups["v"].Value Date = $match.Groups["d"].Value } if (!$versionSet.ContainsKey($version.Version)) { $versionSet[$version.Version] = $version } } return $versionSet } function GetTextVersionFields($versionList, $pkgWorkItem) { $betaVersions = $gaVersions = $patchVersions = "" foreach ($v in $versionList) { $vstr = "$($v.Version),$($v.Date)" if ($v.Type -eq "Beta") { if ($betaVersions.Length + $vstr.Length -lt 255) { if ($betaVersions.Length -gt 0) { $betaVersions += "|" } $betaVersions += $vstr } } elseif ($v.Type -eq "GA") { if ($gaVersions.Length + $vstr.Length -lt 255) { if ($gaVersions.Length -gt 0) { $gaVersions += "|" } $gaVersions += $vstr } } elseif ($v.Type -eq "Patch") { if ($patchVersions.Length + $vstr.Length -lt 255) { if ($patchVersions.Length -gt 0) { $patchVersions += "|" } $patchVersions += $vstr } } } $fieldUpdates = @() if ("$($pkgWorkItem.fields["Custom.PackageBetaVersions"])" -ne $betaVersions) { $fieldUpdates += @" { "op": "replace", "path": "/fields/PackageBetaVersions", "value": "$betaVersions" } "@ } if ("$($pkgWorkItem.fields["Custom.PackageGAVersion"])" -ne $gaVersions) { $fieldUpdates += @" { "op": "replace", "path": "/fields/PackageGAVersion", "value": "$gaVersions" } "@ } if ("$($pkgWorkItem.fields["Custom.PackagePatchVersions"])" -ne $patchVersions) { $fieldUpdates += @" { "op": "replace", "path": "/fields/PackagePatchVersions", "value": "$patchVersions" } "@ } return ,$fieldUpdates } function GetMDVersionValue($versionlist) { $mdVersions = "" $mdFormat = "| {0} | {1} | {2} |`n" $versionlist | ForEach-Object { $mdVersions += ($mdFormat -f $_.Type, $_.Version, $_.Date) } $htmlVersions = "" $htmlFormat = @" {0} {1} {2} "@ $versionlist | ForEach-Object { $htmlVersions += ($htmlFormat -f $_.Type, $_.Version, $_.Date) } $htmlTemplate = @"
htmlVersions
Type Version Date
"@ -replace "'", '\"' return $htmlTemplate.Replace("mdVersions", $mdVersions).Replace("htmlVersions", "`n$htmlVersions"); } function UpdatePackageVersions($pkgWorkItem, $plannedVersions, $shippedVersions) { # Create the planned and shipped versions, adding the new ones if any $updatePlanned = $false $plannedVersionSet = ParseVersionSetFromMDField $pkgWorkItem.fields["Custom.PlannedPackages"] foreach ($version in $plannedVersions) { if (!$plannedVersionSet.ContainsKey($version.Version)) { $plannedVersionSet[$version.Version] = $version $updatePlanned = $true } else { # Lets check to see if someone wanted to update a date $existingVersion = $plannedVersionSet[$version.Version] if ($existingVersion.Date -ne $version.Date) { $existingVersion.Date = $version.Date $updatePlanned = $true } } } $updateShipped = $false $shippedVersionSet = ParseVersionSetFromMDField $pkgWorkItem.fields["Custom.ShippedPackages"] foreach ($version in $shippedVersions) { if (!$shippedVersionSet.ContainsKey($version.Version)) { $shippedVersionSet[$v.Version] = $version $updateShipped = $true } } $versionSet = @{} foreach ($version in $shippedVersionSet.Keys) { if (!$versionSet.ContainsKey($version)) { $versionSet[$version] = $shippedVersionSet[$version] } } foreach ($version in $plannedVersionSet.Keys) { if (!$versionSet.ContainsKey($version)) { $versionSet[$version] = $plannedVersionSet[$version] } else { # Looks like we shipped this version so remove it from the planned set $plannedVersionSet.Remove($version) $updatePlanned = $true } } $fieldUpdates = @() if ($updatePlanned) { $plannedPackages = GetMDVersionValue ($plannedVersionSet.Values | Sort-Object Date, Version -Descending) $fieldUpdates += @" { "op": "replace", "path": "/fields/Planned Packages", "value": "$plannedPackages" } "@ } if ($updateShipped) { $newShippedVersions = $shippedVersionSet.Values | Sort-Object Date, Version -Descending $shippedPackages = GetMDVersionValue $newShippedVersions $fieldUpdates += @" { "op": "replace", "path": "/fields/Shipped Packages", "value": "$shippedPackages" } "@ # If we shipped a version after we set "In Release" state then reset the state to "Next Release Unknown" if ($pkgWorkItem.fields["System.State"] -eq "In Release") { $lastShippedDate = [DateTime]$newShippedVersions[0].Date $markedInReleaseDate = ([DateTime]$pkgWorkItem.fields["Microsoft.VSTS.Common.StateChangeDate"]) # We just shipped so lets set the state to "Next Release Unknown" if ($markedInReleaseDate -le $lastShippedDate) { $fieldUpdates += @' { "op": "replace", "path": "/fields/State", "value": "Next Release Unknown" } '@ } } } # Full merged version set $versionList = $versionSet.Values | Sort-Object Date, Version -Descending $versionFieldUpdates = GetTextVersionFields $versionList $pkgWorkItem if ($versionFieldUpdates.Count -gt 0) { $fieldUpdates += $versionFieldUpdates } # If no version files to update do nothing if ($fieldUpdates.Count -eq 0) { return } $versionsForDebug = ($versionList | Foreach-Object { $_.Version }) -join "," $id = $pkgWorkItem.id $loggingString = "[$($pkgWorkItem.id)]" $loggingString += "$($pkgWorkItem.fields['Custom.Language'])" $loggingString += " - $($pkgWorkItem.fields['Custom.Package'])" $loggingString += "($($pkgWorkItem.fields['Custom.PackageVersionMajorMinor']))" $loggingString += " - Updating versions $versionsForDebug" Write-Host $loggingString $body = "[" + ($fieldUpdates -join ',') + "]" # Get a temp access token from the logged in az cli user for azure devops resource $jwt_accessToken = (az account get-access-token --resource "499b84ac-1321-427f-aa17-267ca6975798" --query "accessToken" --output tsv) $headers = @{ Authorization = "Bearer $jwt_accessToken" } $response = Invoke-RestMethod -Method PATCH ` -Uri "https://dev.azure.com/azure-sdk/_apis/wit/workitems/${id}?api-version=6.0" ` -Headers $headers -Body $body -ContentType "application/json-patch+json" }