Engines/Update-WikiWorkItemLinks.ps1

<#
.SYNOPSIS
    Repoints Azure DevOps work item links inside a migrated project wiki from the
    source org/project (and source work item IDs) to the target's.
 
.DESCRIPTION
    The work item migration assigns NEW IDs in the target organisation, so a wiki
    link such as
        https://dev.azure.com/<srcOrg>/<srcProject>/_workitems/edit/<srcId>
    cannot simply have its org/project swapped: the ID changes too. Each target
    work item records its original source reference in Custom.ReflectedWorkItemId,
    absolute URLs of the form
        https://dev.azure.com/<srcOrg>/<srcProject>/_workitems/edit/<srcId>
    are rewritten (both org/project and the numeric id) to the target, and, by
    default, bare '#<srcId>' work item mentions are also remapped to
    '#<targetId>'. To avoid false positives (hex colours like #666666, heading
    anchors, discussion-thread fragments such as ...edit/123#16612706), a
    '#<id>' is only rewritten when <id> is a known source work item in the map.
    Any '#<discussion>' anchor on a URL is preserved. Links whose target work
    item cannot be resolved (e.g. the wiki is processed before the work items
    are migrated) are left unchanged.
 
    The rewrite is applied inside a temporary git worktree of the wiki's bare
    mirror so the full history is preserved. By DEFAULT the script runs in
    PREVIEW mode: it reports every change (and any unresolved links), shows a
    'git diff --stat', and then discards the edits WITHOUT committing or pushing,
    so the result can be validated first. Pass -Commit to instead commit the
    rewrite onto the wiki branch (Migrate-Repos.ps1 pushes it afterwards). This
    script never pushes.
 
.PARAMETER SourceOrg
    Source organization URL, e.g. https://dev.azure.com/georgfischer.
 
.PARAMETER SourceProject
    Source project name (used to match work item links in the wiki).
 
.PARAMETER TargetOrg
    Target organization URL, e.g. https://dev.azure.com/machining.
 
.PARAMETER TargetPat
    Personal Access Token for the target organization (Work Items Read).
    Optional: Entra is the default. When the automation module is loaded the
    script acquires an Entra access token via Get-AzureDevOpsAccessToken
    (re-resolved per work item batch so the cached token renews across a long
    run); the PAT is only used when Entra sign-in is unavailable or fails.
 
.PARAMETER TargetProject
    Target project name.
 
.PARAMETER CloneDir
    Path to the wiki's bare Git mirror (the '<Project>.wiki.git' directory that
    Migrate-Repos.ps1 creates; kept when -KeepClones is used).
 
.PARAMETER Branch
    Wiki branch to rewrite. Default: wikiMaster.
 
.PARAMETER Commit
    Commit the rewrite onto the branch. Omit (the default) to preview only:
    changes are reported and then discarded without committing or pushing.
 
.PARAMETER ShowDiff
    Also print the full unified diff of the pending changes (in addition to the
    summary table and --stat).
 
.PARAMETER SkipMentions
    Do not rewrite bare '#<id>' work item mentions; only rewrite full
    '_workitems/edit/<id>' URLs.
 
.PARAMETER LogDir
    Directory to write CSV logs of the run to. Two timestamped files are
    written: '<ts>-wiki-link-changes.csv' (every rewritten link) and, when any
    exist, '<ts>-wiki-link-unresolved.csv' (source ids with no target work
    item). The directory is created if missing. When omitted no logs are
    written.
 
.EXAMPLE
    # Preview only (no commit, no push) against an already-cloned wiki mirror.
    # Ambient identity: Entra for the target, no PAT (pass -TargetPat as a
    # fallback for unattended runs).
    .\Update-WikiWorkItemLinks.ps1 `
        -SourceOrg https://dev.azure.com/georgfischer -SourceProject GF.MS.S3R.Kebnekaise `
        -TargetOrg https://dev.azure.com/machining -TargetProject UM-S3R-WSM `
        -CloneDir 'C:\Users\default-admin\source\export\georgfischer\GF.MS.S3R.Kebnekaise\repos\GF.MS.S3R.Kebnekaise.wiki.git'
 
.OUTPUTS
    One object per rewritten link: Page, SourceId, TargetId, OldUrl, NewUrl.
 
.NOTES
    Requires Git 2.5+ (git worktree). Called by Migrate-Repos.ps1 with -Commit as
    part of a wiki migration; can be run standalone to validate the rewrite.
#>

[CmdletBinding()]
param(
    [Parameter(Mandatory = $true)]
    [ValidatePattern('^https://')]
    [string]$SourceOrg,

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

    [Parameter(Mandatory = $true)]
    [ValidatePattern('^https://')]
    [string]$TargetOrg,

    [string]$TargetPat,

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

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

    [string]$Branch = 'wikiMaster',

    [switch]$Commit,

    [switch]$ShowDiff,

    [switch]$SkipMentions,

    [string]$LogDir
)

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

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

function Get-AuthHeader {
    param([string]$Pat)
    @{ Authorization = 'Basic ' + [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(":$Pat")) }
}

function Initialize-TargetAuth {
    # Ambient identity first: an Entra access token works anywhere a PAT does, so
    # Entra is the default and -TargetPat only the fallback. Called before every
    # work item detail batch, not just once: the module caches the token and renews
    # it shortly before expiry, so re-resolving keeps a long run authenticated.
    # Announces the mode once - never the credential.
    $token = $null
    $entraError = $null
    if (Get-Command Get-AzureDevOpsAccessToken -ErrorAction SilentlyContinue) {
        try { $token = Get-AzureDevOpsAccessToken -Collection $TargetOrg }
        catch { $entraError = $_.Exception.Message }
    }
    else {
        $entraError = 'the NKDAgility.AzureDevOps.AutomationTools module is not loaded'
    }

    if ($token) {
        $script:TargetHeaders = @{ Authorization = 'Bearer ' + $token }
        if ($script:TargetAuthMode -ne 'Entra') {
            Write-Host '==> Target auth: Entra.' -ForegroundColor DarkGray
            $script:TargetAuthMode = 'Entra'
        }
        return
    }

    if ($TargetPat) {
        $script:TargetHeaders = Get-AuthHeader -Pat $TargetPat
        if ($script:TargetAuthMode -ne 'PAT') {
            Write-Warning ("Entra sign-in unavailable ({0}); falling back to the target PAT." -f $entraError)
            $script:TargetAuthMode = 'PAT'
        }
        return
    }

    throw ("No target credential available: Entra sign-in failed ({0}) and no -TargetPat was supplied. Sign in to Entra, or add the target PAT to secrets\secrets.json." -f $entraError)
}

function Invoke-AdoApi {
    param([string]$Uri, [hashtable]$Headers, [string]$Method = 'Get', [object]$Body)
    $params = @{ Uri = $Uri; Headers = $Headers; Method = $Method }
    if ($PSBoundParameters.ContainsKey('Body')) {
        $params.Body = ($Body | ConvertTo-Json -Depth 10)
        $params.ContentType = 'application/json'
    }
    Invoke-RestMethod @params
}

function Get-TargetWorkItemIdMap {
    # Builds source work item ID -> target work item ID from the target project's
    # Custom.ReflectedWorkItemId field. The trailing integer of that field is the
    # original source ID; System.Id is the new target ID (they differ).

    $reflectedField = 'Custom.ReflectedWorkItemId'
    $org = Get-OrgName -OrgUrl $TargetOrg
    $projSeg = [uri]::EscapeDataString($TargetProject)

    $wiql = @{ query = "SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = '$TargetProject' AND [$reflectedField] <> ''" }
    $wiqlUrl = "https://dev.azure.com/$org/$projSeg/_apis/wit/wiql?api-version=7.1"

    $map = @{}
    $result = Invoke-AdoApi -Uri $wiqlUrl -Headers $script:TargetHeaders -Method Post -Body $wiql
    $ids = @($result.workItems | ForEach-Object { $_.id })
    if (-not $ids) { return $map }

    # The work items API caps at 200 IDs per request.
    for ($i = 0; $i -lt $ids.Count; $i += 200) {
        # Renew a near-expiry Entra token before each batch (cache hit otherwise).
        Initialize-TargetAuth
        $batch = $ids[$i..([math]::Min($i + 199, $ids.Count - 1))]
        $idList = $batch -join ','
        $detailUrl = "https://dev.azure.com/$org/_apis/wit/workitems?ids=$idList&fields=System.Id,$reflectedField&api-version=7.1"
        $details = Invoke-AdoApi -Uri $detailUrl -Headers $script:TargetHeaders
        foreach ($wi in $details.value) {
            $reflected = $wi.fields.$reflectedField
            if (-not $reflected) { continue }
            $m = [regex]::Match([string]$reflected, '(\d+)\s*$')
            if ($m.Success) {
                $map[[int]$m.Groups[1].Value] = [int]$wi.fields.'System.Id'
            }
        }
    }
    $map
}

function Invoke-Git {
    param([Parameter(ValueFromRemainingArguments = $true)][string[]]$GitArgs)
    & git @GitArgs
    if ($LASTEXITCODE -ne 0) {
        throw "git $($GitArgs -join ' ') failed with exit code $LASTEXITCODE"
    }
}

# Verify prerequisites.
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
    throw 'Git was not found on PATH. Install Git 2.x and try again.'
}
if (-not (Test-Path -LiteralPath (Join-Path $CloneDir 'HEAD'))) {
    throw "CloneDir does not look like a git repository (no HEAD): $CloneDir"
}

# Ambient-first credential resolution: Entra then the -TargetPat fallback,
# renewed per work item detail batch (see Initialize-TargetAuth).
$script:TargetAuthMode = $null
Initialize-TargetAuth
$srcOrg = Get-OrgName -OrgUrl $SourceOrg
$tgtOrg = Get-OrgName -OrgUrl $TargetOrg

Write-Host "==> Building source -> target work item ID map from '$TargetProject'..." -ForegroundColor Cyan
$idMap = Get-TargetWorkItemIdMap
if (-not $idMap.Count) {
    Write-Warning 'No migrated work items found in target (Custom.ReflectedWorkItemId is empty everywhere). Links left unchanged; re-run after work items are migrated.'
    return
}
Write-Host (" Loaded {0} work item ID mapping(s)." -f $idMap.Count) -ForegroundColor DarkGray

# Match source work item links, capturing the numeric ID. The project segment is
# matched in raw or URL-encoded form.
$srcProjPattern = [regex]::Escape($SourceProject) + '|' + [regex]::Escape([uri]::EscapeDataString($SourceProject))
$linkPattern = "https://dev\.azure\.com/$([regex]::Escape($srcOrg))/(?:$srcProjPattern)/_workitems/edit/(\d+)"
$targetBase = "https://dev.azure.com/$tgtOrg/$([uri]::EscapeDataString($TargetProject))/_workitems/edit/"

$changes = [System.Collections.Generic.List[object]]::new()
$unresolved = [System.Collections.Generic.List[object]]::new()
$script:CurrentPage = ''
$evaluator = {
    param($m)
    $srcId = [int]$m.Groups[1].Value
    if ($idMap.ContainsKey($srcId)) {
        $newUrl = $targetBase + $idMap[$srcId]
        $changes.Add([pscustomobject]@{
                Page     = $script:CurrentPage
                Kind     = 'url'
                SourceId = $srcId
                TargetId = $idMap[$srcId]
                OldUrl   = $m.Value
                NewUrl   = $newUrl
            })
        return $newUrl
    }
    # Leave unresolved links pointing at the source so they don't 404.
    $unresolved.Add([pscustomobject]@{ Page = $script:CurrentPage; SourceId = $srcId; Url = $m.Value })
    return $m.Value
}

# Bare '#<id>' work item mentions. Only ids present in the map are rewritten,
# which excludes hex colours, heading anchors and discussion-thread fragments.
# A negative lookbehind for '/' avoids touching the fragment part of a URL
# (e.g. '_workitems/edit/123#16612706'), and a lookbehind for word characters
# avoids matching inside identifiers.
$mentionPattern = '(?<![\w/])#(\d+)\b'
$mentionEvaluator = {
    param($m)
    $srcId = [int]$m.Groups[1].Value
    if ($idMap.ContainsKey($srcId)) {
        $changes.Add([pscustomobject]@{
                Page     = $script:CurrentPage
                Kind     = 'mention'
                SourceId = $srcId
                TargetId = $idMap[$srcId]
                OldUrl   = $m.Value
                NewUrl   = '#' + $idMap[$srcId]
            })
        return '#' + $idMap[$srcId]
    }
    # Not a known work item id (hex colour, anchor, other project) -> leave as-is.
    return $m.Value
}

$worktree = Join-Path ([System.IO.Path]::GetTempPath()) ("wiki-links-" + [Guid]::NewGuid().ToString('N'))
Invoke-Git -C $CloneDir worktree add --quiet $worktree $Branch
try {
    $pagesChanged = 0
    Get-ChildItem -LiteralPath $worktree -Recurse -File -Filter *.md | ForEach-Object {
        $script:CurrentPage = $_.FullName.Substring($worktree.Length).TrimStart('\', '/')
        $content = [System.IO.File]::ReadAllText($_.FullName)
        $updated = [regex]::Replace($content, $linkPattern, $evaluator)
        if (-not $SkipMentions) {
            $updated = [regex]::Replace($updated, $mentionPattern, $mentionEvaluator)
        }
        if ($updated -ne $content) {
            [System.IO.File]::WriteAllText($_.FullName, $updated)
            $pagesChanged++
        }
    }

    Write-Host ''
    Write-Host '================ Work item link rewrite ================' -ForegroundColor Cyan
    if ($changes.Count -eq 0) {
        Write-Host 'No work item links needed updating.' -ForegroundColor Yellow
    }
    else {
        $changes |
            Sort-Object Page, SourceId |
            Format-Table -AutoSize @(
                @{ Label = 'Page';       Expression = { $_.Page } }
                @{ Label = 'Kind';       Expression = { $_.Kind } }
                @{ Label = 'Source ID';  Expression = { $_.SourceId }; Alignment = 'Right' }
                @{ Label = 'Target ID';  Expression = { $_.TargetId }; Alignment = 'Right' }
            ) |
            Out-Host
        Write-Host ("{0} link(s) across {1} page(s) will be repointed." -f $changes.Count, $pagesChanged) -ForegroundColor Green
    }

    if ($unresolved.Count -gt 0) {
        Write-Warning ("{0} link(s) had no matching target work item and were left unchanged:" -f $unresolved.Count)
        $unresolved | Sort-Object SourceId -Unique | ForEach-Object {
            Write-Host (" - source id {0} ({1})" -f $_.SourceId, $_.Page) -ForegroundColor DarkYellow
        }
    }

    if ($pagesChanged -gt 0) {
        Write-Host ''
        Invoke-Git -C $worktree --no-pager diff --stat | Out-Host
        if ($ShowDiff) {
            Write-Host ''
            & git -C $worktree --no-pager diff | Out-Host
        }
    }

    if ($Commit) {
        if ($pagesChanged -gt 0) {
            Invoke-Git -C $worktree add -A
            Invoke-Git -C $worktree -c user.name=Migration -c user.email=migration@localhost `
                commit --quiet -m 'Migration: repoint work item links to target work items'
            Write-Host 'Committed rewrite onto branch (not pushed).' -ForegroundColor Green
        }
        else {
            Write-Host 'Nothing to commit.' -ForegroundColor DarkGray
        }
    }
    else {
        Write-Host ''
        Write-Host 'PREVIEW only: changes were NOT committed or pushed. Re-run with -Commit to apply.' -ForegroundColor Yellow
    }
    Write-Host '=======================================================' -ForegroundColor Cyan

    # Write CSV logs of the run when a log directory is supplied.
    if ($LogDir) {
        if (-not (Test-Path -LiteralPath $LogDir)) {
            New-Item -ItemType Directory -Path $LogDir -Force | Out-Null
        }
        $ts = Get-Date -Format 'yyyyMMdd-HHmmss'
        $changesLog = Join-Path $LogDir "$ts-wiki-link-changes.csv"
        $changes |
            Select-Object Page, Kind, SourceId, TargetId, OldUrl, NewUrl |
            Export-Csv -LiteralPath $changesLog -NoTypeInformation -Encoding UTF8
        Write-Host ("Wrote change log: {0}" -f $changesLog) -ForegroundColor Green
        if ($unresolved.Count -gt 0) {
            $unresolvedLog = Join-Path $LogDir "$ts-wiki-link-unresolved.csv"
            $unresolved |
                Select-Object Page, SourceId, Url |
                Export-Csv -LiteralPath $unresolvedLog -NoTypeInformation -Encoding UTF8
            Write-Host ("Wrote unresolved log: {0}" -f $unresolvedLog) -ForegroundColor Green
        }
    }

    # Emit change objects for scripted validation.
    $changes
}
finally {
    # Remove the worktree. In preview mode this discards the uncommitted edits;
    # any committed change lives on the branch in the bare mirror and is
    # unaffected by worktree removal.
    & git -C $CloneDir worktree remove --force $worktree 2>$null
}