modules/shared/Build-CopilotTriagePlan.ps1

#Requires -Version 7.4
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

function Get-PlanHash {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [object[]] $Findings = @()
    )

    $parts = foreach ($f in @($Findings)) {
        $id = [string]$f.Id
        $body = [string]$f.Body
        "$id`n$body"
    }
    $normalized = ($parts | Sort-Object) -join "`n---`n"
    $bytes = [System.Text.Encoding]::UTF8.GetBytes($normalized)
    $sha = [System.Security.Cryptography.SHA256]::Create()
    try {
        $hashBytes = $sha.ComputeHash($bytes)
        -join ($hashBytes | ForEach-Object { $_.ToString('x2') })
    } finally {
        $sha.Dispose()
    }
}

function Normalize-CopilotCategory {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [AllowEmptyString()]
        [string] $Category = ''
    )

    $c = $Category.Trim().ToLowerInvariant()
    if ($c -in @('blocker', 'correctness', 'security', 'style', 'nit')) {
        return $c
    }
    return 'correctness'
}

function Build-CopilotTriagePlan {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [object[]] $Findings = @(),
        [AllowNull()]
        [object] $DiffContext = $null
    )

    $dedupe = @{}
    foreach ($f in @($Findings)) {
        if ($null -eq $f) { continue }
        $category = Normalize-CopilotCategory -Category ([string]$f.Category)
        $path = [string]$f.Path
        $line = if ($null -eq $f.Line) { '' } else { [string]$f.Line }
        $body = ([string]$f.Body).Trim()
        $threadId = [string]$f.ThreadId
        $key = "$category|$threadId|$path|$line|$body"
        if ($dedupe.ContainsKey($key)) { continue }

        $clone = [ordered]@{
            Id                = [string]$f.Id
            Path              = $path
            Line              = $f.Line
            Body              = [string]$f.Body
            Category          = $category
            Severity          = [string]$f.Severity
            ThreadId          = $threadId
            IsResolved        = [bool]$f.IsResolved
            IsOutdated        = [bool]$f.IsOutdated
            CommentDatabaseId = [string]$f.CommentDatabaseId
            HasRejectionReply = [bool]$f.HasRejectionReply
        }
        $dedupe[$key] = [pscustomobject]$clone
    }

    $normalizedFindings = @($dedupe.Values | Sort-Object Category, Path, Line, Id)
    $categories = @('blocker', 'correctness', 'security', 'style', 'nit')
    $items = [System.Collections.Generic.List[object]]::new()
    foreach ($category in $categories) {
        $groupItems = @($normalizedFindings | Where-Object { $_.Category -eq $category })
        if ($groupItems.Count -eq 0) { continue }
        $items.Add([pscustomobject]@{
                Category    = $category
                Count       = $groupItems.Count
                Findings    = @($groupItems)
                DiffContext = $DiffContext
            }) | Out-Null
    }

    $threadStates = @{}
    foreach ($f in $normalizedFindings) {
        if ([string]::IsNullOrWhiteSpace([string]$f.ThreadId)) { continue }
        if (-not $threadStates.ContainsKey($f.ThreadId)) {
            $threadStates[$f.ThreadId] = [pscustomobject]@{
                ThreadId          = [string]$f.ThreadId
                IsResolved        = [bool]$f.IsResolved
                HasRejectionReply = [bool]$f.HasRejectionReply
                CommentDatabaseId = [string]$f.CommentDatabaseId
                Category          = [string]$f.Category
            }
            continue
        }

        # Once true, stays true.
        if ([bool]$f.IsResolved) { $threadStates[$f.ThreadId].IsResolved = $true }
        if ([bool]$f.HasRejectionReply) { $threadStates[$f.ThreadId].HasRejectionReply = $true }
        if ([string]::IsNullOrWhiteSpace([string]$threadStates[$f.ThreadId].CommentDatabaseId) -and
            -not [string]::IsNullOrWhiteSpace([string]$f.CommentDatabaseId)) {
            $threadStates[$f.ThreadId].CommentDatabaseId = [string]$f.CommentDatabaseId
        }
    }

    $unaddressed = @($threadStates.Values | Where-Object { -not $_.IsResolved -and -not $_.HasRejectionReply } | Sort-Object ThreadId)

    $planHash = Get-PlanHash -Findings $normalizedFindings
    $summary = [pscustomobject]@{
        TotalFindings               = $normalizedFindings.Count
        CategoryCounts              = [pscustomobject]@{
            blocker     = (@($normalizedFindings | Where-Object Category -eq 'blocker')).Count
            correctness = (@($normalizedFindings | Where-Object Category -eq 'correctness')).Count
            security    = (@($normalizedFindings | Where-Object Category -eq 'security')).Count
            style       = (@($normalizedFindings | Where-Object Category -eq 'style')).Count
            nit         = (@($normalizedFindings | Where-Object Category -eq 'nit')).Count
        }
        CopilotThreadStates         = @($threadStates.Values | Sort-Object ThreadId)
        UnaddressedCopilotThreads   = @($unaddressed)
        AllCopilotThreadsAddressed  = ($unaddressed.Count -eq 0)
    }

    [pscustomobject]@{
        PlanHash = $planHash
        Items    = @($items)
        Summary  = $summary
    }
}