Public/Add-AzLocalNoReadyClustersStepSummary.ps1

function Add-AzLocalNoReadyClustersStepSummary {
    <#
    .SYNOPSIS
        Renders the 'No Clusters Ready for Update' markdown section emitted by
        the third Step.6 job (no-clusters-ready / NoClustersReady stage).
    .DESCRIPTION
        v0.8.5 Step.6 thin-YAML helper. Replaces the ~25-line inline `run:`
        block in both Step.6 pipelines (GH job no-clusters-ready, ADO stage
        NoClustersReady).
 
        Behaviour:
          - When -UpdateRing is empty/whitespace (the schedule resolver found
            NO row matching this firing's date/time): renders an informational
            "No UpdateRing Scheduled for This Firing" section explaining this
            is an EXPECTED idle cron tick (not a misconfiguration), and emits a
            plain informational log line - NOT a warning - so the run does not
            surface a spurious warning annotation. (v0.8.74)
          - When -UpdateRing is non-empty and -TotalCount is 0: renders the
            "No clusters found with UpdateRing tag value 'X'" message.
          - When -UpdateRing is non-empty and -TotalCount > 0: renders the
            "Found N cluster(s) ... but none are ready" message + "Possible
            reasons" bullet list.
          - For the two non-empty-ring cases above, emits a Write-Warning
            (GitHub / Local) or task.logissue warning (Azure DevOps) with the
            ring name, matching the prior inline block byte-for-byte.
    .PARAMETER UpdateRing
        Resolved UpdateRing label (string; may be empty for schedule-no-row case).
    .PARAMETER TotalCount
        Total count of clusters discovered by the readiness query (string-
        or-int accepted). 0 = no clusters found at all.
    .PARAMETER UpToDateCount
        Count of discovered clusters that are already fully patched (the
        readiness gate's Up-to-Date bucket). Optional; pass -1 (the default)
        when the caller does not have the breakdown - the summary then falls
        back to the generic "possible reasons" list. (v0.8.74)
    .PARAMETER NotReadyCount
        Count of discovered clusters that are held back and need attention
        before they can update. Optional; pass -1 (the default) when the
        caller does not have the breakdown. (v0.8.74)
    .PARAMETER SummaryFileName
        Per-task markdown filename (ADO/Local). Default
        'azlocal-step6-no-ready-summary.md'.
    .PARAMETER PassThru
        Returns PSCustomObject with: SummaryPath.
    .NOTES
        Author : AzLocal.UpdateManagement
        Version : 0.8.5 (Step.6 thin-YAML port)
    #>

    [CmdletBinding()]
    [OutputType([void])]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [string]$UpdateRing,

        [Parameter(Mandatory = $false)]
        [object]$TotalCount = 0,

        [Parameter(Mandatory = $false)]
        [object]$UpToDateCount = -1,

        [Parameter(Mandatory = $false)]
        [object]$NotReadyCount = -1,

        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [string]$SummaryFileName = 'azlocal-step6-no-ready-summary.md',

        [switch]$PassThru
    )

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

    $pipelineHost = Get-AzLocalPipelineHost
    $headingLevel = if ($pipelineHost -eq 'AzureDevOps') { '#' } else { '##' }
    $totalInt     = [int]([string]$TotalCount)
    # Defensive parse: a count may arrive as an empty string when an upstream
    # pipeline variable / job output was not set (e.g. an unresolved ADO
    # $(macro)). Treat any non-integer value as -1 (unknown) so the summary
    # falls back to the generic message instead of throwing.
    $parseCount = {
        param($value)
        $parsed = 0
        if ([int]::TryParse([string]$value, [ref]$parsed)) { return $parsed }
        return -1
    }
    $upToDateInt  = & $parseCount $UpToDateCount
    $notReadyInt  = & $parseCount $NotReadyCount
    $haveBreakdown = ($upToDateInt -ge 0 -or $notReadyInt -ge 0)
    $ringIsEmpty  = [string]::IsNullOrWhiteSpace($UpdateRing)

    $checkChar = [string][char]0x2705                       # green check
    $warnChar  = [string][char]0x26A0 + [string][char]0xFE0F # warning sign

    $sb = New-Object System.Text.StringBuilder

    if ($ringIsEmpty) {
        # v0.8.74: the schedule resolver returned NO ring for this firing
        # (e.g. a cron tick on a day/cycleWeek with no schedule row). This is
        # an EXPECTED idle run, not a "no clusters found" failure - render an
        # informational section that says so rather than the misleading
        # "No clusters found with UpdateRing tag value ''" message.
        [void]$sb.AppendLine("$headingLevel No UpdateRing Scheduled for This Firing")
        [void]$sb.AppendLine()
        [void]$sb.AppendLine("This scheduled run did not match any row in the apply-updates schedule for the current date/time, so there is no UpdateRing to process. **This is expected** on cron firings that fall outside your configured maintenance windows - no action is required and no clusters were queried.")
        [void]$sb.AppendLine()
        [void]$sb.AppendLine('- Apply-updates was skipped cleanly (the readiness gate reported ready_count=0).')
        [void]$sb.AppendLine('- See the **Resolve UpdateRing from schedule** step log for the cycleWeek / dayOfWeek that was evaluated and why no schedule row matched.')
        [void]$sb.AppendLine('- To change which days trigger updates, edit your `apply-updates-schedule.yml`.')
    }
    else {
        [void]$sb.AppendLine("$headingLevel No Clusters Ready for Update")
        [void]$sb.AppendLine()
        [void]$sb.AppendLine("**Target UpdateRing:** $UpdateRing")
        [void]$sb.AppendLine()

        if ($totalInt -eq 0) {
            [void]$sb.AppendLine("No clusters found with UpdateRing tag value '$UpdateRing'")
        }
        elseif ($haveBreakdown) {
            # v0.8.74: clusters were found but none are ready to START a new
            # update. Break the outcome down so "no clusters ready" is not
            # alarming - clusters that are already fully patched are a HEALTHY
            # steady state, not a failure. Per-cluster "why" lives in the
            # Check Update Readiness table + readiness-report.csv (re-deriving
            # status from the CSV here is unsafe - imported booleans are
            # strings, e.g. [bool]'False' is $true).
            $udShown = if ($upToDateInt -ge 0) { $upToDateInt } else { 0 }
            $nrShown = if ($notReadyInt -ge 0) { $notReadyInt } else { $totalInt - $udShown }

            if ($nrShown -le 0 -and $udShown -gt 0) {
                [void]$sb.AppendLine("All $totalInt cluster(s) tagged UpdateRing='$UpdateRing' are already up to date - there is nothing to apply. This is a healthy steady state, not a failure.")
            }
            else {
                [void]$sb.AppendLine("Found $totalInt cluster(s) tagged UpdateRing='$UpdateRing'. None are ready to start a new update right now.")
            }
            [void]$sb.AppendLine()
            [void]$sb.AppendLine('| Outcome | Clusters |')
            [void]$sb.AppendLine('|---|---|')
            [void]$sb.AppendLine(("| {0} Up to Date (already fully patched - no action needed) | {1} |" -f $checkChar, $udShown))
            [void]$sb.AppendLine(("| {0} Not Ready (needs attention before updating) | {1} |" -f $warnChar, $nrShown))
            [void]$sb.AppendLine()
            if ($nrShown -gt 0) {
                [void]$sb.AppendLine("**Not Ready** clusters were held back for one or more of: an update already in progress, a pending SBE / prerequisite update, or a blocking health-check failure. See the per-cluster **Status** and **Blocking Reasons** columns in the **Check Update Readiness** summary (or the ``readiness-report.csv`` artifact) for the exact reason on each cluster.")
            }
            else {
                [void]$sb.AppendLine("See the **Check Update Readiness** summary table (or the ``readiness-report.csv`` artifact) for the per-cluster detail.")
            }
        }
        else {
            # Fallback when the caller did not supply the Up-to-Date / Not-Ready
            # breakdown (older callers / -UpToDateCount and -NotReadyCount left
            # at the -1 default).
            [void]$sb.AppendLine("Found $totalInt cluster(s) with UpdateRing='$UpdateRing', but none are ready for updates.")
            [void]$sb.AppendLine()
            [void]$sb.AppendLine('Possible reasons:')
            [void]$sb.AppendLine('- Clusters may already be up to date')
            [void]$sb.AppendLine('- Updates may be in progress')
            [void]$sb.AppendLine('- Clusters may have health check failures')
            [void]$sb.AppendLine()
            [void]$sb.AppendLine('Download the readiness report artifact for details.')
        }
    }

    $summaryPath = Add-AzLocalPipelineStepSummary -Markdown $sb.ToString() -SummaryFileName $SummaryFileName

    # Per-host log surfacing. For a non-empty ring with no ready clusters this
    # is a genuine warning. For the empty-ring (no schedule row matched) case
    # it is an EXPECTED idle firing, so emit a plain informational line - NOT a
    # warning - to avoid a spurious warning annotation on the run. (v0.8.74)
    # Likewise, when EVERY discovered cluster is already up to date (the
    # breakdown shows zero Not-Ready) that is a healthy steady state, so emit a
    # notice rather than a warning. (v0.8.74)
    $allUpToDate = ($haveBreakdown -and ($notReadyInt -le 0) -and ($upToDateInt -gt 0))
    if ($ringIsEmpty) {
        switch ($pipelineHost) {
            'GitHub'      { Write-Host "::notice title=No UpdateRing scheduled for this firing::No schedule row matched the current date/time - apply-updates skipped cleanly (expected)." }
            'AzureDevOps' { Write-Host "No UpdateRing scheduled for this firing - apply-updates skipped cleanly (expected). No schedule row matched the current date/time." }
            default       { Write-Host "[notice] No UpdateRing scheduled for this firing - apply-updates skipped cleanly (expected)." }
        }
    }
    elseif ($allUpToDate) {
        switch ($pipelineHost) {
            'GitHub'      { Write-Host "::notice title=All clusters up to date::All clusters in ring '$UpdateRing' are already up to date - nothing to apply (expected)." }
            'AzureDevOps' { Write-Host "All clusters in ring '$UpdateRing' are already up to date - nothing to apply (expected)." }
            default       { Write-Host "[notice] All clusters in ring '$UpdateRing' are already up to date - nothing to apply (expected)." }
        }
    }
    else {
        switch ($pipelineHost) {
            'GitHub'      { Write-Warning "No clusters ready for update in ring '$UpdateRing'" }
            'AzureDevOps' { Write-Host "##vso[task.logissue type=warning]No clusters are ready for updates in ring '$UpdateRing'" }
            default       { Write-Warning "No clusters ready for update in ring '$UpdateRing'" }
        }
    }

    if ($PassThru) {
        return [pscustomobject]@{
            SummaryPath = $summaryPath
        }
    }
}