modules/shared/Resolve-PRReviewThreads.ps1

#Requires -Version 7.4
<#
.SYNOPSIS
    Auto-resolves PR review threads when a follow-up commit modifies the
    file/line range the reviewer flagged.

.DESCRIPTION
    Implements issue #106. After a squad agent pushes a fix in response to a
    Copilot or human review comment, threads whose path + line range were
    touched by commits added AFTER the thread was created are resolved via
    the GitHub GraphQL `resolveReviewThread` mutation. A short reply comment
    is posted on each resolved thread linking the addressing commit SHA.

    Threads that the new commits did NOT touch stay open - the reviewer
    decides. This is the conservative half of the auto-resolve contract:
    explanation-only replies still need manual resolution.

    Designed to be dot-sourced from a workflow step or invoked directly:

        ./modules/shared/Resolve-PRReviewThreads.ps1 -PRNumber 142

    Disable via env var: SQUAD_AUTO_RESOLVE_THREADS=0
#>

[CmdletBinding()]
param(
    [ValidateRange(0, [int]::MaxValue)]
    [int] $PRNumber = 0,

    [ValidateNotNullOrEmpty()]
    [string] $Repo = 'martinopedal/azure-analyzer',

    [switch] $DryRun
)

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

. (Join-Path $PSScriptRoot 'Sanitize.ps1')
. (Join-Path $PSScriptRoot 'Retry.ps1')
$errorsPath = Join-Path $PSScriptRoot 'Errors.ps1'
if (Test-Path $errorsPath) { . $errorsPath }

$script:AutoResolveMarker = '<!-- squad-auto-resolve-thread -->'

function Resolve-RepoOwnerName {
    [CmdletBinding()]
    param([Parameter(Mandatory)][string] $Repo)

    $parts = $Repo.Split('/', 2, [System.StringSplitOptions]::RemoveEmptyEntries)
    if ($parts.Count -ne 2) {
        throw (Format-FindingErrorMessage (New-FindingError -Source 'shared:Resolve-PRReviewThreads' `
            -Category 'InvalidParameter' `
            -Reason "Repo must be in owner/name format. Received: '$Repo'" `
            -Remediation "Pass -Repo as 'owner/name' (e.g. 'martinopedal/azure-analyzer')."))
    }
    [PSCustomObject]@{ Owner = $parts[0]; Name = $parts[1] }
}

function Invoke-GhGraphQl {
    <#
        Thin wrapper around `gh api graphql`. Returns a parsed PSObject.

        Stderr visibility contract (#843):
        When `gh api graphql` exits non-zero we emit the UNSANITIZED stderr
        to the Actions log as a `::debug::` annotation BEFORE running
        Remove-Credentials. GitHub Actions auto-masks registered secrets in
        `::debug::` output, so tokens remain redacted while the underlying
        error payload (FORBIDDEN / NOT_FOUND / RESOLVED / rate-limit JSON)
        becomes visible to maintainers debugging auto-resolve failures.
        The thrown finding-error message still carries the sanitized copy.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string] $Query,
        [hashtable] $Fields
    )

    $ghArgs = @('api', 'graphql', '-f', "query=$Query")
    if ($Fields) {
        foreach ($k in $Fields.Keys) {
            $ghArgs += @('-F', "$k=$($Fields[$k])")
        }
    }

    $text = Invoke-WithRetry -MaxAttempts 3 -InitialDelaySeconds 1 -ScriptBlock {
        # `gh` can be mocked as a PowerShell function in tests. In that case,
        # LASTEXITCODE is not updated and may retain a stale non-zero value
        # from an earlier native command (observed on ubuntu-latest). Reset
        # before invocation so exit handling reflects this call only.
        $global:LASTEXITCODE = 0
        $stdout = & gh @ghArgs 2>&1
        $exitCode = 0
        $exitCodeVar = Get-Variable -Name LASTEXITCODE -Scope Global -ErrorAction SilentlyContinue
        if ($exitCodeVar) { $exitCode = [int]$exitCodeVar.Value }
        $innerText = ($stdout | Out-String)
        if ($exitCode -ne 0) {
            # #843: surface the raw stderr as a debug annotation BEFORE we
            # sanitize / throw. GitHub Actions masks registered secrets in
            # ::debug:: output automatically, so this does not leak tokens.
            # Without this, the classifier loses the signal it needs to
            # decide RESOLVED / OUTDATED / FORBIDDEN / NOT_FOUND.
            $debugLine = ($innerText -replace "`r?`n", ' ⏎ ')
            Write-Host "::debug::gh api graphql exit=$exitCode raw=$debugLine"
            # #843: the classifier (and Invoke-WithRetry's transient-message
            # scan) MUST be able to see the upstream error vocabulary
            # (FORBIDDEN / NOT_FOUND / HTTP 503 / rate limit). Format-
            # FindingErrorMessage drops Details from its rendered string, so
            # we throw a plain exception whose .Message IS the sanitized
            # stderr payload. The ::debug:: annotation above preserves the
            # raw (still token-masked) stderr for maintainers.
            $sanitized = Remove-Credentials $innerText
            throw [System.Exception]::new("gh api graphql failed (exit=$exitCode): $sanitized")
        }
        $innerText
    }

    if ([string]::IsNullOrWhiteSpace($text)) { return $null }
    $text | ConvertFrom-Json -ErrorAction Stop
}

function ConvertTo-ThreadResolveClassification {
    <#
        Classify a `resolveReviewThread` failure message (#843).

        Returns one of:
          - 'AlreadyResolved' : thread is already resolved; idempotent skip
          - 'Outdated' : thread attached to an outdated diff; skip
          - 'NotFound' : thread id unknown (rebase / force-push / deletion); skip
          - 'Forbidden' : mutation refused (bot-vs-bot fallback, app scope drift); warn + skip
          - 'Transient' : rate-limit / 5xx / network glitch; warn + skip (retry handled upstream)
          - 'Fatal' : anything else (auth, schema, unknown); bubble up

        The classifier scans the raw gh stderr / exception message for the
        upstream GitHub GraphQL error vocabulary.
    #>

    [CmdletBinding()]
    param(
        [AllowNull()][AllowEmptyString()]
        [string] $Message
    )

    if ([string]::IsNullOrWhiteSpace($Message)) { return 'Fatal' }

    # Normalize for case-insensitive substring matching.
    $m = $Message

    # Already-resolved: GitHub returns either an explicit "already resolved"
    # string or the mutation succeeds idempotently with isResolved=true.
    if ($m -match '(?i)already\s+resolved' -or
        $m -match '(?i)thread\s+is\s+resolved' -or
        $m -match '(?i)"isResolved"\s*:\s*true') {
        return 'AlreadyResolved'
    }

    if ($m -match '(?i)\bOUTDATED\b' -or $m -match '(?i)outdated\s+(?:diff|thread|line)') {
        return 'Outdated'
    }

    if ($m -match '(?i)\bNOT_FOUND\b' -or
        $m -match '(?i)could\s+not\s+resolve\s+to\s+a\s+node' -or
        ($m -match '(?i)resource\s+not\s+accessible' -and $m -match '(?i)thread')) {
        return 'NotFound'
    }

    if ($m -match '(?i)\bFORBIDDEN\b' -or $m -match '(?i)HTTP\s*403') {
        return 'Forbidden'
    }

    if ($m -match '(?i)\brate\s*limit' -or
        $m -match '(?i)HTTP\s*(?:429|5\d\d)' -or
        $m -match '(?i)\btimeout\b' -or
        $m -match '(?i)\bEOF\b' -or
        $m -match '(?i)connection\s+(?:reset|refused|closed)' -or
        $m -match '(?i)broken\s+pipe') {
        return 'Transient'
    }

    return 'Fatal'
}

function Get-PRReviewThreads {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][int] $PRNumber,
        [Parameter(Mandatory)][string] $Repo
    )

    $r = Resolve-RepoOwnerName -Repo $Repo
    # Pagination (#137 gate fix, Codex-2): cursor through reviewThreads until
    # hasNextPage=false. PRs with >100 unresolved threads were silently missing
    # candidates with the previous single-page query.
    $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
          startLine
          originalStartLine
          comments(first: 100) {
            nodes {
              id
              databaseId
              createdAt
              body
              author { login }
            }
          }
        }
      }
    }
  }
}
'@


    $allNodes = [System.Collections.Generic.List[object]]::new()
    $cursor = $null
    $safetyMax = 50  # 50 * 100 = 5000 threads upper bound; defends against runaway loops.
    $iter = 0
    do {
        $fields = @{
            owner  = $r.Owner
            name   = $r.Name
            number = $PRNumber
        }
        if ($null -ne $cursor) { $fields['cursor'] = $cursor }

        $resp = Invoke-GhGraphQl -Query $query -Fields $fields
        if (-not $resp) { break }

        $page = $resp.data.repository.pullRequest.reviewThreads
        foreach ($n in @($page.nodes)) { $allNodes.Add($n) | Out-Null }

        $hasNext = $false
        if ($page.PSObject.Properties['pageInfo'] -and $page.pageInfo) {
            if ($page.pageInfo.PSObject.Properties['hasNextPage']) {
                $hasNext = [bool]$page.pageInfo.hasNextPage
            }
            if ($hasNext -and $page.pageInfo.PSObject.Properties['endCursor']) {
                $cursor = [string]$page.pageInfo.endCursor
            } else {
                $cursor = $null
            }
        }
        $iter++
        if ($iter -ge $safetyMax) {
            Write-Warning "Get-PRReviewThreads: pagination safety cap ($safetyMax pages) reached for PR #$PRNumber"
            break
        }
    } while ($hasNext -and $cursor)

    if ($allNodes.Count -eq 0) { return @() }

    $threads = foreach ($n in $allNodes) {
        $comments = @($n.comments.nodes)
        $first = if ($comments.Count -gt 0) { $comments[0] } else { $null }

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

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

        [PSCustomObject]@{
            Id             = [string]$n.id
            Path           = [string]$n.path
            Line           = $line
            StartLine      = $startLine
            IsResolved     = [bool]$n.isResolved
            IsOutdated     = [bool]$n.isOutdated
            CreatedAt      = if ($first) { [string]$first.createdAt } else { $null }
            FirstCommentId = if ($first) { [string]$first.id } else { $null }
            Comments       = $comments
        }
    }

    @($threads)
}

function Get-PRCommitsAfter {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][int] $PRNumber,
        [Parameter(Mandatory)][string] $Repo,
        [string] $AfterIso
    )

    $r = Resolve-RepoOwnerName -Repo $Repo
    $endpoint = "repos/$($r.Owner)/$($r.Name)/pulls/$PRNumber/commits"
    $text = Invoke-WithRetry -MaxAttempts 3 -InitialDelaySeconds 1 -ScriptBlock {
        # See Invoke-GhGraphQl: reset LASTEXITCODE so function-mocked `gh`
        # in Pester does not inherit a stale non-zero from a prior native call.
        $global:LASTEXITCODE = 0
        $stdout = & gh api $endpoint --paginate --slurp 2>&1
        $exitCode = 0
        $exitCodeVar = Get-Variable -Name LASTEXITCODE -Scope Global -ErrorAction SilentlyContinue
        if ($exitCodeVar) { $exitCode = [int]$exitCodeVar.Value }
        $innerText = ($stdout | Out-String)
        if ($exitCode -ne 0) {
            throw (Format-FindingErrorMessage (New-FindingError -Source 'shared:Resolve-PRReviewThreads' `
                -Category 'UnexpectedFailure' `
                -Reason "gh api $endpoint failed (PR commits)." `
                -Remediation 'Inspect gh stderr (sanitized in Details) and verify the GH_TOKEN scope.' `
                -Details (Remove-Credentials $innerText)))
        }
        $innerText
    }
    if ([string]::IsNullOrWhiteSpace($text)) { return @() }

    $pages = @($text | ConvertFrom-Json -ErrorAction Stop)
    $afterDt = $null
    if (-not [string]::IsNullOrWhiteSpace($AfterIso)) {
        $afterDt = [System.DateTimeOffset]::Parse($AfterIso, [System.Globalization.CultureInfo]::InvariantCulture).UtcDateTime
    }

    $out = [System.Collections.Generic.List[object]]::new()
    foreach ($page in $pages) {
        foreach ($c in @($page)) {
            $when = $null
            if ($c.commit -and $c.commit.committer -and $c.commit.committer.date) {
                $when = [System.DateTimeOffset]::Parse([string]$c.commit.committer.date, `
                    [System.Globalization.CultureInfo]::InvariantCulture).UtcDateTime
            }
            if ($null -eq $afterDt -or ($when -and $when -gt $afterDt)) {
                $out.Add([PSCustomObject]@{
                    Sha         = [string]$c.sha
                    CommittedAt = if ($when) { $when.ToString('o') } else { $null }
                })
            }
        }
    }
    @($out)
}

function Get-CommitChangedRanges {
    <#
        Returns hashtable: path -> array of [int[]] line-range tuples
        describing lines the commit touched on the RIGHT side of the diff.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string] $Repo,
        [Parameter(Mandatory)][string] $Sha
    )

    $r = Resolve-RepoOwnerName -Repo $Repo
    $endpoint = "repos/$($r.Owner)/$($r.Name)/commits/$Sha"
    $text = Invoke-WithRetry -MaxAttempts 3 -InitialDelaySeconds 1 -ScriptBlock {
        # See Invoke-GhGraphQl: reset LASTEXITCODE so function-mocked `gh`
        # in Pester does not inherit a stale non-zero from a prior native call.
        $global:LASTEXITCODE = 0
        $stdout = & gh api $endpoint 2>&1
        $exitCode = 0
        $exitCodeVar = Get-Variable -Name LASTEXITCODE -Scope Global -ErrorAction SilentlyContinue
        if ($exitCodeVar) { $exitCode = [int]$exitCodeVar.Value }
        $innerText = ($stdout | Out-String)
        if ($exitCode -ne 0) {
            throw (Format-FindingErrorMessage (New-FindingError -Source 'shared:Resolve-PRReviewThreads' `
                -Category 'UnexpectedFailure' `
                -Reason "gh api $endpoint failed (commit detail)." `
                -Remediation 'Inspect gh stderr (sanitized in Details) and verify the GH_TOKEN scope.' `
                -Details (Remove-Credentials $innerText)))
        }
        $innerText
    }

    $payload = $text | ConvertFrom-Json -ErrorAction Stop
    $map = @{}
    if (-not $payload.PSObject.Properties['files']) { return $map }
    foreach ($f in @($payload.files)) {
        $path = [string]$f.filename
        if ([string]::IsNullOrWhiteSpace($path)) { continue }
        if (-not $map.ContainsKey($path)) { $map[$path] = @() }

        $patch = ''
        if ($f.PSObject.Properties['patch']) { $patch = [string]$f.patch }
        if ([string]::IsNullOrWhiteSpace($patch)) {
            # Missing patch (#137 gate fix, Codex-1 / Goldeneye-1):
            # Renames, binary diffs, and oversized diffs all arrive without
            # `patch`. The previous behavior marked the WHOLE file as touched
            # (line 1..MaxValue), which would auto-resolve any thread on that
            # file even if the change was unrelated. Conservative path: skip
            # the file with a warning and let the thread stay open for human
            # review.
            Write-Warning "Skipping file '$path' for commit ${Sha}: no patch in API response (binary, rename, or oversized diff)."
            continue
        }

        foreach ($line in ($patch -split "`n")) {
            if ($line -match '^@@\s+-\d+(?:,\d+)?\s+\+(\d+)(?:,(\d+))?\s+@@') {
                $start = [int]$Matches[1]
                # Right-side count semantics:
                # `+c` -> count = 1 (single added line at $start)
                # `+c,n` -> count = n (n lines on the right)
                # `+c,0` -> count = 0 (deletion-only hunk, NO right-side range)
                # (#137 gate fix, Codex-1 / Goldeneye-1): when count is 0,
                # treat the hunk as having no right-side line range and skip
                # it entirely. The previous coercion to 1 fabricated a
                # right-side line at $start and overlap-matched threads on
                # untouched context.
                $hasCount = [bool]$Matches[2]
                if ($hasCount) {
                    $count = [int]$Matches[2]
                    if ($count -lt 1) {
                        # Deletion-only hunk: no right-side line was added or modified.
                        continue
                    }
                } else {
                    $count = 1
                }
                $end = $start + $count - 1
                $map[$path] += , @($start, $end)
            }
        }
    }
    $map
}

function Test-ThreadAddressedByCommits {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][psobject] $Thread,
        [Parameter(Mandatory)][object[]] $Commits,
        [Parameter(Mandatory)][string] $Repo
    )

    if ([string]::IsNullOrWhiteSpace($Thread.Path)) {
        return [PSCustomObject]@{ Addressed = $false; Sha = $null }
    }
    if ($null -eq $Thread.Line) {
        return [PSCustomObject]@{ Addressed = $false; Sha = $null }
    }

    $start = if ($Thread.StartLine) { [int]$Thread.StartLine } else { [int]$Thread.Line }
    $end = [int]$Thread.Line
    if ($end -lt $start) { $end = $start }

    foreach ($commit in $Commits) {
        $changes = Get-CommitChangedRanges -Repo $Repo -Sha $commit.Sha
        if (-not $changes.ContainsKey($Thread.Path)) { continue }
        foreach ($range in $changes[$Thread.Path]) {
            $rs = [int]$range[0]
            $re = [int]$range[1]
            if ($rs -le $end -and $re -ge $start) {
                return [PSCustomObject]@{ Addressed = $true; Sha = $commit.Sha }
            }
        }
    }
    [PSCustomObject]@{ Addressed = $false; Sha = $null }
}

function Resolve-ReviewThread {
    <#
        Attempt to resolve a single review thread via the GraphQL
        `resolveReviewThread` mutation.

        Returns a PSCustomObject with:
          Resolved [bool] true when the thread is resolved server-side (or DryRun)
          Classification [string] one of: Resolved | AlreadyResolved | Outdated |
                                            NotFound | Forbidden | Transient | Fatal
          Message [string] sanitized failure message ('' on success)

        Only 'Fatal' classifications should bubble up as job-level failures;
        every other classification is a tolerable per-thread skip (#843).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string] $ThreadId,
        [switch] $DryRun
    )

    if ($DryRun) {
        Write-Verbose "DryRun: would resolve thread $ThreadId"
        return [PSCustomObject]@{
            Resolved       = $true
            Classification = 'Resolved'
            Message        = ''
        }
    }

    $mutation = @'
mutation($threadId: ID!) {
  resolveReviewThread(input: { threadId: $threadId }) {
    thread { id isResolved }
  }
}
'@

    try {
        $resp = Invoke-GhGraphQl -Query $mutation -Fields @{ threadId = $ThreadId }
        $resolved = [bool]($resp -and $resp.data.resolveReviewThread.thread.isResolved)
        if ($resolved) {
            return [PSCustomObject]@{
                Resolved       = $true
                Classification = 'Resolved'
                Message        = ''
            }
        }
        # Mutation returned without an isResolved=true payload. Treat as
        # AlreadyResolved (idempotent) if the response mentions resolution,
        # otherwise Fatal so the caller can surface it.
        $respText = if ($resp) { ($resp | ConvertTo-Json -Depth 4 -Compress) } else { '' }
        $classification = ConvertTo-ThreadResolveClassification -Message $respText
        return [PSCustomObject]@{
            Resolved       = $false
            Classification = $classification
            Message        = (Remove-Credentials $respText)
        }
    } catch {
        $rawMsg = [string]$_.Exception.Message
        $classification = ConvertTo-ThreadResolveClassification -Message $rawMsg
        $sanitized = Remove-Credentials $rawMsg
        return [PSCustomObject]@{
            Resolved       = $false
            Classification = $classification
            Message        = $sanitized
        }
    }
}

function Add-ResolutionReply {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][int] $PRNumber,
        [Parameter(Mandatory)][string] $Repo,
        [Parameter(Mandatory)][string] $InReplyToCommentDatabaseId,
        [Parameter(Mandatory)][string] $Sha,
        [string] $Rationale = 'follow-up commit modified the flagged lines',
        [switch] $DryRun
    )

    $shortSha = if ($Sha.Length -gt 7) { $Sha.Substring(0, 7) } else { $Sha }
    $body = "$script:AutoResolveMarker`n_Auto-resolved by squad: addressed in ``$shortSha`` ($Rationale)._"

    if ($DryRun) {
        Write-Verbose "DryRun: would reply to comment $InReplyToCommentDatabaseId"
        return $true
    }

    $r = Resolve-RepoOwnerName -Repo $Repo
    $endpoint = "repos/$($r.Owner)/$($r.Name)/pulls/$PRNumber/comments/$InReplyToCommentDatabaseId/replies"
    try {
        Invoke-WithRetry -MaxAttempts 3 -InitialDelaySeconds 1 -ScriptBlock {
            # See Invoke-GhGraphQl: reset LASTEXITCODE so function-mocked `gh`
            # in Pester does not inherit a stale non-zero from a prior native call.
            $global:LASTEXITCODE = 0
            $stdout = & gh api --method POST $endpoint -f "body=$body" 2>&1
            $exitCode = 0
            $exitCodeVar = Get-Variable -Name LASTEXITCODE -Scope Global -ErrorAction SilentlyContinue
            if ($exitCodeVar) { $exitCode = [int]$exitCodeVar.Value }
            if ($exitCode -ne 0) {
                throw (Format-FindingErrorMessage (New-FindingError -Source 'shared:Resolve-PRReviewThreads' `
                    -Category 'UnexpectedFailure' `
                    -Reason 'Failed to post resolution reply.' `
                    -Remediation 'Inspect gh stderr (sanitized in Details) and verify the GH_TOKEN scope.' `
                    -Details (Remove-Credentials ($stdout | Out-String))))
            }
            $true
        } | Out-Null
    } catch {
        Write-Warning (Remove-Credentials $_.Exception.Message)
        return $false
    }
    $true
}

function Invoke-AutoResolveThreads {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][int] $PRNumber,
        [string] $Repo = 'martinopedal/azure-analyzer',
        [switch] $DryRun
    )

    if ($env:SQUAD_AUTO_RESOLVE_THREADS -eq '0') {
        return [PSCustomObject]@{
            Status            = 'Disabled'
            ResolvedThreadIds = @()
            SkippedThreadIds  = @()
            ToleratedFailures = @()
            ErrorMessage      = $null
        }
    }

    try {
        $threads = Get-PRReviewThreads -PRNumber $PRNumber -Repo $Repo
        $open = @($threads | Where-Object { -not $_.IsResolved -and $_.Path -and $_.CreatedAt })

        if ($open.Count -eq 0) {
            return [PSCustomObject]@{
                Status            = 'NoOpenThreads'
                ResolvedThreadIds = @()
                SkippedThreadIds  = @()
                ToleratedFailures = @()
                ErrorMessage      = $null
            }
        }

        $earliest = ($open | Sort-Object CreatedAt | Select-Object -First 1).CreatedAt
        $allCommits = Get-PRCommitsAfter -PRNumber $PRNumber -Repo $Repo -AfterIso $earliest

        $resolved = [System.Collections.Generic.List[string]]::new()
        $skipped = [System.Collections.Generic.List[string]]::new()
        $tolerated = [System.Collections.Generic.List[pscustomobject]]::new()

        foreach ($thread in $open) {
            $threadDt = [System.DateTimeOffset]::Parse($thread.CreatedAt, `
                [System.Globalization.CultureInfo]::InvariantCulture).UtcDateTime
            $eligibleCommits = @($allCommits | Where-Object {
                $_.CommittedAt -and ([System.DateTimeOffset]::Parse($_.CommittedAt, `
                    [System.Globalization.CultureInfo]::InvariantCulture).UtcDateTime -gt $threadDt)
            })

            if ($eligibleCommits.Count -eq 0) {
                $skipped.Add($thread.Id) | Out-Null
                continue
            }

            $check = Test-ThreadAddressedByCommits -Thread $thread -Commits $eligibleCommits -Repo $Repo
            if (-not $check.Addressed) {
                $skipped.Add($thread.Id) | Out-Null
                continue
            }

            # #843: per-thread tolerance. Resolve-ReviewThread now returns a
            # classified result; tolerable classifications (AlreadyResolved,
            # Outdated, NotFound, Forbidden, Transient) are logged and the
            # thread moves to SkippedThreadIds. Only 'Fatal' aborts the loop.
            $attempt = Resolve-ReviewThread -ThreadId $thread.Id -DryRun:$DryRun
            $skipReason = $null
            switch ($attempt.Classification) {
                'Resolved'        { break }
                'AlreadyResolved' { $skipReason = 'AlreadyResolved'; break }
                'Outdated'        { $skipReason = 'Outdated'; break }
                'NotFound'        { $skipReason = 'NotFound'; break }
                'Forbidden'       { $skipReason = 'Forbidden'; break }
                'Transient'       { $skipReason = 'Transient'; break }
                default {
                    # 'Fatal' — propagate. This path is for auth / schema /
                    # unrecognised errors that an operator must investigate.
                    throw (Format-FindingErrorMessage (New-FindingError -Source 'shared:Resolve-PRReviewThreads' `
                        -Category 'UnexpectedFailure' `
                        -Reason "Fatal GraphQL error resolving thread $($thread.Id)." `
                        -Remediation 'Inspect the ::debug:: gh api graphql annotation on the failing run; verify app token scope.' `
                        -Details $attempt.Message))
                }
            }
            if ($skipReason) {
                if ($skipReason -in @('Forbidden','Transient')) {
                    Write-Warning "auto-resolve skip thread=$($thread.Id) reason=$skipReason msg=$($attempt.Message)"
                } else {
                    Write-Host "::notice::auto-resolve skip thread=$($thread.Id) reason=$skipReason"
                }
                $skipped.Add($thread.Id) | Out-Null
                $tolerated.Add([pscustomobject]@{ Id = $thread.Id; Reason = $skipReason }) | Out-Null
                continue
            }

            $hasFirstComment = $thread.Comments -and $thread.Comments.Count -gt 0
            if ($hasFirstComment -and $thread.Comments[0].PSObject.Properties['databaseId'] -and $null -ne $thread.Comments[0].databaseId) {
                # Idempotency guard (#137 gate fix, Codex-3):
                # Two overlapping workflow runs can both reach this point
                # before concurrency cancellation lands. The
                # resolveReviewThread mutation is server-side idempotent on a
                # resolved thread, but the reply POST is NOT, so we'd post the
                # marker twice. Scan existing comments for our marker first
                # and skip the reply if it's already there.
                $markerAlreadyPresent = $false
                foreach ($c in $thread.Comments) {
                    if ($c -and $c.PSObject.Properties['body'] -and $c.body -and `
                        ([string]$c.body).Contains($script:AutoResolveMarker)) {
                        $markerAlreadyPresent = $true
                        break
                    }
                }
                if (-not $markerAlreadyPresent) {
                    Add-ResolutionReply -PRNumber $PRNumber -Repo $Repo `
                        -InReplyToCommentDatabaseId ([string]$thread.Comments[0].databaseId) `
                        -Sha $check.Sha -DryRun:$DryRun | Out-Null
                }
            }
            $resolved.Add($thread.Id) | Out-Null
        }

        [PSCustomObject]@{
            Status               = 'Success'
            ResolvedThreadIds    = @($resolved)
            SkippedThreadIds     = @($skipped)
            ToleratedFailures    = @($tolerated)
            ErrorMessage         = $null
        }
    } catch {
        $msg = Remove-Credentials ([string]$_.Exception.Message)
        Write-Warning "Invoke-AutoResolveThreads failed: $msg"
        [PSCustomObject]@{
            Status            = 'Failed'
            ResolvedThreadIds = @()
            SkippedThreadIds  = @()
            ToleratedFailures = @()
            ErrorMessage      = $msg
        }
    }
}

if ($MyInvocation.InvocationName -ne '.') {
    if ($PRNumber -lt 1) {
        throw (Format-FindingErrorMessage (New-FindingError -Source 'shared:Resolve-PRReviewThreads' `
            -Category 'InvalidParameter' `
            -Reason 'PRNumber is required when running Resolve-PRReviewThreads.ps1 directly.' `
            -Remediation 'Pass -PRNumber <int> to the script invocation.'))
    }
    $result = Invoke-AutoResolveThreads -PRNumber $PRNumber -Repo $Repo -DryRun:$DryRun
    $result | ConvertTo-Json -Depth 5
    if ($result.Status -eq 'Failed') { throw $result.ErrorMessage }
}