modules/shared/Invoke-PRReviewGate.ps1
|
#Requires -Version 7.4 [CmdletBinding()] param( [ValidateRange(0, [int]::MaxValue)] [int] $PRNumber = 0, [ValidateNotNullOrEmpty()] [string] $Repo = 'martinopedal/azure-analyzer', [ValidateNotNullOrEmpty()] [string] $OutputPath = '.squad/decisions/inbox/', [string] $ModelResponsesPath, [string] $Agent = 'sentinel', [string] $PRAuthorAgent = $env:PR_AUTHOR_AGENT, [switch] $DryRun ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $retryPath = Join-Path $PSScriptRoot 'Retry.ps1' if (Test-Path $retryPath) { . $retryPath } . (Join-Path $PSScriptRoot 'Sanitize.ps1') $errorsPath = Join-Path $PSScriptRoot 'Errors.ps1' if (Test-Path $errorsPath) { . $errorsPath } # Inline test-compat fallback for Invoke-WithRetry if (-not (Get-Command Invoke-WithRetry -ErrorAction SilentlyContinue)) { function Invoke-WithRetry { param ([scriptblock]$ScriptBlock, [int]$MaxAttempts = 3, [string]$OperationName = 'operation') & $ScriptBlock } } function New-RepoTempFile { [CmdletBinding()] param( [Parameter(Mandatory)] [string] $Prefix ) $tempDir = Join-Path $PSScriptRoot '..\..\.squad\state\tmp' if (-not (Test-Path $tempDir)) { New-Item -Path $tempDir -ItemType Directory -Force | Out-Null } Join-Path $tempDir "$Prefix-$([guid]::NewGuid().ToString('N')).tmp" } function Resolve-RepoParts { [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:Invoke-PRReviewGate' ` -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-GhApiPaged { [CmdletBinding()] param( [Parameter(Mandatory)] [string] $Endpoint ) Invoke-WithRetry -ScriptBlock { $stdoutPath = New-RepoTempFile -Prefix 'gh-api-out' $stderrPath = New-RepoTempFile -Prefix 'gh-api-err' $lastError = '' $text = '' try { # `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 & gh api $Endpoint --paginate --slurp 1> $stdoutPath 2> $stderrPath $exitCode = 0 $exitCodeVar = Get-Variable -Name LASTEXITCODE -Scope Global -ErrorAction SilentlyContinue if ($exitCodeVar) { $exitCode = [int]$exitCodeVar.Value } if ($exitCode -ne 0) { $stderrText = '' if (Test-Path $stderrPath) { $stderrText = Get-Content -Path $stderrPath -Raw } $lastError = Remove-Credentials $stderrText throw (Format-FindingErrorMessage (New-FindingError -Source 'shared:Invoke-PRReviewGate' ` -Category 'UnexpectedFailure' ` -Reason "gh api $Endpoint failed." ` -Remediation 'Inspect gh stderr (sanitized in Details) and verify the GH_TOKEN scope and rate limits.' ` -Details $lastError)) } if (Test-Path $stdoutPath) { $text = Get-Content -Path $stdoutPath -Raw } if ([string]::IsNullOrWhiteSpace($text) -and -not [string]::IsNullOrWhiteSpace($lastError)) { throw (Format-FindingErrorMessage (New-FindingError -Source 'shared:Invoke-PRReviewGate' ` -Category 'UnexpectedFailure' ` -Reason "gh api $Endpoint failed (empty stdout)." ` -Remediation 'Inspect gh stderr (sanitized in Details) and verify the GH_TOKEN scope and rate limits.' ` -Details $lastError)) } # NOTE: Do NOT run Remove-Credentials on raw JSON text here. # Greedy regex patterns (e.g. Password=[^;]+) can match inside # diff_hunk string values and consume past the closing JSON quote, # producing "Unterminated string" parse errors. Individual fields # are sanitized after parsing in Get-PRReviewFeedback instead. if ([string]::IsNullOrWhiteSpace($text)) { return @() } try { $pages = @($text | ConvertFrom-Json -ErrorAction Stop) } catch { # Fallback: strip diff_hunk values that may contain characters # breaking JSON structure, then retry parsing. $stripped = [regex]::Replace($text, '"diff_hunk"\s*:\s*"(?:[^"\\]|\\.)*"', '"diff_hunk":""') $pages = @($stripped | ConvertFrom-Json -ErrorAction Stop) } $items = [System.Collections.Generic.List[object]]::new() foreach ($page in $pages) { foreach ($item in @($page)) { $items.Add($item) } } return @($items) } finally { Remove-Item -Path $stdoutPath -ErrorAction SilentlyContinue Remove-Item -Path $stderrPath -ErrorAction SilentlyContinue } } -MaxAttempts 3 } function Get-PRReviewFeedback { [CmdletBinding()] param( [Parameter(Mandatory)] [int] $PRNumber, [Parameter(Mandatory)] [string] $Repo ) $repoParts = Resolve-RepoParts -Repo $Repo $basePath = "repos/$($repoParts.Owner)/$($repoParts.Name)/pulls/$PRNumber" $reviewsRaw = Invoke-GhApiPaged -Endpoint "$basePath/reviews" $lineCommentsRaw = Invoke-GhApiPaged -Endpoint "$basePath/comments" $reviews = foreach ($review in $reviewsRaw) { $reviewer = if ($review.PSObject.Properties['user'] -and $review.user -and $review.user.PSObject.Properties['login'] -and $review.user.login) { [string]$review.user.login } else { 'unknown-reviewer' } [PSCustomObject]@{ Id = [string]$review.id Reviewer = $reviewer State = if ($review.state) { [string]$review.state } else { 'COMMENTED' } Body = Remove-Credentials ([string]$review.body) SubmittedAt = [string]$review.submitted_at CommitId = [string]$review.commit_id } } $lineComments = foreach ($comment in $lineCommentsRaw) { $reviewer = if ($comment.PSObject.Properties['user'] -and $comment.user -and $comment.user.PSObject.Properties['login'] -and $comment.user.login) { [string]$comment.user.login } else { 'unknown-reviewer' } $inReplyToId = $null if ($comment.PSObject.Properties['in_reply_to_id']) { $inReplyToId = [string]$comment.in_reply_to_id } $reviewId = $null if ($comment.PSObject.Properties['pull_request_review_id']) { $reviewId = [string]$comment.pull_request_review_id } [PSCustomObject]@{ Id = [string]$comment.id Reviewer = $reviewer Body = Remove-Credentials ([string]$comment.body) Path = [string]$comment.path Line = if ($null -ne $comment.line) { [int]$comment.line } else { $null } Side = [string]$comment.side InReplyToId = $inReplyToId ReviewId = $reviewId SubmittedAt = if ($comment.created_at) { [string]$comment.created_at } else { [string]$comment.updated_at } } } $states = @($reviews | ForEach-Object { $_.State }) $reviewerStateCounts = @{} foreach ($state in $states) { if (-not $reviewerStateCounts.ContainsKey($state)) { $reviewerStateCounts[$state] = 0 } $reviewerStateCounts[$state]++ } [PSCustomObject]@{ Repo = $Repo PRNumber = $PRNumber GeneratedAt = (Get-Date).ToUniversalTime().ToString('o') Reviews = @($reviews) LineComments = @($lineComments) Summary = [PSCustomObject]@{ ReviewCount = @($reviews).Count LineCommentCount = @($lineComments).Count StateCounts = $reviewerStateCounts } } } function Get-TriageModels { [CmdletBinding()] param() # Frontier-only roster. Strict allow-list, see # `.copilot/copilot-instructions.md` -> "Frontier Model Roster". @( [PSCustomObject]@{ Name = 'claude-opus-4.7'; Role = 'claude-premium' } [PSCustomObject]@{ Name = 'gpt-5.3-codex'; Role = 'openai-codex' } [PSCustomObject]@{ Name = 'goldeneye'; Role = 'architectural-diversity' } ) } function Invoke-MultiModelTriage { [CmdletBinding()] param( [Parameter(Mandatory)] [psobject] $FeedbackPayload, [Parameter(Mandatory)] [string] $OutputPath, [switch] $DryRun ) $models = @(Get-TriageModels) $promptBundle = [System.Collections.Generic.List[object]]::new() $safeJson = Remove-Credentials ($FeedbackPayload | ConvertTo-Json -Depth 20) if (-not $DryRun) { New-Item -Path $OutputPath -ItemType Directory -Force | Out-Null } foreach ($model in $models) { $safeModelName = $model.Name -replace '[^A-Za-z0-9.-]', '-' $inputFile = Join-Path $OutputPath "pr-$($FeedbackPayload.PRNumber)-$safeModelName-feedback.json" $responseFile = Join-Path $OutputPath "pr-$($FeedbackPayload.PRNumber)-$safeModelName-response.json" $prompt = @" You are model '$($model.Name)' in a 3-model PR review gate. Analyze the feedback JSON file at '$inputFile'. Return strict JSON with this schema: { "reviewerVerdict": "APPROVED|CHANGES_REQUESTED|COMMENTED", "consensusFindings": [ { "title": "...", "detail": "...", "path": "...", "line": 0, "severity": "Critical|High|Medium|Low" } ], "disputedFindings": [ { "title": "...", "detail": "...", "path": "...", "line": 0, "severity": "Critical|High|Medium|Low" } ], "actionPlan": ["..."], "recommendedRevisionOwner": "agent-login" } Focus on correctness, security, lockout governance, dedup quality, and edge cases. "@ if (-not $DryRun) { Set-Content -Path $inputFile -Value $safeJson -Encoding utf8 } $promptBundle.Add([PSCustomObject]@{ Model = $model.Name Role = $model.Role InputFile = $inputFile ResponseFile = $responseFile Prompt = $prompt }) } $bundlePath = Join-Path $OutputPath "pr-$($FeedbackPayload.PRNumber)-triage-prompt-bundle.json" $bundlePayload = [PSCustomObject]@{ Repo = $FeedbackPayload.Repo PRNumber = $FeedbackPayload.PRNumber CreatedAt = (Get-Date).ToUniversalTime().ToString('o') Status = 'AwaitingModelResponses' ModelPromptPack = @($promptBundle) } if (-not $DryRun) { $bundleJson = Remove-Credentials ($bundlePayload | ConvertTo-Json -Depth 20) Set-Content -Path $bundlePath -Value $bundleJson -Encoding utf8 } [PSCustomObject]@{ BundlePath = $bundlePath Bundle = $bundlePayload } } function Get-ReplacementAgent { [CmdletBinding()] param( [string] $LockedOutAgent, [string[]] $SuggestedAgents ) foreach ($agent in @($SuggestedAgents)) { if (-not [string]::IsNullOrWhiteSpace($agent) -and $agent -ne $LockedOutAgent) { return $agent } } $fallbackAgents = @('forge', 'atlas', 'iris', 'sage', 'sentinel', 'lead') foreach ($agent in $fallbackAgents) { if ($agent -ne $LockedOutAgent) { return $agent } } 'unassigned' } function ConvertTo-TriageResponse { [CmdletBinding()] param( [AllowNull()] [object] $Response ) if ($null -eq $Response) { return $null } if ($Response -is [string]) { if ([string]::IsNullOrWhiteSpace($Response)) { return $null } $Response = $Response | ConvertFrom-Json -ErrorAction Stop if ($null -eq $Response) { return $null } } $verdict = if ($Response.reviewerVerdict) { [string]$Response.reviewerVerdict } else { 'COMMENTED' } $findings = @($Response.consensusFindings | Where-Object { $_ }) $disputed = @($Response.disputedFindings | Where-Object { $_ }) $actions = @($Response.actionPlan | Where-Object { $_ }) $recommendedOwner = [string]$Response.recommendedRevisionOwner [PSCustomObject]@{ ReviewerVerdict = $verdict ConsensusFindings = @($findings) DisputedFindings = @($disputed) ActionPlan = @($actions) RecommendedRevisionOwner = $recommendedOwner } } function Merge-TriageResponses { [CmdletBinding()] param( [Parameter(Mandatory)] [psobject] $FeedbackPayload, [object[]] $Responses = @(), [string] $LockedOutAgent ) $normalized = @( $Responses | Where-Object { $null -ne $_ } | ForEach-Object { ConvertTo-TriageResponse -Response $_ } | Where-Object { $null -ne $_ } ) if ($normalized.Count -eq 0) { $autoVerdict = if ($FeedbackPayload.Reviews | Where-Object { $_.State -eq 'CHANGES_REQUESTED' }) { 'CHANGES_REQUESTED' } elseif ($FeedbackPayload.Reviews | Where-Object { $_.State -eq 'APPROVED' }) { 'APPROVED' } else { 'COMMENTED' } $autoFindings = foreach ($comment in @($FeedbackPayload.LineComments)) { [PSCustomObject]@{ title = "Line comment: $($comment.Path):$($comment.Line)" detail = $comment.Body path = $comment.Path line = $comment.Line severity = 'Medium' } } $recommendedOwner = Get-ReplacementAgent -LockedOutAgent $LockedOutAgent -SuggestedAgents @() return [PSCustomObject]@{ ReviewerVerdict = $autoVerdict ConsensusFindings = @($autoFindings) DisputedFindings = @() ActionPlan = @( 'Address all CHANGES_REQUESTED and unresolved line comments.', 'Reply on each review thread with fix reference or rationale.', 'Re-run 3-model rubber-duck gate before re-requesting review.', 'Lockout enforcement: rejected PR author must not execute revisions in this cycle.' ) LockedOutAgent = if ($LockedOutAgent) { $LockedOutAgent } else { 'unknown' } RecommendedRevisionOwner = $recommendedOwner } } $verdictCounts = @{} foreach ($response in $normalized) { $key = [string]$response.ReviewerVerdict if (-not $verdictCounts.ContainsKey($key)) { $verdictCounts[$key] = 0 } $verdictCounts[$key]++ } $consensusVerdict = 'APPROVED' if ($verdictCounts.ContainsKey('CHANGES_REQUESTED')) { $consensusVerdict = 'CHANGES_REQUESTED' } elseif ($verdictCounts.ContainsKey('COMMENTED')) { $consensusVerdict = 'COMMENTED' } elseif ($verdictCounts.ContainsKey('APPROVED')) { $consensusVerdict = 'APPROVED' } $findingMap = @{} $allFindings = [System.Collections.Generic.List[object]]::new() foreach ($response in $normalized) { foreach ($finding in @($response.ConsensusFindings | Where-Object { $_ })) { $path = [string]$finding.path $line = if ($null -ne $finding.line) { [string]$finding.line } else { '' } $title = [string]$finding.title $detail = [string]$finding.detail $key = "$path|$line|$title|$detail" if (-not $findingMap.ContainsKey($key)) { $findingMap[$key] = 0 $allFindings.Add([PSCustomObject]@{ title = $title detail = $detail path = $path line = $finding.line severity = if ($finding.severity) { [string]$finding.severity } else { 'Medium' } }) } $findingMap[$key]++ } } $consensusFindings = [System.Collections.Generic.List[object]]::new() $disputedFindings = [System.Collections.Generic.List[object]]::new() foreach ($finding in $allFindings) { $lineKey = if ($null -ne $finding.line) { [string]$finding.line } else { '' } $key = "$($finding.path)|$lineKey|$($finding.title)|$($finding.detail)" if ($findingMap[$key] -ge 2) { $consensusFindings.Add($finding) } else { $disputedFindings.Add($finding) } } foreach ($response in $normalized) { foreach ($finding in @($response.DisputedFindings | Where-Object { $_ })) { $disputedFindings.Add([PSCustomObject]@{ title = [string]$finding.title detail = [string]$finding.detail path = [string]$finding.path line = $finding.line severity = if ($finding.severity) { [string]$finding.severity } else { 'Medium' } }) } } $actions = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($response in $normalized) { foreach ($action in @($response.ActionPlan | Where-Object { $_ })) { $safeAction = Remove-Credentials ([string]$action) if (-not [string]::IsNullOrWhiteSpace($safeAction)) { [void]$actions.Add($safeAction) } } } [void]$actions.Add('Lockout enforcement: rejected PR author must not execute revisions in this cycle.') $suggested = @($normalized | ForEach-Object { $_.RecommendedRevisionOwner } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) $recommendedOwner = Get-ReplacementAgent -LockedOutAgent $LockedOutAgent -SuggestedAgents $suggested [PSCustomObject]@{ ReviewerVerdict = $consensusVerdict ConsensusFindings = @($consensusFindings) DisputedFindings = @($disputedFindings) ActionPlan = @($actions) LockedOutAgent = if ($LockedOutAgent) { $LockedOutAgent } else { 'unknown' } RecommendedRevisionOwner = $recommendedOwner } } function Save-ReviewPlan { [CmdletBinding()] param( [Parameter(Mandatory)] [psobject] $Consensus, [Parameter(Mandatory)] [int] $PRNumber, [Parameter(Mandatory)] [string] $OutputPath, [string] $Agent = 'sentinel', [switch] $DryRun ) $filePath = Join-Path $OutputPath "$Agent-pr-$PRNumber-review.md" $consensusLines = @($Consensus.ConsensusFindings | ForEach-Object { "- [$($_.severity)] $($_.title) (``$($_.path):$($_.line)``)`n - $($_.detail)" }) if ($consensusLines.Count -eq 0) { $consensusLines = @('- None') } $disputedLines = @($Consensus.DisputedFindings | ForEach-Object { "- [$($_.severity)] $($_.title) (``$($_.path):$($_.line)``)`n - $($_.detail)" }) if ($disputedLines.Count -eq 0) { $disputedLines = @('- None') } $actionLines = @($Consensus.ActionPlan | ForEach-Object { "- $_" }) if ($actionLines.Count -eq 0) { $actionLines = @('- No action plan provided') } $content = @" # PR #$PRNumber Review Gate Consensus ## Reviewer Verdict $($Consensus.ReviewerVerdict) ## Consensus Findings $($consensusLines -join "`n") ## Disputed Findings $($disputedLines -join "`n") ## Action Plan $($actionLines -join "`n") ## Reviewer Lockout Notice - Locked-out agent: $($Consensus.LockedOutAgent) - Replacement revision owner: $($Consensus.RecommendedRevisionOwner) - Rule: rejected PR author must not self-revise in the same gate cycle. "@ $safeContent = Remove-Credentials $content if (-not $DryRun) { New-Item -Path $OutputPath -ItemType Directory -Force | Out-Null Set-Content -Path $filePath -Value $safeContent -Encoding utf8 } [PSCustomObject]@{ Path = $filePath Content = $safeContent } } function Post-PRSummaryComment { [CmdletBinding()] param( [Parameter(Mandatory)] [int] $PRNumber, [Parameter(Mandatory)] [string] $Repo, [Parameter(Mandatory)] [psobject] $Consensus, [Parameter(Mandatory)] [string] $PlanPath, [switch] $DryRun ) $marker = '<!-- squad-pr-review-gate -->' $actions = @($Consensus.ActionPlan | ForEach-Object { "- $_" }) $body = @" $marker ### PR Review Gate Summary - Verdict: **$($Consensus.ReviewerVerdict)** - Locked-out agent: **$($Consensus.LockedOutAgent)** - Replacement revision owner: **$($Consensus.RecommendedRevisionOwner)** - Consensus plan: `$PlanPath` What will change: $($actions -join "`n") _Updated in place on each review event — see PR timeline for full history._ "@ $safeBody = Remove-Credentials $body if ($DryRun) { Write-Host "[DryRun] Upsert squad-pr-review-gate comment on PR $PRNumber (repo $Repo)" return } # Look for existing gate comment to update in place (prevents email noise on re-runs) $existingId = Invoke-WithRetry -ScriptBlock { $commentsJson = & gh api "repos/$Repo/issues/$PRNumber/comments" --paginate 2> $null if ($LASTEXITCODE -eq 0 -and $commentsJson) { $existing = $commentsJson | ConvertFrom-Json $match = @($existing | Where-Object { $_.body -and $_.body.Contains($marker) } | Select-Object -First 1) if ($match.Count -gt 0) { return [string]$match[0].id } } return $null } -MaxAttempts 3 $bodyFilePath = New-RepoTempFile -Prefix 'pr-review-comment' $stderrPath = New-RepoTempFile -Prefix 'pr-review-comment-err' try { Set-Content -Path $bodyFilePath -Value $safeBody -Encoding utf8 Invoke-WithRetry -ScriptBlock { # See Invoke-GhApiPaged: reset LASTEXITCODE so function-mocked `gh` # in Pester does not inherit a stale non-zero from a prior native call. $global:LASTEXITCODE = 0 if ($existingId) { & gh api --method PATCH "repos/$Repo/issues/comments/$existingId" -F "body=@$bodyFilePath" 1> $null 2> $stderrPath } else { & gh pr comment $PRNumber --repo $Repo --body-file $bodyFilePath 1> $null 2> $stderrPath } $exitCode = 0 $exitCodeVar = Get-Variable -Name LASTEXITCODE -Scope Global -ErrorAction SilentlyContinue if ($exitCodeVar) { $exitCode = [int]$exitCodeVar.Value } if ($exitCode -ne 0) { $errorText = '' if (Test-Path $stderrPath) { $errorText = Remove-Credentials (Get-Content -Path $stderrPath -Raw) } throw (Format-FindingErrorMessage (New-FindingError -Source 'shared:Invoke-PRReviewGate' ` -Category 'UnexpectedFailure' ` -Reason 'gh pr comment failed.' ` -Remediation 'Inspect gh stderr (sanitized in Details) and verify the GH_TOKEN scope and PR access.' ` -Details $errorText)) } } -MaxAttempts 3 } finally { Remove-Item -Path $bodyFilePath -ErrorAction SilentlyContinue Remove-Item -Path $stderrPath -ErrorAction SilentlyContinue } } function Get-ModelResponses { [CmdletBinding()] param( [string] $ModelResponsesPath ) $rawText = $null if ($ModelResponsesPath) { if (-not (Test-Path $ModelResponsesPath)) { throw (Format-FindingErrorMessage (New-FindingError -Source 'shared:Invoke-PRReviewGate' ` -Category 'NotFound' ` -Reason "ModelResponsesPath not found: $ModelResponsesPath" ` -Remediation 'Pass an existing JSON file path or unset -ModelResponsesPath to use $env:PR_REVIEW_GATE_RESPONSES_JSON.')) } $rawText = Get-Content -Path $ModelResponsesPath -Raw } elseif (-not [string]::IsNullOrWhiteSpace($env:PR_REVIEW_GATE_RESPONSES_JSON)) { $rawText = $env:PR_REVIEW_GATE_RESPONSES_JSON } if ([string]::IsNullOrWhiteSpace($rawText)) { return @() } $parsed = $rawText | ConvertFrom-Json -ErrorAction Stop if ($null -eq $parsed) { return @() } @($parsed | Where-Object { $null -ne $_ }) } function Invoke-PRReviewGate { [CmdletBinding()] param( [Parameter(Mandatory)] [int] $PRNumber, [string] $Repo = 'martinopedal/azure-analyzer', [string] $OutputPath = '.squad/decisions/inbox/', [string] $ModelResponsesPath, [string] $Agent = 'sentinel', [string] $PRAuthorAgent = $env:PR_AUTHOR_AGENT, [switch] $DryRun ) try { $feedback = Get-PRReviewFeedback -PRNumber $PRNumber -Repo $Repo $promptBundle = Invoke-MultiModelTriage -FeedbackPayload $feedback -OutputPath $OutputPath -DryRun:$DryRun $responses = Get-ModelResponses -ModelResponsesPath $ModelResponsesPath $consensus = Merge-TriageResponses -FeedbackPayload $feedback -Responses $responses -LockedOutAgent $PRAuthorAgent if ([string]::IsNullOrWhiteSpace($PRAuthorAgent)) { throw (Format-FindingErrorMessage (New-FindingError -Source 'shared:Invoke-PRReviewGate' ` -Category 'InvalidParameter' ` -Reason 'PRAuthorAgent is required for mechanical lockout enforcement.' ` -Remediation 'Pass -PRAuthorAgent or set $env:PR_AUTHOR_AGENT to the agent that authored the PR.')) } if ($consensus.RecommendedRevisionOwner -eq $PRAuthorAgent) { throw (Format-FindingErrorMessage (New-FindingError -Source 'shared:Invoke-PRReviewGate' ` -Category 'AuthorizationFailed' ` -Reason "Lockout enforcement failed: replacement owner matches PR author '$PRAuthorAgent'." ` -Remediation 'Re-run multi-model triage; the recommended revision owner must differ from the PR author.')) } $plan = Save-ReviewPlan -Consensus $consensus -PRNumber $PRNumber -OutputPath $OutputPath -Agent $Agent -DryRun:$DryRun Post-PRSummaryComment -PRNumber $PRNumber -Repo $Repo -Consensus $consensus -PlanPath $plan.Path -DryRun:$DryRun [PSCustomObject]@{ Status = 'Success' Feedback = $feedback PromptBundle = $promptBundle Consensus = $consensus PlanPath = $plan.Path DryRun = [bool]$DryRun } } catch { $safeError = Remove-Credentials ([string]$_.Exception.Message) $location = '' if ($_.InvocationInfo -and $_.InvocationInfo.PositionMessage) { $location = Remove-Credentials ([string]$_.InvocationInfo.PositionMessage) } Write-Warning "Invoke-PRReviewGate failed: $safeError" if ($location) { Write-Warning $location } [PSCustomObject]@{ Status = 'Failed' Message = $safeError Location = $location DryRun = [bool]$DryRun PlanPath = $null } } } if ($MyInvocation.InvocationName -ne '.') { if ($PRNumber -lt 1) { throw (Format-FindingErrorMessage (New-FindingError -Source 'shared:Invoke-PRReviewGate' ` -Category 'InvalidParameter' ` -Reason 'PRNumber is required when running Invoke-PRReviewGate.ps1 directly.' ` -Remediation 'Pass -PRNumber <int> to the script invocation.')) } $result = Invoke-PRReviewGate ` -PRNumber $PRNumber ` -Repo $Repo ` -OutputPath $OutputPath ` -ModelResponsesPath $ModelResponsesPath ` -Agent $Agent ` -PRAuthorAgent $PRAuthorAgent ` -DryRun:$DryRun if ($result.Status -ne 'Success') { exit 1 } } |