scripts/Set-BoardState.ps1

#Requires -Version 7.0
<#
.SYNOPSIS
    Sets Azure DevOps work-item states (and optionally adds a comment) for the Scout board.

.DESCRIPTION
    Board state changes used to be made from throwaway scripts in a scratch directory, which
    meant the writes actually applied to the board were not reviewable, not re-runnable, and not
    subject to the repo's standards. This script exists so every board write is committed code,
    alongside scripts/Test-BoardConformance.ps1.

    ALWAYS run scripts/Test-BoardConformance.ps1 afterwards. Skipping it once left 15 violations
    on the board.

    Traps this script encodes so callers do not rediscover them:

    - A work item cannot be CREATED in Resolved or Closed, and some types refuse a direct
      New -> Closed jump. The state walk (Active -> Resolved -> Closed) handles that.
    - PATCH needs Content-Type application/json-patch+json; comments need application/json and
      the 7.0-preview.3 API version.
    - System.Tags with op=add APPENDS rather than replaces. This script does not write tags; if
      you add that, use op=replace and send the complete semicolon-joined string.
    - An ordered dictionary with integer keys indexes by POSITION, not key -- iterate with
      .GetEnumerator(), never $plan[$id].

.PARAMETER State
    Hashtable mapping work-item id to target state, e.g. @{ 5648 = 'Active'; 5642 = 'Closed' }.

.PARAMETER Comment
    Optional hashtable mapping work-item id to a comment string to post after the state change.

.PARAMETER WhatIf
    Show what would be written without calling the API.

.EXAMPLE
    ./scripts/Set-BoardState.ps1 -State @{ 5648 = 'Active'; 5642 = 'Closed' }

.EXAMPLE
    ./scripts/Set-BoardState.ps1 -State @{ 5659 = 'Active' } -Comment @{ 5659 = 'Released in v2.9.0; 124 of 176 converted.' }

.NOTES
    Requires an authenticated Azure CLI session (az login). Reads no secrets from the repo.
#>

[CmdletBinding(SupportsShouldProcess)]
param(
    [Parameter(Mandatory)]
    [hashtable] $State,

    [hashtable] $Comment = @{},

    [string] $Organization = 'hybridcloudsolutions',

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

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

# 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' }
$commentHeaders = @{ Authorization = "Bearer $token"; 'Content-Type' = 'application/json' }

function Set-WorkItemState {
    param([int] $Id, [string] $TargetState)

    $uri  = 'https://dev.azure.com/{0}/{1}/_apis/wit/workitems/{2}?api-version=7.0' -f $Organization, $ProjectId, $Id
    $body = ConvertTo-Json @(@{ op = 'add'; path = '/fields/System.State'; value = $TargetState }) -Depth 5
    try {
        Invoke-RestMethod -Uri $uri -Method PATCH -Headers $patchHeaders -Body $body -ErrorAction Stop | Out-Null
        return $true
    }
    catch {
        Write-Verbose "PATCH $Id -> $TargetState failed: $($_.Exception.Message)"
        return $false
    }
}

function Add-WorkItemComment {
    param([int] $Id, [string] $Text)

    $uri  = 'https://dev.azure.com/{0}/{1}/_apis/wit/workItems/{2}/comments?api-version=7.0-preview.3' -f $Organization, $ProjectId, $Id
    $body = @{ text = $Text } | ConvertTo-Json
    try {
        Invoke-RestMethod -Uri $uri -Method POST -Headers $commentHeaders -Body $body -ErrorAction Stop | Out-Null
        return $true
    }
    catch {
        Write-Verbose "Comment on $Id failed: $($_.Exception.Message)"
        return $false
    }
}

$failed = 0

foreach ($entry in $State.GetEnumerator()) {
    $id     = [int] $entry.Key
    $target = [string] $entry.Value

    if (-not $PSCmdlet.ShouldProcess("work item $id", "set state to $target")) { continue }

    $ok = Set-WorkItemState -Id $id -TargetState $target

    if (-not $ok -and $target -eq 'Closed') {
        # Some work-item types refuse a direct New -> Closed transition.
        Set-WorkItemState -Id $id -TargetState 'Active'   | Out-Null
        Set-WorkItemState -Id $id -TargetState 'Resolved' | Out-Null
        $ok = Set-WorkItemState -Id $id -TargetState 'Closed'
    }

    if (-not $ok) { $failed++ }
    '{0} -> {1} : {2}' -f $id, $target, $(if ($ok) { 'OK' } else { 'FAILED' })
}

foreach ($entry in $Comment.GetEnumerator()) {
    $id   = [int] $entry.Key
    $text = [string] $entry.Value

    if (-not $PSCmdlet.ShouldProcess("work item $id", 'add comment')) { continue }

    $ok = Add-WorkItemComment -Id $id -Text $text
    if (-not $ok) { $failed++ }
    'comment {0} : {1}' -f $id, $(if ($ok) { 'OK' } else { 'FAILED' })
}

if ($failed -gt 0) {
    Write-Warning "$failed board write(s) failed."
}

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

exit ([int]($failed -gt 0))