scripts/Update-BoardItemDetail.ps1

#Requires -Version 7.0
<#
.SYNOPSIS
    Rewrites work-item descriptions and adds Predecessor/Successor links on the Azure Scout ADO board.

.DESCRIPTION
    scripts/New-BoardTree.ps1 creates a conformant tree, but it only ever writes an item once. The
    two things it cannot do are the two things the HCS work-items standard asks for after creation:

      1. Bring an existing description into the standard's template -- problem, "Why now:", an
         "In scope:" list and an "Out of scope:" list, both as real <ul><li> lists rather than
         prose. A description written as a paragraph renders as a paragraph forever; nothing on
         the board rewrites it.
      2. LINK dependencies rather than describe them. "X has to land before Y" written in prose is
         invisible to a query, to the Dependency Tracker, and to whoever picks the work up cold.

    This script does both from a declarative data file, for the same reason New-BoardTree.ps1 takes
    one: board writes made from throwaway scratch scripts are not reviewable, not re-runnable and
    not subject to the repo's standards. Run scripts/Test-BoardConformance.ps1 afterwards -- the
    one time that was skipped it left 15 violations on the board.

    Both operations are idempotent. A description is skipped when the board already holds exactly
    the string in the data file, and a link is skipped when a relation of the same type already
    points at the same target. Re-running after a partial failure is therefore safe.

    Nothing is written until every item in the file has been fetched and checked, because a
    half-applied set of dependency links is a worse artefact than none: it reads as a complete
    ordering while omitting edges.

    Traps this script encodes so callers do not rediscover them:

    - The dependency link direction is easy to invert and silently wrong when you do.
      System.LinkTypes.Dependency-FORWARD names the SUCCESSOR: added to A with a url pointing at
      B it means "B comes after A", i.e. A must finish first. -Reverse names the PREDECESSOR. This
      script therefore takes 'Precedes' and emits Forward, and -Verify reads the mirror back off
      the target to prove the direction rather than assuming it.
    - The server creates the mirror relation itself. Writing both ends is a 400 (the second write
      is a duplicate link), so only one end is ever sent.
    - The relation URL segment must be camel-cased 'workItems'. A lower-case 'workitems' is
      accepted on write but scripts/Test-BoardConformance.ps1's regex is case-sensitive and will
      not see it.
    - ConvertTo-Json defaults to -Depth 2, which flattens a relation's nested attributes into the
      string "System.Collections.Hashtable". Every serialisation here passes -Depth 6.
    - ConvertTo-Json on a single-element array emits an object; the patch endpoint requires an
      array. Every body is wrapped with @( ).
    - System.Description with op=add APPENDS nothing but is semantically a create; use op=replace
      so an existing description is actually overwritten.
    - $pid is a read-only automatic variable (the process id), and PowerShell names are
      case-insensitive, so $pId IS $PID. Never name a work-item id that.
    - Run this as a FILE, never as an inline `pwsh -Command`. A backtick in an ADO URL breaks bash
      quoting and the request silently goes somewhere else.

.PARAMETER Path
    Path to a PowerShell data file (.psd1) holding a single hashtable with an Items key. Each entry
    supports:

      Id (required) the work-item id to update
      Description HTML replacing System.Description. Omit to leave the description alone
      Precedes ids of work items that must not start until this one completes. Emitted as
                   System.LinkTypes.Dependency-Forward (Successor) relations on THIS item

    A data file is used rather than a script so loading the detail cannot execute code.

.PARAMETER Organization
    Azure DevOps organization URL.

.PARAMETER ProjectId
    Azure DevOps project GUID. The GUID is used rather than the name because the project name
    contains an em-dash that Windows console encoding mangles, which silently writes to the wrong
    project.

.PARAMETER Verify
    After writing, re-read every touched item and print its rendered description and every
    dependency relation on it, resolving each relation to the human link name ("Successor" /
    "Predecessor") from the link-types API. Use this to confirm the result rather than assume it.

.EXAMPLE
    ./scripts/Update-BoardItemDetail.ps1 -Path ./scripts/trees/v3-engine-completion-detail.psd1 -WhatIf

    Fetches every item, reports which descriptions and links would change, and writes nothing.

.EXAMPLE
    ./scripts/Update-BoardItemDetail.ps1 -Path ./scripts/trees/v3-engine-completion-detail.psd1 -Verify

    Applies the file, then reads every touched item back and prints what the board now holds.

.NOTES
    Requires an authenticated Azure CLI session (az login). Reads no secrets from the repo and
    writes none to it. Run scripts/Test-BoardConformance.ps1 immediately afterwards.
#>

[CmdletBinding(SupportsShouldProcess)]
param(
    [Parameter(Mandatory)]
    [string] $Path,

    [string] $Organization = 'https://dev.azure.com/hybridcloudsolutions',

    # Scout project GUID. Using the GUID avoids the U+2014 em-dash in the project name, which
    # console encoding mangles into a write against the wrong project.
    [string] $ProjectId = '85b6e47e-a666-4a38-8c43-de87dd21aa56',

    [switch] $Verify
)

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

# The dependency pair. Forward is the SUCCESSOR end: on item A pointing at B it asserts B follows
# A. 'Precedes' in the data file therefore maps to Forward, and the mirror the server writes on B
# is the Reverse (Predecessor) end.
$DependencyForward = 'System.LinkTypes.Dependency-Forward'
$DependencyReverse = 'System.LinkTypes.Dependency-Reverse'

function Get-EntryValue {
    # Hashtable member access under StrictMode is inconsistent enough to be worth avoiding.
    param($Entry, [string] $Name)
    if ($Entry.Contains($Name)) { return $Entry[$Name] }
    return $null
}

function Get-PrecedesId {
    # NOT `@(Get-EntryValue $Entry 'Precedes')`. On a missing key that yields @($null) -- a
    # ONE-element array holding $null -- and [int]$null is 0, which reaches the batch endpoint as
    # work item 0 and fails the whole run with "The value 0 is outside of the allowed range".
    param($Entry)
    return @(Get-EntryValue $Entry 'Precedes' | Where-Object { $null -ne $_ } | ForEach-Object { [int]$_ })
}

# --------------------------------------------------------------------------------------------
# Load and pre-flight the data file. Nothing is fetched until the file itself is coherent.
# --------------------------------------------------------------------------------------------
if (-not (Test-Path -LiteralPath $Path)) { throw "Detail file not found: $Path" }
$data = Import-PowerShellDataFile -LiteralPath $Path
if (-not $data.Contains('Items')) { throw "$Path must contain a hashtable with an Items key." }
$entries = @($data['Items'])
if (-not $entries) { throw 'The Items collection is empty.' }

$errs  = [System.Collections.Generic.List[string]]::new()
$seen  = @{}
foreach ($e in $entries) {
    if ($e -isnot [System.Collections.IDictionary]) { $errs.Add('Every entry must be a hashtable.'); continue }
    $id = Get-EntryValue $e 'Id'
    if (-not $id)               { $errs.Add('An entry has no Id.'); continue }
    if ($seen.ContainsKey($id)) { $errs.Add("Duplicate Id $id in the data file."); continue }
    $seen[[int]$id] = $true

    $desc = Get-EntryValue $e 'Description'
    $prec = Get-PrecedesId -Entry $e
    if (-not $desc -and -not $prec) { $errs.Add("AB#$id : neither Description nor Precedes is set; the entry does nothing.") }

    foreach ($p in $prec) {
        if ([int]$p -eq [int]$id) { $errs.Add("AB#$id : cannot precede itself.") }
    }
}
if ($errs.Count -gt 0) {
    $errs | ForEach-Object { Write-Error $_ -ErrorAction Continue }
    throw "$($errs.Count) pre-flight failure(s) in the data file. Nothing was fetched or written."
}

# --------------------------------------------------------------------------------------------
# Token and headers
# --------------------------------------------------------------------------------------------
# 499b84ac-1321-427f-aa17-267ca6975798 is the well-known Azure DevOps resource id.
$token = az account get-access-token --resource 499b84ac-1321-427f-aa17-267ca6975798 --query accessToken -o tsv
if (-not $token) { throw 'Could not get an Azure DevOps access token. Run: az login' }

$patchHeaders = @{ Authorization = "Bearer $token"; 'Content-Type' = 'application/json-patch+json' }
$jsonHeaders  = @{ Authorization = "Bearer $token"; 'Content-Type' = 'application/json' }

$org  = $Organization
$proj = $ProjectId

# --------------------------------------------------------------------------------------------
# Fetch current state for every item mentioned, as an id OR as a link target. A Precedes target
# that is not on the board would create a dangling relation, so it is checked before any write.
# --------------------------------------------------------------------------------------------
function Get-ItemState {
    param([int[]] $Ids)
    $result = @{}
    $unique = @($Ids | Sort-Object -Unique)
    for ($i = 0; $i -lt $unique.Count; $i += 100) {
        $chunk = $unique[$i..([Math]::Min($i + 99, $unique.Count - 1))]
        $body  = @{ ids = $chunk; '$expand' = 'Relations' } | ConvertTo-Json -Depth 6
        $batch = (Invoke-RestMethod -Uri "$org/$proj/_apis/wit/workitemsbatch?api-version=7.0" -Method POST -Headers $jsonHeaders -Body $body).value
        foreach ($w in $batch) { $result[[int]$w.id] = $w }
    }
    return $result
}

$allIds = [System.Collections.Generic.List[int]]::new()
foreach ($e in $entries) {
    $allIds.Add([int](Get-EntryValue $e 'Id'))
    foreach ($p in (Get-PrecedesId -Entry $e)) { $allIds.Add($p) }
}
$state = Get-ItemState -Ids $allIds.ToArray()

foreach ($wanted in @($allIds | Sort-Object -Unique)) {
    if (-not $state.ContainsKey($wanted)) { $errs.Add("AB#$wanted is referenced by the data file but is not on this board.") }
}
if ($errs.Count -gt 0) {
    $errs | ForEach-Object { Write-Error $_ -ErrorAction Continue }
    throw "$($errs.Count) pre-flight failure(s) against the live board. Nothing was written."
}

function Get-Relation {
    # $expand=Relations omits the property entirely on an item with no relations, which under
    # StrictMode is a terminating error rather than $null.
    param($Item)
    if ($Item.PSObject.Properties.Name -contains 'relations' -and $Item.relations) { return @($Item.relations) }
    return @()
}

function Get-RelationTargetId {
    param($Relation)
    if ($Relation.url -match '/workItems/(\d+)$') { return [int]$Matches[1] }
    return $null
}

Write-Host ("Loaded : {0} entr(ies) from {1}" -f $entries.Count, (Split-Path -Leaf $Path))
Write-Host ("Board : {0} item(s) fetched with relations" -f $state.Count)
Write-Host ''

# --------------------------------------------------------------------------------------------
# Apply
# --------------------------------------------------------------------------------------------
$descChanged = 0
$descSame    = 0
$linkAdded   = 0
$linkSame    = 0
$touched     = [System.Collections.Generic.List[int]]::new()

foreach ($e in $entries) {
    $id   = [int](Get-EntryValue $e 'Id')
    $item = $state[$id]
    $ops  = [System.Collections.Generic.List[object]]::new()
    $what = [System.Collections.Generic.List[string]]::new()

    $desc = Get-EntryValue $e 'Description'
    if ($desc) {
        $current = $null
        if ($item.fields.PSObject.Properties.Name -contains 'System.Description') { $current = $item.fields.'System.Description' }
        if ($current -ceq $desc) {
            $descSame++
        }
        else {
            # op=replace, not add: op=add on a field that already has a value is accepted but is
            # semantically a create, and the intent here is explicitly to overwrite.
            $ops.Add(@{ op = 'replace'; path = '/fields/System.Description'; value = $desc })
            $what.Add('description')
        }
    }

    $existing = @()
    foreach ($r in (Get-Relation -Item $item)) {
        if ($r.rel -ne $DependencyForward -and $r.rel -ne $DependencyReverse) { continue }
        $target = Get-RelationTargetId -Relation $r
        if ($null -ne $target) { $existing += ('{0}:{1}' -f $r.rel, $target) }
    }

    foreach ($p in (Get-PrecedesId -Entry $e)) {
        $target = [int]$p
        if ($existing -contains ('{0}:{1}' -f $DependencyForward, $target)) { $linkSame++; continue }
        # Only ONE end is ever written. The server creates the mirror Dependency-Reverse relation
        # on the target itself; sending both ends is a 400 on the second write.
        # The -f argument list is parenthesised: inside a hashtable literal its commas would
        # otherwise be read as entry separators and the script would not parse.
        $ops.Add(@{ op = 'add'; path = '/relations/-'; value = @{
            rel        = $DependencyForward
            url        = ('{0}/{1}/_apis/wit/workItems/{2}' -f $org, $proj, $target)
            attributes = @{ comment = 'Predecessor of this item; it must complete first.' }
        } })
        $what.Add("precedes AB#$target")
        $linkAdded++
    }

    if ($ops.Count -eq 0) {
        '{0,-8} SKIP nothing to change' -f "AB#$id"
        continue
    }

    $target = "AB#$id ($($what -join ', '))"
    if (-not $PSCmdlet.ShouldProcess($target, 'patch work item')) {
        '{0,-8} WHATIF {1}' -f "AB#$id", ($what -join ', ')
        continue
    }

    $uri  = '{0}/{1}/_apis/wit/workitems/{2}?api-version=7.0' -f $org, $proj, $id
    $body = ConvertTo-Json @($ops.ToArray()) -Depth 6   # -Depth 6 or the relation attributes flatten
    $null = Invoke-RestMethod -Uri $uri -Method PATCH -Headers $patchHeaders -Body $body

    if ($what -contains 'description') { $descChanged++ }
    $touched.Add($id)
    '{0,-8} OK {1}' -f "AB#$id", ($what -join ', ')
}

Write-Host ''
Write-Host ("Descriptions : {0} rewritten, {1} already identical" -f $descChanged, $descSame)
Write-Host ("Dependencies : {0} added, {1} already present" -f $linkAdded, $linkSame)

# --------------------------------------------------------------------------------------------
# Verify. Reads the board back rather than trusting the write, and resolves each relation to its
# human link name so an inverted dependency is visible instead of inferred.
# --------------------------------------------------------------------------------------------
if ($Verify -and $touched.Count -gt 0) {
    Write-Host ''
    Write-Host '--- VERIFY (re-read from the API) ---'

    $linkNames = @{}
    foreach ($t in (Invoke-RestMethod -Uri "$org/_apis/wit/workitemrelationtypes?api-version=7.0" -Method Get -Headers $jsonHeaders).value) {
        $linkNames[$t.referenceName] = $t.name
    }

    $after = Get-ItemState -Ids $touched.ToArray()
    foreach ($id in $touched) {
        $w = $after[$id]
        Write-Host ''
        Write-Host ("===== AB#{0} [{1}] {2}" -f $id, $w.fields.'System.WorkItemType', $w.fields.'System.Title')
        if ($w.fields.PSObject.Properties.Name -contains 'System.Description') { $w.fields.'System.Description' }
        foreach ($r in (Get-Relation -Item $w)) {
            if ($r.rel -ne $DependencyForward -and $r.rel -ne $DependencyReverse) { continue }
            $name = if ($linkNames.ContainsKey($r.rel)) { $linkNames[$r.rel] } else { $r.rel }
            Write-Host (" LINK {0,-12} -> AB#{1}" -f $name, (Get-RelationTargetId -Relation $r))
        }
    }
}

Write-Host ''
Write-Host 'Now run: ./scripts/Test-BoardConformance.ps1' -ForegroundColor Yellow