Engines/Set-WorkItemStartId.ps1

<#
.SYNOPSIS
    Advances an Azure DevOps organisation's work item ID counter so that the
    next work item created will have an ID of at least -MinId.
 
.DESCRIPTION
    Azure DevOps assigns work item IDs from a single sequence that is shared by
    every project in the organisation, and the sequence can only ever move
    forward. When migrating work items into a fresh organisation it is often
    desirable for the new IDs to line up with (or sit above) the IDs used in the
    source organisation. There is no API to set the counter directly, so the
    only way to advance it is to consume IDs by creating work items.
 
    This script repeatedly creates throwaway work items and immediately destroys
    them (permanently, so they do not linger in the recycle bin) until a created
    work item reaches ID (MinId - 1). At that point the counter is positioned so
    the next *real* work item created anywhere in the organisation will have an
    ID of at least -MinId.
 
    Because each work item creation only advances the counter by one, closing a
    large gap can take many requests. Creations are issued in batches of
    -BatchSize to reduce overhead, and the batch is trimmed automatically as the
    target is approached so the counter is not overshot by more than one.
 
    Authentication is ambient-identity first, stored token as the fallback:
    Entra by default. When the automation module is loaded the script acquires
    an Entra access token via Get-AzureDevOpsAccessToken, re-resolved before
    every batch so the module's cache renews it near expiry across a long run.
    The fallbacks are -Pat, then the AZDO_PAT_<ORG> environment variable
    populated by Set-AutomationSecrets. Whichever identity is used needs Work
    Items (Read, Write & Manage) so it can both create and permanently destroy
    work items.
 
.PARAMETER OrgUrl
    Organisation URL, e.g. https://dev.azure.com/contoso.
 
.PARAMETER Project
    Project in which the throwaway work items are created. Any project works;
    the ID sequence is organisation-wide.
 
.PARAMETER MinId
    The minimum ID the next created work item should have. The script advances
    the counter until a throwaway work item reaches (MinId - 1).
 
.PARAMETER Pat
    Fallback Personal Access Token with Work Items (Read, Write & Manage), used
    when Entra sign-in is unavailable or fails. Defaults to the AZDO_PAT_<ORG>
    environment variable derived from the organisation name.
 
.PARAMETER TenantId
    Deprecated and ignored: the module's Get-AzureDevOpsAccessToken discovers
    the organisation's tenant automatically and pins the sign-in to it. Kept so
    existing runbook lines that pass it keep working.
 
.PARAMETER WorkItemType
    Work item type to create for the throwaway items. Default: Task.
 
.PARAMETER Title
    Title used for the throwaway work items. Default marks them as temporary.
 
.PARAMETER BatchSize
    Number of work items to create before re-evaluating progress. The batch is
    automatically trimmed near the target. Default: 50.
 
.PARAMETER MaxRetries
    Maximum number of times a single throttled request (HTTP 429/5xx or the
    Azure DevOps CircuitBreakerExceededConcurrency exception) is retried before
    the run aborts. Default: 8.
 
.PARAMETER MaxBackoffSeconds
    Upper bound, in seconds, for the exponential backoff delay between retries.
    A server-provided Retry-After header is honoured when present but still
    capped by this value. Default: 60.
 
.EXAMPLE
    # Ambient identity: Entra, no PAT.
    .\Set-WorkItemStartId.ps1 -OrgUrl https://dev.azure.com/machining -Project Milling -MinId 100000
 
.EXAMPLE
    # Fallback PAT from the workspace secrets (Set-AutomationSecrets exports
    # AZDO_PAT_<ORG>), previewed first.
    Set-AutomationSecrets
    .\Set-WorkItemStartId.ps1 -OrgUrl https://dev.azure.com/machining -Project Milling -MinId 100000 -WhatIf
 
.OUTPUTS
    The highest work item ID that was consumed. The next work item created will
    have an ID greater than this value (i.e. >= MinId).
#>

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

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

    [Parameter(Mandatory = $true)]
    [ValidateRange(1, [int]::MaxValue)]
    [int]$MinId,

    [string]$Pat,

    [string]$TenantId,

    [string]$WorkItemType = 'Task',

    [string]$Title = 'TEMP - ID reservation (safe to delete)',

    [ValidateRange(1, 200)]
    [int]$BatchSize = 50,

    [ValidateRange(0, 20)]
    [int]$MaxRetries = 8,

    [ValidateRange(1, 600)]
    [double]$MaxBackoffSeconds = 60
)

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

#region Helpers ---------------------------------------------------------------

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

function Get-DerivedEnvVarName {
    param([string]$Org)
    'AZDO_PAT_' + ($Org.ToUpperInvariant() -replace '[^A-Z0-9]', '_')
}

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

function Initialize-Auth {
    # Ambient identity first: an Entra access token works anywhere a PAT does, so
    # Entra is the default and -Pat (then the AZDO_PAT_<ORG> variable that
    # Set-AutomationSecrets exports) only the fallback. Called before every
    # batch, not just once: the module caches the token and renews it shortly
    # before expiry, so re-resolving keeps a long ID-consuming 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 $OrgUrl }
        catch { $entraError = $_.Exception.Message }
    }
    else {
        $entraError = 'the NKDAgility.AzureDevOps.AutomationTools module is not loaded'
    }

    if ($token) {
        $script:Headers = @{ Authorization = 'Bearer ' + $token }
        if ($script:AuthMode -ne 'Entra') {
            Write-Host '==> Auth: Entra.' -ForegroundColor DarkGray
            $script:AuthMode = 'Entra'
        }
        return
    }

    $envName = Get-DerivedEnvVarName -Org $org
    $pat = $Pat
    if ([string]::IsNullOrWhiteSpace($pat)) {
        $pat = [System.Environment]::GetEnvironmentVariable($envName)
    }
    if (-not [string]::IsNullOrWhiteSpace($pat)) {
        $script:Headers = Get-BasicAuthHeader -Token $pat
        if ($script:AuthMode -ne 'PAT') {
            Write-Warning ("Entra sign-in unavailable ({0}); falling back to the PAT." -f $entraError)
            $script:AuthMode = 'PAT'
        }
        return
    }

    throw ("No credential available: Entra sign-in failed ({0}), no -Pat was supplied and `$ENV:{1} is not set. Sign in to Entra, or run Set-AutomationSecrets." -f $entraError, $envName)
}

function Write-Step {
    param([string]$Message)
    Write-Host "==> $Message" -ForegroundColor Cyan
}

$org = Get-OrgName -Url $OrgUrl

if ($TenantId) {
    Write-Warning '-TenantId is deprecated and ignored: the tenant is discovered automatically by Get-AzureDevOpsAccessToken.'
}

# Ambient-first credential resolution: Entra then the -Pat / AZDO_PAT_<ORG>
# fallbacks, renewed per batch (see Initialize-Auth).
$script:AuthMode = $null
Initialize-Auth

$apiVersion = '7.1'
$createUri = "https://dev.azure.com/$org/$Project/_apis/wit/workitems/`$$WorkItemType`?api-version=$apiVersion"

function Invoke-AdoRestWithRetry {
    # Wraps Invoke-RestMethod with exponential backoff so transient Azure DevOps
    # throttling (HTTP 429, HTTP 503, or the CircuitBreakerExceededConcurrency
    # exception) is retried instead of aborting the run. Honours a Retry-After
    # header when present, otherwise backs off exponentially with jitter up to
    # -MaxBackoffSeconds. Non-transient errors are rethrown immediately.
    param([hashtable]$RequestArgs)

    $attempt = 0
    while ($true) {
        try {
            return Invoke-RestMethod @RequestArgs
        }
        catch {
            $attempt++
            $resp = $_.Exception.Response
            $status = $null
            if ($resp -and ($resp.PSObject.Properties.Name -contains 'StatusCode')) {
                $status = [int]$resp.StatusCode
            }
            $isThrottle = ($status -in 429, 500, 502, 503, 504) -or
                ($_.Exception.Message -match 'CircuitBreaker|currently unavailable|TF10216|throttl')

            if (-not $isThrottle -or $attempt -gt $MaxRetries) { throw }

            # Prefer the server-provided Retry-After when available.
            $retryAfter = $null
            if ($resp -and ($resp.PSObject.Properties.Name -contains 'Headers')) {
                try { $retryAfter = [double]$resp.Headers['Retry-After'] } catch { }
            }
            if ($retryAfter -and $retryAfter -gt 0) {
                $delay = [Math]::Min($retryAfter, $MaxBackoffSeconds)
            }
            else {
                # Exponential backoff (2^attempt) with random jitter, capped.
                $base = [Math]::Min([Math]::Pow(2, $attempt), $MaxBackoffSeconds)
                $delay = [Math]::Min($base + (Get-Random -Minimum 0.0 -Maximum 1.0), $MaxBackoffSeconds)
            }

            Write-Warning ("Azure DevOps throttled the request (attempt {0}/{1}). Backing off {2:n1}s..." -f `
                    $attempt, $MaxRetries, $delay)
            Start-Sleep -Seconds $delay
        }
    }
}

function New-ThrowawayWorkItem {
    # Creates a single work item and returns its assigned ID.
    $body = @(
        @{ op = 'add'; path = '/fields/System.Title'; value = $Title }
    ) | ConvertTo-Json -Depth 5 -AsArray
    $wi = Invoke-AdoRestWithRetry -RequestArgs @{
        Uri         = $createUri
        Headers     = $script:Headers
        Method      = 'Post'
        Body        = $body
        ContentType = 'application/json-patch+json'
    }
    [int]$wi.id
}

function Remove-WorkItem {
    # Permanently destroys a work item so it does not sit in the recycle bin.
    param([int]$Id)
    $uri = "https://dev.azure.com/$org/$Project/_apis/wit/workitems/$Id`?destroy=true&api-version=$apiVersion"
    Invoke-AdoRestWithRetry -RequestArgs @{
        Uri     = $uri
        Headers = $script:Headers
        Method  = 'Delete'
    } | Out-Null
}

#endregion Helpers ------------------------------------------------------------

# We advance the counter until a created item reaches (MinId - 1); the next real
# work item then gets an ID >= MinId.
$target = $MinId - 1

if (-not $PSCmdlet.ShouldProcess(
        "org '$org' / project '$Project'",
        "Create and permanently destroy work items until an ID of $target is reached (next work item >= $MinId)")) {
    return
}

Write-Step "Advancing work item counter in '$org' so the next ID is >= $MinId."

$lastId = 0
$consumed = 0
$startTime = Get-Date
$startId = 0

while ($lastId -lt $target) {
    # Renew a near-expiry Entra token before each batch (cache hit otherwise).
    Initialize-Auth

    # First iteration probes the current position with a single item; afterwards
    # create as many as needed, capped by BatchSize and trimmed near the target.
    if ($lastId -eq 0) {
        $count = 1
    }
    else {
        $remaining = $target - $lastId
        $count = [Math]::Min($BatchSize, $remaining)
    }

    for ($i = 0; $i -lt $count; $i++) {
        $id = New-ThrowawayWorkItem
        $consumed++
        try {
            Remove-WorkItem -Id $id
        }
        catch {
            Write-Warning "Failed to destroy work item $id ($($_.Exception.Message)). It may need to be removed manually from the recycle bin."
        }
        if ($id -gt $lastId) { $lastId = $id }
        # Anchor the ID-gap measurement to the first observed ID so throughput
        # reflects actual counter movement, not the initial jump to $startId.
        if ($startId -eq 0) {
            $startId = $lastId
            # The very first created item reveals where the counter currently
            # sits: the next real work item would have had ID ($lastId + 1).
            Write-Step "Current work item counter is at $lastId (next new ID would be $($lastId + 1))."
            if ($lastId -ge $target) {
                Write-Step "Counter is already >= $MinId; nothing to advance."
            }
            else {
                Write-Step "Need to consume $($target - $lastId) more ID(s) to reach $MinId."
            }
        }

        $elapsed = (Get-Date) - $startTime
        $rate = if ($elapsed.TotalMinutes -gt 0) { $consumed / $elapsed.TotalMinutes } else { 0 }
        $idsToGo = [Math]::Max(0, $target - $lastId)
        $spanTotal = [Math]::Max(1, $target - $startId)
        $spanDone = [Math]::Max(0, $lastId - $startId)
        $percent = [Math]::Min(100, [int](($spanDone / $spanTotal) * 100))

        if ($rate -gt 0 -and $idsToGo -gt 0) {
            $etaMin = $idsToGo / $rate
            $eta = [TimeSpan]::FromMinutes($etaMin)
            $etaText = '{0:hh\:mm\:ss}' -f $eta
            $finishText = (Get-Date).Add($eta).ToString('HH:mm:ss')
        }
        else {
            $etaText = '--:--:--'
            $finishText = '--:--:--'
        }

        Write-Progress -Activity "Advancing work item ID counter in '$org'" `
            -Status ("ID {0} / {1} | {2:n0}/min | {3} consumed | ETA {4} (~{5})" -f `
                $lastId, $target, $rate, $consumed, $etaText, $finishText) `
            -PercentComplete $percent

        if ($lastId -ge $target) { break }
    }
}

Write-Progress -Activity "Advancing work item ID counter in '$org'" -Completed

$totalElapsed = (Get-Date) - $startTime
Write-Step ("Done in {0:hh\:mm\:ss}. Highest ID consumed: {1} ({2} created). The next work item will have ID >= {3}." -f `
        $totalElapsed, $lastId, $consumed, $MinId)
$lastId