Modules/AzureDevOpsDsc.Common/Resources/Functions/Public/AzDoWIPTagHygiene/Get-AzDoWIPTagHygiene.ps1

<#
.SYNOPSIS
Reports which of a project's work item tags are misaligned against the canonical vocabulary.
 
.DESCRIPTION
Lists the project's live tags, runs them through Get-AzDoTagMisalignment and reports the merges
that would bring them into line.
 
The misalignments are returned in propertiesChanged so that Set can apply exactly the set that
was reported, rather than recomputing and possibly acting on something the user never saw.
 
.PARAMETER ProjectName
The name of the Azure DevOps project.
 
.PARAMETER CanonicalTags
The approved tag vocabulary.
 
.PARAMETER Aliases
Explicit From/To mappings, applied regardless of threshold.
 
.PARAMETER MatchStrategy
'Exact', 'Fuzzy' or 'Both'.
 
.PARAMETER SimilarityThreshold
The minimum normalized similarity for a fuzzy match.
 
.PARAMETER MinimumTagLength
Tags shorter than this are never fuzzy-matched.
 
.PARAMETER ExcludedTags
Tags that are never touched.
 
.PARAMETER RemediationAction
'Report' or 'Merge'.
 
.PARAMETER MaxAutoCorrections
The safety cap on how many merges may be applied in one run.
 
.PARAMETER LookupResult
The lookup result from a previous call, supplied by the DSC base class.
 
.PARAMETER Ensure
The desired state, supplied by the DSC base class.
 
.PARAMETER Force
Forces the operation, supplied by the DSC base class.
 
.EXAMPLE
Get-AzDoWIPTagHygiene -ProjectName 'Contoso' -CanonicalTags @('Bug', 'Tech Debt')
#>

Function Get-AzDoWIPTagHygiene
{
    [CmdletBinding()]
    [OutputType([System.Management.Automation.PSObject[]])]
    param
    (
        [Parameter(Mandatory = $true)]
        [Alias('Name')]
        [System.String]$ProjectName,

        [Parameter(Mandatory = $true)]
        [AllowEmptyCollection()]
        [Alias('WITTagList')]
        [System.String[]]$CanonicalTags,

        [Parameter()]
        [AllowEmptyCollection()]
        [HashTable[]]$Aliases,

        [Parameter()]
        [System.String]$MatchStrategy = 'Exact',

        [Parameter()]
        [System.Int32]$SimilarityThreshold = 85,

        [Parameter()]
        [System.Int32]$MinimumTagLength = 5,

        [Parameter()]
        [AllowEmptyCollection()]
        [System.String[]]$ExcludedTags,

        [Parameter()]
        [System.String]$RemediationAction = 'Report',

        [Parameter()]
        [System.Int32]$MaxAutoCorrections = 25,

        [Parameter()]
        [HashTable]$LookupResult,

        [Parameter()]
        [Ensure]$Ensure,

        [Parameter()]
        [System.Management.Automation.SwitchParameter]$Force
    )

    Write-Verbose "[Get-AzDoWIPTagHygiene] Started."

    $result = @{
        Ensure            = [Ensure]::Absent
        propertiesChanged = @()
        status            = [DSCGetSummaryState]::Unchanged
        reason            = $null
        project           = $ProjectName
    }

    $organization = Get-AzDoOrganizationName

    $liveTags = List-WITTags -Organization $organization -ProjectName $ProjectName

    if ($null -eq $liveTags)
    {
        Write-Verbose "[Get-AzDoWIPTagHygiene] No tags found in project '$ProjectName'. Nothing to align."
        return $result
    }

    $matchParams = @{
        CurrentTags         = @($liveTags.name)
        CanonicalTags       = @($CanonicalTags)
        Aliases             = @($Aliases)
        MatchStrategy       = $MatchStrategy
        SimilarityThreshold = $SimilarityThreshold
        MinimumTagLength    = $MinimumTagLength
        ExcludedTags        = @($ExcludedTags)
    }

    $misalignments = @(Get-AzDoTagMisalignment @matchParams)

    if ($misalignments.Count -eq 0)
    {
        Write-Verbose "[Get-AzDoWIPTagHygiene] All tags in project '$ProjectName' are aligned."
        $result.Ensure = [Ensure]::Present
        $result.status = [DSCGetSummaryState]::Unchanged
        return $result
    }

    # Carry the tag id alongside each merge. Renames are addressed by id because a name that has
    # just been merged away no longer resolves, so a chain of merges would otherwise fail partway.
    $tagsByName = @{}
    foreach ($liveTag in $liveTags) { $tagsByName[$liveTag.name] = $liveTag }

    $enriched = @()
    foreach ($misalignment in $misalignments)
    {
        $liveTag = $tagsByName[$misalignment.From]

        $enriched += @{
            From   = $misalignment.From
            To     = $misalignment.To
            Reason = $misalignment.Reason
            Score  = $misalignment.Score
            TagId  = if ($liveTag) { $liveTag.id } else { $null }
        }
    }

    foreach ($item in $enriched)
    {
        Write-Verbose "[Get-AzDoWIPTagHygiene] Misaligned tag '$($item.From)' -> '$($item.To)' ($($item.Reason), score $($item.Score))."
    }

    $result.propertiesChanged = $enriched
    $result.Ensure            = [Ensure]::Present

    # The cap is evaluated here, not in Set, so that Test() surfaces the problem rather than
    # letting a misconfigured vocabulary look like ordinary drift right up until it is applied.
    if ($enriched.Count -gt $MaxAutoCorrections)
    {
        Write-Error "[Get-AzDoWIPTagHygiene] Found $($enriched.Count) misaligned tags in project '$ProjectName', which exceeds MaxAutoCorrections ($MaxAutoCorrections). No tags will be merged. Review the vocabulary, or raise the cap if this is expected."
        $result.status = [DSCGetSummaryState]::Error
        $result.reason = 'MaxAutoCorrectionsExceeded'
        return $result
    }

    $result.status = [DSCGetSummaryState]::Changed
    $result.reason = "$($enriched.Count) misaligned tag(s) found."

    Write-Verbose "[Get-AzDoWIPTagHygiene] $($enriched.Count) misaligned tag(s) found in project '$ProjectName'."

    return $result
}