modules/shared/Get-CopilotReviewFindings.ps1

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

. (Join-Path $PSScriptRoot 'Sanitize.ps1')
. (Join-Path $PSScriptRoot 'Retry.ps1')
. (Join-Path $PSScriptRoot 'Resolve-PRReviewThreads.ps1')

function Get-CopilotCategoryAndSeverity {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [AllowEmptyString()]
        [string] $Body = ''
    )

    $category = 'correctness'
    if (-not [string]::IsNullOrWhiteSpace($Body)) {
        if ($Body -match '(?im)^\s*\[(blocker|correctness|security|style|nit)\]') {
            $category = $Matches[1].ToLowerInvariant()
        }
    }

    $severity = switch ($category) {
        'blocker'     { 'High' }
        'security'    { 'High' }
        'correctness' { 'Medium' }
        'style'       { 'Low' }
        'nit'         { 'Info' }
        default       { 'Medium' }
    }

    [pscustomobject]@{
        Category = $category
        Severity = $severity
    }
}

function Get-CopilotReviewFindings {
    [CmdletBinding()]
    [OutputType([object[]])]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string] $Owner,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string] $Repo,

        [Parameter(Mandatory)]
        [ValidateRange(1, [int]::MaxValue)]
        [int] $PullNumber
    )

    $resolvedOwner = $Owner.Trim()
    $resolvedRepo = $Repo.Trim()
    if ($resolvedOwner.Contains('/')) {
        $ownerParts = Resolve-RepoOwnerName -Repo $resolvedOwner
        $resolvedOwner = [string]$ownerParts.Owner
        if ([string]::IsNullOrWhiteSpace($resolvedRepo)) {
            $resolvedRepo = [string]$ownerParts.Name
        }
    }
    if ($resolvedRepo.Contains('/')) {
        $repoParts = Resolve-RepoOwnerName -Repo $resolvedRepo
        if ([string]::IsNullOrWhiteSpace($resolvedOwner) -or $resolvedOwner -eq [string]$repoParts.Owner) {
            $resolvedOwner = [string]$repoParts.Owner
            $resolvedRepo = [string]$repoParts.Name
        } else {
            $resolvedRepo = [string]$repoParts.Name
        }
    }

    $query = @'
query($owner: String!, $name: String!, $number: Int!, $cursor: String) {
  repository(owner: $owner, name: $name) {
    pullRequest(number: $number) {
      reviewThreads(first: 100, after: $cursor) {
        pageInfo { hasNextPage endCursor }
        nodes {
          id
          isResolved
          isOutdated
          path
          line
          originalLine
          comments(first: 100) {
            nodes {
              id
              databaseId
              body
              author { login }
            }
          }
        }
      }
    }
  }
}
'@


    $all = [System.Collections.Generic.List[object]]::new()
    $cursor = $null
    $hasNext = $true
    $maxPages = 50
    $page = 0

    while ($hasNext -and $page -lt $maxPages) {
        $fields = @{
            owner  = $resolvedOwner
            name   = $resolvedRepo
            number = $PullNumber
        }
        if ($null -ne $cursor) {
            $fields['cursor'] = $cursor
        }

        $resp = Invoke-WithRetry -MaxAttempts 3 -InitialDelaySeconds 1 -ScriptBlock {
            Invoke-GhGraphQl -Query $query -Fields $fields
        }

        if (-not $resp) { break }

        $threads = $resp.data.repository.pullRequest.reviewThreads
        foreach ($thread in @($threads.nodes)) {
            $threadHasRejectionReply = $false
            foreach ($comment in @($thread.comments.nodes)) {
                $body = [string]$comment.body
                if ($body -match '(?i)multi-model rejection') {
                    $threadHasRejectionReply = $true
                    break
                }
            }

            foreach ($comment in @($thread.comments.nodes)) {
                $login = ''
                if ($comment.author -and $comment.author.login) {
                    $login = [string]$comment.author.login
                }
                if ($login -notmatch '(?i)copilot') { continue }

                $line = $null
                if ($thread.PSObject.Properties['line'] -and $null -ne $thread.line) {
                    $line = [int]$thread.line
                } elseif ($thread.PSObject.Properties['originalLine'] -and $null -ne $thread.originalLine) {
                    $line = [int]$thread.originalLine
                }

                $cat = Get-CopilotCategoryAndSeverity -Body ([string]$comment.body)
                $findingId = "$([string]$thread.id):$([string]$comment.id)"

                $all.Add([pscustomobject]@{
                        Id               = $findingId
                        Path             = [string]$thread.path
                        Line             = $line
                        Body             = [string]$comment.body
                        Category         = [string]$cat.Category
                        Severity         = [string]$cat.Severity
                        ThreadId         = [string]$thread.id
                        IsResolved       = [bool]$thread.isResolved
                        IsOutdated       = [bool]$thread.isOutdated
                        CommentDatabaseId = if ($comment.PSObject.Properties['databaseId']) { [string]$comment.databaseId } else { $null }
                        HasRejectionReply = $threadHasRejectionReply
                    }) | Out-Null
            }
        }

        $hasNext = [bool]$threads.pageInfo.hasNextPage
        $cursor = if ($hasNext) { [string]$threads.pageInfo.endCursor } else { $null }
        $page++
    }

    @($all)
}