Engines/Remove-CommitMentionLinks.ps1

<#
.SYNOPSIS
    Removes work item links that a repository migration created through Azure DevOps'
    commit mention linking, across every project in an organisation.
 
.DESCRIPTION
    Azure DevOps creates repositories with 'Commit mention linking' enabled. A migration
    pushes an entire history in one operation, so every historical '#1234' in every commit
    message is processed as a new mention and linked. Work item ids are unique per
    ORGANISATION rather than per project, so the links land wherever those ids happen to
    live - on one engagement, 6,798 work items across unrelated projects, from a single
    repository push.
 
    This undoes that. It is deliberately narrow: it removes only ArtifactLink relations
    that point at commits in the named repository AND were added by the matching revisions
    inside the window. Commit links that predate the window, belong to other repositories,
    or were made by anyone else are left alone - those are somebody's real history.
 
    Ordering matters if mentions also transitioned work items. Removing a link does NOT
    revert a state change, so restore states FIRST; this script reports transitions it
    sees and refuses to treat them as its business.
 
    Two phases, always in this order:
 
      1. DISCOVER (read-only). Query the organisation for work items changed in the window,
         read each one's revision history, and record every qualifying link in an evidence
         CSV. Written BEFORE anything is removed, so the removal is auditable and could be
         reversed by re-adding exactly what is listed.
      2. REMOVE. Per work item, re-read its current relations, resolve the indexes of the
         recorded links, and remove them in DESCENDING index order - a JSON Patch 'remove'
         renumbers everything after it, so ascending order deletes the wrong relations.
 
    Re-running is safe: a checkpoint records completed work items, and a link already gone
    is skipped rather than failed.
 
.PARAMETER Collection
    Organisation URL holding the work items, e.g. https://dev.azure.com/contoso.
 
.PARAMETER Project
    Project containing the repository whose links are being removed.
 
.PARAMETER RepoName
    Repository whose commit links are to be removed. Only links pointing at commits in
    this repository are touched.
 
.PARAMETER SinceDays
    How far back to look, in days. Default 30. Links added before this are left alone.
 
.PARAMETER ChangedBy
    Optional. Restrict to links added by this identity (the account that ran the
    migration). Omit to consider any identity within the window - wider, and worth
    thinking about before using, since it can catch genuine links made by real people.
 
.PARAMETER EvidencePath
    Where the evidence CSV is written. Defaults to 'commit-mention-links.csv' beside the
    checkpoint. Commit it: it is the record of what was removed.
 
.PARAMETER CheckpointPath
    Where progress is recorded so a re-run resumes. Defaults beside the evidence file.
 
.PARAMETER Pat
    Personal access token (Work Items Read & Write). Omit to use Entra - the default.
 
.EXAMPLE
    # ALWAYS do this first: discovers and writes the evidence CSV, removes nothing.
    .\Remove-CommitMentionLinks.ps1 -Collection https://dev.azure.com/contoso `
        -Project Subsurface -RepoName PTL-AllInOne -ChangedBy someone@contoso.com -WhatIf
 
.EXAMPLE
    .\Remove-CommitMentionLinks.ps1 -Collection https://dev.azure.com/contoso `
        -Project Subsurface -RepoName PTL-AllInOne -ChangedBy someone@contoso.com -SinceDays 30
 
.NOTES
    Run with -WhatIf first, always, and read the evidence CSV before committing to the
    removal. This writes to work items across the whole organisation, most of them owned
    by teams with no connection to the migration.
#>

[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
param(
    [Parameter(Mandatory = $true)]
    [ValidatePattern('^https://')]
    [string]$Collection,

    [Parameter(Mandatory = $true)]
    [string]$Project,

    [Parameter(Mandatory = $true)]
    [string]$RepoName,

    [ValidateRange(1, 3650)]
    [int]$SinceDays = 30,

    [string]$ChangedBy,

    [string]$EvidencePath,

    [string]$CheckpointPath,

    [string]$Pat
)

$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest

#region Auth and helpers -------------------------------------------------------

function Initialize-Auth {
    # Ambient identity first, stored token as the fallback - the module convention.
    $token = $null
    $entraError = $null
    if (Get-Command Get-AzureDevOpsAccessToken -ErrorAction SilentlyContinue) {
        try { $token = Get-AzureDevOpsAccessToken -Collection $Collection }
        catch { $entraError = $_.Exception.Message }
    }
    else { $entraError = 'the NKDAgility.AzureDevOps.AutomationTools module is not loaded' }

    if ($token) {
        $script:Headers = @{ Authorization = 'Bearer ' + $token; Accept = 'application/json' }
        Write-Host '==> Auth: Entra.' -ForegroundColor DarkGray
        return
    }
    if ($Pat) {
        $b64 = [Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes(":$Pat"))
        $script:Headers = @{ Authorization = "Basic $b64"; Accept = 'application/json' }
        Write-Warning "Entra sign-in unavailable ($entraError); using the supplied PAT."
        return
    }
    throw "No credential available: Entra sign-in failed ($entraError) and no -Pat was supplied."
}

function Invoke-Ado {
    # Retries on throttling. Removing thousands of links WILL hit rate limits, and a
    # 429 treated as a failure would leave the run half-done for no reason.
    param([string]$Uri, [string]$Method = 'Get', [object]$Body, [string]$ContentType = 'application/json')

    for ($attempt = 1; $attempt -le 5; $attempt++) {
        $params = @{ Uri = $Uri; Headers = $script:Headers; Method = $Method; SkipHttpErrorCheck = $true }
        if ($PSBoundParameters.ContainsKey('Body')) { $params.Body = $Body; $params.ContentType = $ContentType }

        # A dropped socket is an exception, not a status code, so it has to be caught
        # rather than checked for.
        $response = $null
        try { $response = Invoke-WebRequest @params -ErrorAction Stop }
        catch {
            if ($attempt -eq 5) { throw "Transport failure calling $Uri : $($_.Exception.Message)" }
            Start-Sleep -Seconds ([math]::Min(30, 2 * $attempt))
            continue
        }

        if ($response.StatusCode -eq 429 -or $response.StatusCode -ge 500) {
            $wait = [int]($response.Headers['Retry-After'] | Select-Object -First 1)
            if (-not $wait) { $wait = [math]::Min(30, [math]::Pow(2, $attempt)) }
            Write-Host (" throttled ({0}); waiting {1}s" -f $response.StatusCode, $wait) -ForegroundColor DarkGray
            Start-Sleep -Seconds $wait
            continue
        }
        if ($response.StatusCode -ge 400) {
            throw "HTTP $($response.StatusCode) from $Uri : $($response.Content)"
        }
        if (-not $response.Content) { return $null }

        # Azure DevOps answers a sign-in redirect, and some throttling responses, with an
        # HTML page carrying a 2xx status. Handing that to ConvertFrom-Json produces
        # 'Unexpected character encountered while parsing value: <', which says nothing
        # about the real problem. Detected, retried, and finally reported for what it is.
        $content = [string]$response.Content
        if ($content.TrimStart().StartsWith('<')) {
            if ($attempt -lt 5) {
                Write-Host (" non-JSON (HTML) response; retrying in {0}s" -f (2 * $attempt)) -ForegroundColor DarkGray
                Start-Sleep -Seconds (2 * $attempt)
                continue
            }
            throw ("Received an HTML response instead of JSON from $Uri. " +
                'That is normally an authentication redirect (the token was rejected or has expired) ' +
                'or heavy throttling. Re-run after signing in again, or with fewer concurrent runs.')
        }

        try { return ($content | ConvertFrom-Json) }
        catch { throw "Could not parse the response from $Uri as JSON: $($_.Exception.Message)" }
    }
    throw "Gave up after repeated failures: $Uri"
}

function Get-OrgName { param([string]$Url) ($Url.TrimEnd('/') -split '/')[-1] }

#endregion Auth and helpers ----------------------------------------------------

#region Discovery --------------------------------------------------------------

function Get-TargetRepositoryId {
    param([string]$Name)
    $org = Get-OrgName -Url $Collection
    $projectSeg = [uri]::EscapeDataString($Project)
    $nameSeg = [uri]::EscapeDataString($Name)
    $repo = Invoke-Ado -Uri "https://dev.azure.com/$org/$projectSeg/_apis/git/repositories/$nameSeg`?api-version=7.1"
    if (-not $repo.id) { throw "Repository '$Name' was not found in project '$Project'." }
    return $repo.id
}

function Get-CandidateWorkItemId {
    <# Work items changed in the window, organisation-wide.
 
       Deliberately NOT driven from the repository's commits: that would mean querying
       artifact uris for every commit in the history (119,694 on the engagement that
       prompted this), where one identity-and-window query returns the same set directly
       and is scoped to what the migration actually did. #>

    $org = Get-OrgName -Url $Collection
    $where = @("[System.ChangedDate] >= @today-$SinceDays")
    if ($ChangedBy) { $where += "[System.ChangedBy] = '$ChangedBy'" }
    $wiql = "SELECT [System.Id] FROM WorkItems WHERE " + ($where -join ' AND ')

    Write-Host "==> Finding work items changed in the last $SinceDays day(s)$(if ($ChangedBy) { " by $ChangedBy" })..." -ForegroundColor Cyan
    $result = Invoke-Ado -Uri "https://dev.azure.com/$org/_apis/wit/wiql?api-version=7.1" -Method Post `
        -Body (@{ query = $wiql } | ConvertTo-Json)
    $ids = @($result.workItems | ForEach-Object { $_.id })
    Write-Host " $($ids.Count) candidate work item(s)." -ForegroundColor DarkGray
    return $ids
}

function Get-WorkItemSummary {
    <# Project, type, title and state for a set of ids, in batches of 200.
 
       Only used to make the preview readable: 'work item 1735710' says nothing, while
       'sliic / Task / editable file location...' tells the operator whose data this is
       about to touch. #>

    param([int[]]$Ids)

    $org = Get-OrgName -Url $Collection
    $summary = @{}
    for ($offset = 0; $offset -lt $Ids.Count; $offset += 200) {
        $batch = @($Ids[$offset..([math]::Min($offset + 199, $Ids.Count - 1))])
        $body = @{
            ids    = $batch
            fields = @('System.TeamProject', 'System.WorkItemType', 'System.Title', 'System.State')
        } | ConvertTo-Json -Depth 4
        $result = Invoke-Ado -Uri "https://dev.azure.com/$org/_apis/wit/workitemsbatch?api-version=7.1" -Method Post -Body $body
        foreach ($item in @($result.value)) {
            $summary[[int]$item.id] = [pscustomobject]@{
                Project = $item.fields.'System.TeamProject'
                Type    = $item.fields.'System.WorkItemType'
                Title   = $item.fields.'System.Title'
                State   = $item.fields.'System.State'
            }
        }
    }
    return $summary
}

function Write-WhatIfReport {
    <# The preview. Deliberately a REPORT rather than one ShouldProcess line per item:
       6,796 near-identical lines cannot be reviewed, and this change is spread across
       other teams' projects, so the operator needs to see the shape of it - which
       projects, how many each, over what window, and anything the script will not fix. #>

    param($Links, $StateChanges, $Summary, $EvidencePath)

    $byItem = @($Links | Group-Object WorkItemId)
    $projects = @{}
    foreach ($group in $byItem) {
        $project = if ($Summary.ContainsKey([int]$group.Name)) { $Summary[[int]$group.Name].Project } else { '(unknown)' }
        if (-not $projects.ContainsKey($project)) { $projects[$project] = [pscustomobject]@{ Items = 0; Links = 0 } }
        $projects[$project].Items++
        $projects[$project].Links += $group.Count
    }

    $dates = @($Links | ForEach-Object { $_.AddedOn }) | Sort-Object
    $authors = @($Links | ForEach-Object { $_.AddedBy } | Sort-Object -Unique)

    Write-Host ''
    Write-Host '================ WHAT WOULD BE REMOVED ================' -ForegroundColor Yellow
    Write-Host (" work items : {0:N0}" -f $byItem.Count)
    Write-Host (" links : {0:N0}" -f @($Links).Count)
    Write-Host (" projects : {0:N0}" -f $projects.Count)
    Write-Host (" repository : {0}" -f $RepoName)
    if ($dates.Count) {
        Write-Host (" links added : {0:yyyy-MM-dd HH:mm} .. {1:yyyy-MM-dd HH:mm}" -f $dates[0], $dates[-1])
    }
    Write-Host (" window : last {0} day(s)" -f $SinceDays)
    Write-Host (" added by : {0}" -f ($authors -join ', '))

    Write-Host ''
    Write-Host ' Affected projects (most affected first):' -ForegroundColor Yellow
    $projects.GetEnumerator() | Sort-Object { $_.Value.Links } -Descending | Select-Object -First 25 | ForEach-Object {
        Write-Host (" {0,-45} {1,6:N0} link(s) on {2,6:N0} work item(s)" -f $_.Key, $_.Value.Links, $_.Value.Items)
    }
    if ($projects.Count -gt 25) { Write-Host (" ... and {0} more project(s)" -f ($projects.Count - 25)) }

    Write-Host ''
    Write-Host ' Sample of what would be removed:' -ForegroundColor Yellow
    foreach ($group in ($byItem | Select-Object -First 10)) {
        $id = [int]$group.Name
        $info = if ($Summary.ContainsKey($id)) { $Summary[$id] } else { $null }
        $title = if ($info) { $info.Title } else { '' }
        if ($title.Length -gt 48) { $title = $title.Substring(0, 45) + '...' }
        Write-Host (" #{0,-9} {1,-28} {2,-14} {3} link(s) {4}" -f
            $id, ($(if ($info) { $info.Project } else { '?' })), ($(if ($info) { $info.State } else { '?' })), $group.Count, $title)
    }
    if ($byItem.Count -gt 10) { Write-Host (" ... and {0:N0} more work item(s)" -f ($byItem.Count - 10)) }

    # Surfaced loudly: this script does not revert transitions, and removing a link
    # will not put a wrongly-closed work item back. Only transitions Azure DevOps
    # attributes to mention resolution are called out - the operator's own corrective
    # edits are separated, because a remediation list that includes the remediation
    # is one nobody can act on.
    $byMention = @($StateChanges | Where-Object { $_.ByMention })
    $other = @($StateChanges | Where-Object { -not $_.ByMention })

    if ($byMention.Count -gt 0) {
        Write-Host ''
        Write-Host ' *** STATE CHANGES CAUSED BY MENTION RESOLUTION - NOT FIXED BY THIS SCRIPT ***' -ForegroundColor Red
        Write-Host ' Removing a link does not revert a transition. Restore these by hand FIRST:' -ForegroundColor Red
        foreach ($change in ($byMention | Sort-Object WorkItemId)) {
            $info = if ($Summary.ContainsKey([int]$change.WorkItemId)) { $Summary[[int]$change.WorkItemId] } else { $null }
            $now = if ($info) { $info.State } else { 'unknown' }
            $flag = if ($info -and $info.State -eq $change.OldState) { ' [already restored]' } else { '' }
            Write-Host (" #{0,-9} {1,-28} {2} -> {3} (now: {4}){5}" -f
                $change.WorkItemId, ($(if ($info) { $info.Project } else { '?' })), $change.OldState, $change.NewState,
                $now, $flag) -ForegroundColor Red
        }
    }

    if ($other.Count -gt 0) {
        Write-Host ''
        Write-Host (" Other state changes by this identity in the window ({0}) - informational, not caused by mentions:" -f $other.Count) -ForegroundColor DarkGray
        foreach ($change in ($other | Sort-Object WorkItemId)) {
            $info = if ($Summary.ContainsKey([int]$change.WorkItemId)) { $Summary[[int]$change.WorkItemId] } else { $null }
            Write-Host (" #{0,-9} {1,-28} {2} -> {3}" -f
                $change.WorkItemId, ($(if ($info) { $info.Project } else { '?' })),
                ($(if ($change.OldState) { $change.OldState } else { '(new)' })), $change.NewState) -ForegroundColor DarkGray
        }
    }

    Write-Host ''
    Write-Host (" Evidence CSV (every link listed): {0}" -f $EvidencePath) -ForegroundColor Green
    Write-Host ' Nothing has been changed. Re-run without -WhatIf to remove.' -ForegroundColor Yellow
    Write-Host '=======================================================' -ForegroundColor Yellow
}

#endregion Discovery -----------------------------------------------------------

#region Removal ----------------------------------------------------------------

function Remove-WorkItemLink {
    <# Removes the given relation urls from one work item.
 
       Indexes are resolved against the CURRENT relations - never against what discovery
       saw, which may be stale - and removed in DESCENDING order, because each JSON Patch
       'remove' renumbers every relation after it. Ascending order silently deletes the
       wrong ones. #>

    param([int]$WorkItemId, [string[]]$Urls)

    $org = Get-OrgName -Url $Collection
    $item = Invoke-Ado -Uri "https://dev.azure.com/$org/_apis/wit/workitems/$WorkItemId`?`$expand=relations&api-version=7.1"

    $relations = @($item.relations)
    $indexes = [System.Collections.Generic.List[int]]::new()
    for ($i = 0; $i -lt $relations.Count; $i++) {
        if ($relations[$i].rel -eq 'ArtifactLink' -and $Urls -contains $relations[$i].url) { $indexes.Add($i) }
    }
    if ($indexes.Count -eq 0) { return 0 }   # already gone - a re-run, not a failure

    $patch = @($indexes | Sort-Object -Descending | ForEach-Object {
            @{ op = 'remove'; path = "/relations/$_" }
        })

    Invoke-Ado -Uri "https://dev.azure.com/$org/_apis/wit/workitems/$WorkItemId`?api-version=7.1" `
        -Method Patch -Body (ConvertTo-Json @($patch) -Depth 5) -ContentType 'application/json-patch+json' | Out-Null

    # Verified by re-reading: the removal is the whole point, so it is proven, not assumed.
    $after = Invoke-Ado -Uri "https://dev.azure.com/$org/_apis/wit/workitems/$WorkItemId`?`$expand=relations&api-version=7.1"
    $remaining = @(@($after.relations) | Where-Object { $_.rel -eq 'ArtifactLink' -and $Urls -contains $_.url })
    if ($remaining.Count -gt 0) {
        throw "work item $WorkItemId still has $($remaining.Count) of the targeted link(s) after the patch"
    }
    return $indexes.Count
}

#endregion Removal -------------------------------------------------------------

#region Main -------------------------------------------------------------------

Initialize-Auth

$since = (Get-Date).Date.AddDays(-$SinceDays)
$repoId = Get-TargetRepositoryId -Name $RepoName
Write-Host "==> Repository '$RepoName' = $repoId" -ForegroundColor DarkGray

if (-not $EvidencePath) {
    # The workspace output folder when there is one - machine-local and gitignored -
    # rather than whatever directory the operator happened to be standing in.
    $root = $null
    if (Get-Command Get-AutomationWorkspace -ErrorAction SilentlyContinue) {
        try { $root = (Get-AutomationWorkspace).OutputFolder } catch { $root = $null }
    }
    if (-not $root) { $root = (Get-Location).Path }
    $EvidencePath = Join-Path $root ("commit-mention-links-{0}.csv" -f ($RepoName -replace '[^\w\.-]', '-'))
}
$evidenceDir = Split-Path -Parent $EvidencePath
if ($evidenceDir -and -not (Test-Path -LiteralPath $evidenceDir)) {
    New-Item -ItemType Directory -Path $evidenceDir -Force | Out-Null
}
if (-not $CheckpointPath) { $CheckpointPath = [System.IO.Path]::ChangeExtension($EvidencePath, '.checkpoint.txt') }

$candidates = Get-CandidateWorkItemId
if ($candidates.Count -eq 0) { Write-Host 'Nothing to do.' -ForegroundColor Green; return }

Write-Host "==> Reading revision history (read-only)..." -ForegroundColor Cyan
$headersForParallel = $script:Headers
$org = Get-OrgName -Url $Collection

$scanBlock = {
    $id = $_
    $h = $using:headersForParallel
    $who = $using:ChangedBy
    $rid = $using:repoId
    $since = $using:since
    $org = $using:org

    # Six attempts, because a long scan WILL hit both throttling and dropped sockets.
    # A transport failure ('connection forcibly closed') is an EXCEPTION, not a status
    # code, so -SkipHttpErrorCheck does not cover it - without this try/catch a single
    # dropped connection out of thousands aborts the whole run.
    $u = $null
    for ($try = 1; $try -le 6; $try++) {
        try {
            $r = Invoke-WebRequest -Uri "https://dev.azure.com/$org/_apis/wit/workitems/$id/updates?api-version=7.1" -Headers $h -SkipHttpErrorCheck -ErrorAction Stop
            if ($r.StatusCode -eq 429 -or $r.StatusCode -ge 500) { Start-Sleep -Seconds ([math]::Min(30, 2 * $try)); continue }
            if ($r.StatusCode -ne 200) {
                return [pscustomobject]@{ Kind = 'error'; WorkItemId = $id; Reason = "HTTP $($r.StatusCode)" }
            }
            $u = $r.Content | ConvertFrom-Json
            break
        }
        catch {
            if ($try -eq 6) {
                # Surfaced, never swallowed: an item that could not be read is an item
                # whose links are unknown, and silently dropping it would make an
                # incomplete scan look complete.
                return [pscustomobject]@{ Kind = 'error'; WorkItemId = $id; Reason = $_.Exception.Message }
            }
            Start-Sleep -Seconds ([math]::Min(30, 2 * $try))
        }
    }
    if (-not $u) { return [pscustomobject]@{ Kind = 'error'; WorkItemId = $id; Reason = 'no response' } }

        foreach ($rev in @($u.value)) {
            $by = $rev.revisedBy.uniqueName
            if ($who -and $by -ne $who) { continue }
            $when = $rev.fields.'System.ChangedDate'.newValue -as [datetime]
            if (-not $when -or $when -lt $since) { continue }

            # Recorded so the preview can warn about it. This script never acts on a
            # state change - removing a link does not revert one.
            $st = $rev.fields.'System.State'
            if ($st -and $st.newValue -and $st.oldValue -ne $st.newValue) {
                # Azure DevOps stamps its own comment when mention resolution transitions
                # an item. Without this, the operator's OWN restores - and new work items
                # being created - are reported back to them as damage, which is worse than
                # useless on a list they are meant to act on.
                $note = $rev.fields.'System.History'.newValue
                [pscustomobject]@{
                    Kind        = 'state'
                    WorkItemId  = $id
                    OldState    = $st.oldValue
                    NewState    = $st.newValue
                    OldReason   = $rev.fields.'System.Reason'.oldValue
                    AddedOn     = $when
                    AddedBy     = $by
                    Note        = $note
                    ByMention   = [bool]($note -and $note -match 'mentioned work items')
                }
            }

            if (-not $rev.relations) { continue }
            foreach ($rel in @($rev.relations.added)) {
                if ($rel.rel -ne 'ArtifactLink') { continue }
                if ($rel.url -notlike "*$rid*") { continue }
                [pscustomobject]@{
                    Kind       = 'link'
                    WorkItemId = $id
                    Rev        = $rev.rev
                    AddedOn    = $when
                    AddedBy    = $by
                    Commit     = ($rel.url -split '%2F')[-1]
                    Url        = $rel.url
                }
            }
        }
}

# Chunked so progress is visible. The first version printed nothing for several minutes
# while it read thousands of work items, which is indistinguishable from a hang - the
# same fault that made the LFS scan look locked up, and the reason a run got abandoned
# as broken when it was working.
$discovered = [System.Collections.Generic.List[object]]::new()
$chunkSize = 500
for ($offset = 0; $offset -lt $candidates.Count; $offset += $chunkSize) {
    $upper = [math]::Min($offset + $chunkSize - 1, $candidates.Count - 1)
    $chunk = @($candidates[$offset..$upper])
    foreach ($row in @($chunk | ForEach-Object -ThrottleLimit 8 -Parallel $scanBlock)) {
        if ($row) { $discovered.Add($row) }
    }
    Write-Host (" {0,6:N0}/{1:N0} read; {2:N0} link(s) found" -f
        ($upper + 1), $candidates.Count,
        @($discovered | Where-Object { $_.Kind -eq 'link' }).Count) -ForegroundColor DarkGray
}

$all = @($discovered | Where-Object { $_ })
$links = @($all | Where-Object { $_.Kind -eq 'link' })
$stateChanges = @($all | Where-Object { $_.Kind -eq 'state' })
$readErrors = @($all | Where-Object { $_.Kind -eq 'error' })
$items = @($links | Group-Object WorkItemId)
Write-Host " $($links.Count) link(s) on $($items.Count) work item(s); $($stateChanges.Count) state change(s); $($readErrors.Count) unreadable." -ForegroundColor DarkGray

# An item that could not be read is an item whose links are UNKNOWN. Removing what was
# found while pretending the scan was complete would leave links behind and report
# success, so this refuses to mutate anything until the scan is clean. -WhatIf still
# reports, because seeing the shape of the problem is exactly what it is for.
if ($readErrors.Count -gt 0) {
    Write-Warning ("{0} work item(s) could not be read - the scan is INCOMPLETE." -f $readErrors.Count)
    $readErrors | Select-Object -First 5 | ForEach-Object { Write-Warning " $($_.WorkItemId): $($_.Reason)" }
    if (-not $WhatIfPreference) {
        throw "Refusing to remove links from an incomplete scan. Re-run: reads are retried, and a clean scan is required before anything is changed."
    }
}

# Evidence is written BEFORE any mutation, so what was removed is recoverable from it.
#
# Written even when there is nothing to record. Piping an empty result to Export-Csv
# creates NO FILE, which makes 'no links found' and 'the run died' look identical to
# whoever comes looking for the evidence afterwards.
#
# -WhatIf:$false on every write below is deliberate. Export-Csv and Set-Content honour
# ShouldProcess, so under -WhatIf they silently write NOTHING - which would make the
# preview useless precisely when it matters most, since reviewing 11,000 links means
# reading the file, not the console. These write to the operator's own output folder
# and change nothing in Azure DevOps, so they are not what -WhatIf is protecting.
# An incomplete scan gets its own filename. A run that could read only 742 of 6,799 work
# items produced a 21-row CSV that looks exactly like a complete one - and these files are
# committed as engagement evidence, so a partial list mistaken for the full picture is a
# worse outcome than no list at all.
if ($readErrors.Count -gt 0) {
    $EvidencePath = [System.IO.Path]::ChangeExtension($EvidencePath, '.INCOMPLETE.csv')
    Write-Warning "Scan incomplete - evidence will be written to $EvidencePath and must not be treated as the full set."
}

$evidenceRows = @($links | Sort-Object WorkItemId, Commit |
        Select-Object WorkItemId, Rev, AddedOn, AddedBy, Commit, Url)
if ($evidenceRows.Count) {
    $evidenceRows | Export-Csv -LiteralPath $EvidencePath -NoTypeInformation -Encoding UTF8 -WhatIf:$false
}
else {
    Set-Content -LiteralPath $EvidencePath -Value '"WorkItemId","Rev","AddedOn","AddedBy","Commit","Url"' -Encoding UTF8 -WhatIf:$false
}
Write-Host "==> Evidence written: $EvidencePath ($($evidenceRows.Count) row(s))" -ForegroundColor Green

if ($stateChanges.Count) {
    $statePath = [System.IO.Path]::ChangeExtension($EvidencePath, '.state-changes.csv')
    $stateChanges | Sort-Object WorkItemId |
        Select-Object WorkItemId, OldState, NewState, OldReason, AddedOn, AddedBy, ByMention, Note |
        Export-Csv -LiteralPath $statePath -NoTypeInformation -Encoding UTF8 -WhatIf:$false
    Write-Host "==> State changes recorded separately: $statePath" -ForegroundColor Yellow
}

if ($links.Count -eq 0) { Write-Host 'No qualifying links found.' -ForegroundColor Green; return }

# The preview is a report, not thousands of ShouldProcess lines. Everything above this
# point is read-only, so -WhatIf reaches here having changed nothing.
# State-changed items are included even when they carry no links, otherwise they render
# as '?' in the very section the operator is meant to act on.
$summary = Get-WorkItemSummary -Ids @(
    @($items | ForEach-Object { [int]$_.Name }) + @($stateChanges | ForEach-Object { [int]$_.WorkItemId }) |
        Sort-Object -Unique)
if ($WhatIfPreference) {
    Write-WhatIfReport -Links $links -StateChanges $stateChanges -Summary $summary -EvidencePath $EvidencePath
    return
}

if ($stateChanges.Count) {
    Write-Warning ("{0} work item(s) were also STATE-CHANGED. This script does not revert those - restore them first, then re-run." -f $stateChanges.Count)
}

$done = @{}
if (Test-Path -LiteralPath $CheckpointPath) {
    Get-Content -LiteralPath $CheckpointPath | Where-Object { $_ } | ForEach-Object { $done[[int]$_] = $true }
    if ($done.Count) { Write-Host "==> Resuming: $($done.Count) work item(s) already done." -ForegroundColor DarkGray }
}

$removed = 0
$failed = [System.Collections.Generic.List[string]]::new()
$index = 0

foreach ($group in $items) {
    $index++
    $workItemId = [int]$group.Name
    if ($done.ContainsKey($workItemId)) { continue }

    $urls = @($group.Group | ForEach-Object { $_.Url })
    $what = "work item $workItemId ($($urls.Count) link(s))"

    if (-not $PSCmdlet.ShouldProcess($what, 'Remove commit mention link(s)')) { continue }

    try {
        $count = Remove-WorkItemLink -WorkItemId $workItemId -Urls $urls
        $removed += $count
        Add-Content -LiteralPath $CheckpointPath -Value $workItemId
        if ($index % 50 -eq 0) {
            Write-Host (" [{0}/{1}] {2} link(s) removed so far" -f $index, $items.Count, $removed) -ForegroundColor DarkGray
        }
    }
    catch {
        # One work item must not stop thousands of others; it is recorded and reported.
        $failed.Add("$workItemId : $($_.Exception.Message)")
        Write-Warning " $workItemId : $($_.Exception.Message)"
    }
}

Write-Host ''
Write-Host '================ Summary ================' -ForegroundColor Cyan
Write-Host ("work items : {0}" -f $items.Count)
Write-Host ("links removed: {0}" -f $removed)
Write-Host ("failed : {0}" -f $failed.Count)
if ($failed.Count) { $failed | ForEach-Object { Write-Host " $_" -ForegroundColor Red } }
Write-Host ("evidence : {0}" -f $EvidencePath)
Write-Host '=========================================' -ForegroundColor Cyan

#endregion Main ----------------------------------------------------------------