Public/WorkItemTracking/Get-WorkItemLink.ps1
|
function Get-WorkItemLink { <# .SYNOPSIS Enumerates every work item link of the given link types, with both endpoints resolved. .DESCRIPTION Runs a WIQL WorkItemLinks query per link type end, then reads the work items involved so the result carries titles, types, states and projects rather than bare ids. Link comments are recovered from the source work item's relations in the same pass. Defaults to every CUSTOM link type in the collection, which is exactly the set Remove-WitWorkItemLinkType destroys. Only forward ends are queried, so each link is reported once. .PARAMETER UseDefaultCredentials Authenticate as the process identity instead of Entra. Required for on-premises Azure DevOps Server collections, which have no Entra tenant behind them. .PARAMETER ReferenceName Link types to enumerate. Accepts bare reference names ('Custom.Affects') or specific ends ('Custom.Affects-Forward'). Omit to enumerate all custom link types. .PARAMETER Project Scope the query to one team project. Use this when the collection-wide query trips the WIQL result limit. .PARAMETER Pat Personal access token. Omit to authenticate as the current Windows identity. .EXAMPLE Get-WorkItemLink -Collection $collection | Format-Table LinkType, SourceId, TargetId .EXAMPLE Get-WorkItemLink -Collection $collection -ReferenceName 'Custom.Affects' -Project 'Mammoth' #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Collection, [string[]]$ReferenceName, [string]$Project, [string]$Pat, [switch]$UseDefaultCredentials, [string]$ApiVersion = '5.0' ) # Resolve the requested names to concrete link type ends, and keep the forward ends only so # a link is not reported once from each side. $ends = if ($ReferenceName) { foreach ($name in $ReferenceName) { $matched = @(Get-WorkItemLinkType -Collection $Collection -UseDefaultCredentials:$UseDefaultCredentials -ReferenceName $name -Pat $Pat -ApiVersion $ApiVersion) if (-not $matched) { throw "No work item link type in '$Collection' matches reference name '$name'." } $matched | Where-Object { -not $_.IsReverse } } } else { Get-WorkItemLinkType -Collection $Collection -UseDefaultCredentials:$UseDefaultCredentials -CustomOnly -Pat $Pat -ApiVersion $ApiVersion | Where-Object { -not $_.IsReverse } } $ends = @($ends) if ($ends.Count -eq 0) { if ($ReferenceName) { # Every match was a reverse end, so the forward filter emptied the set. Write-FixStep "'$($ReferenceName -join ', ')' matched only reverse link type ends - the forward end carries the links, so there is nothing to enumerate" } else { Write-FixStep "No custom work item link types found in the collection - nothing to enumerate" } return } $wiqlPath = if ($Project) { "$([uri]::EscapeDataString($Project))/_apis/wit/wiql" } else { '_apis/wit/wiql' } $relations = foreach ($end in $ends) { Write-FixStep "Querying links of type '$($end.Name)' ($($end.ReferenceName))" $wiql = "SELECT [System.Id] FROM WorkItemLinks WHERE [System.Links.LinkType] = '$($end.ReferenceName)' MODE (MustContain)" try { $response = Invoke-AzureDevOpsApi -Collection $Collection -UseDefaultCredentials:$UseDefaultCredentials -Path $wiqlPath -Method Post -Pat $Pat -ApiVersion $ApiVersion -Body @{ query = $wiql } } catch { if ("$_" -match 'VS402337') { throw "The link query for '$($end.ReferenceName)' exceeded the WIQL result limit. Re-run per project with -Project. $_" } throw } # MustContain returns a seed row per source work item with rel = null alongside the real # link rows; only the latter describe a relationship. foreach ($relation in @($response.workItemRelations)) { if (-not $relation.rel) { continue } if (-not $relation.source -or -not $relation.target) { continue } [pscustomobject]@{ End = $end SourceId = [int]$relation.source.id TargetId = [int]$relation.target.id } } } $relations = @($relations) Write-FixStep "Found $($relations.Count) link(s) across $($ends.Count) link type(s)" if ($relations.Count -eq 0) { return } # Both sides wrapped in @() first: with a single relation the property accessors return # scalars, and int + int would add rather than concatenate. $details = Get-WorkItemDetailMap -Collection $Collection -UseDefaultCredentials:$UseDefaultCredentials -Pat $Pat -ApiVersion $ApiVersion ` -Id (@($relations.SourceId) + @($relations.TargetId)) foreach ($relation in $relations) { $source = $details[$relation.SourceId] $target = $details[$relation.TargetId] # The comment lives on the source work item's matching relation, identified by the link # type end plus the id at the tail of the relation url. $comment = '' if ($source) { $match = @($source.relations | Where-Object { $_.rel -eq $relation.End.ReferenceName -and ($_.url -split '/')[-1] -eq "$($relation.TargetId)" }) | Select-Object -First 1 if ($match -and $match.attributes.comment) { $comment = $match.attributes.comment } } [pscustomobject]@{ LinkType = $relation.End.Name LinkTypeReference = $relation.End.ReferenceName Topology = $relation.End.Topology SourceId = $relation.SourceId SourceProject = if ($source) { $source.fields.'System.TeamProject' } else { '' } SourceType = if ($source) { $source.fields.'System.WorkItemType' } else { '' } SourceTitle = if ($source) { $source.fields.'System.Title' } else { '' } SourceState = if ($source) { $source.fields.'System.State' } else { '' } TargetId = $relation.TargetId TargetProject = if ($target) { $target.fields.'System.TeamProject' } else { '' } TargetType = if ($target) { $target.fields.'System.WorkItemType' } else { '' } TargetTitle = if ($target) { $target.fields.'System.Title' } else { '' } TargetState = if ($target) { $target.fields.'System.State' } else { '' } Comment = $comment } } } |