modules/shared/Invoke-PRAdvisoryGate.ps1
|
#Requires -Version 7.4 <# .SYNOPSIS Universal advisory review gate for squad-authored PRs (#109). .DESCRIPTION Fires on every squad PR open/ready/synchronize event. Builds a 3-model triage prompt bundle in `.squad/decisions/inbox/` (reusing the patterns from `Invoke-PRReviewGate.ps1`), then posts or updates a single advisory comment on the PR under the `<!-- squad-advisory -->` marker. This is NON-BLOCKING. The gate posts findings tagged per the #108 severity taxonomy. Untagged findings are auto-tagged `[correctness]` (fail-safe). Merge is never blocked by this gate, the human / Copilot reviewer has the final say. Disable repo-wide via the `SQUAD_ADVISORY_GATE=0` repo variable. .PARAMETER PRNumber The PR number to advise on. .PARAMETER Repo The owner/name slug, e.g. `martinopedal/azure-analyzer`. .PARAMETER PRAuthor Author login from the triggering event. Used by the squad-author filter. .PARAMETER OutputPath Where to write the triage prompt bundle. Defaults to `.squad/decisions/inbox/`. .PARAMETER Enabled Master switch. When `$false`, exits early with a no-op (used by the workflow when `SQUAD_ADVISORY_GATE=0`). .PARAMETER DryRun Skip filesystem writes and `gh` calls. Returns the would-be payload. #> [CmdletBinding()] param( [ValidateRange(0, [int]::MaxValue)] [int] $PRNumber = 0, [ValidateNotNullOrEmpty()] [string] $Repo = 'martinopedal/azure-analyzer', [string] $PRAuthor = $env:PR_AUTHOR, [ValidateNotNullOrEmpty()] [string] $OutputPath = '.squad/decisions/inbox/', [string] $HeadSha = $env:PR_HEAD_SHA, [string] $CopilotTriagePlanPath = '', [bool] $Enabled = $true, [switch] $DryRun ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' . (Join-Path $PSScriptRoot 'Sanitize.ps1') . (Join-Path $PSScriptRoot 'RubberDuckChain.ps1') $errorsPath = Join-Path $PSScriptRoot 'Errors.ps1' if (Test-Path $errorsPath) { . $errorsPath } # Marker used to find and update the single advisory comment in place. $script:AdvisoryMarker = '<!-- squad-advisory -->' function Import-CopilotTriagePlan { [CmdletBinding()] [OutputType([pscustomobject])] param( [string] $PlanPath = '' ) if ([string]::IsNullOrWhiteSpace($PlanPath)) { return [pscustomobject]@{ PlanHash = 'no-copilot-findings' Items = @() Summary = [pscustomobject]@{ TotalFindings = 0 CategoryCounts = [pscustomobject]@{ blocker = 0; correctness = 0; security = 0; style = 0; nit = 0 } CopilotThreadStates = @() UnaddressedCopilotThreads = @() AllCopilotThreadsAddressed = $true } } } if (-not (Test-Path -LiteralPath $PlanPath)) { throw (Format-FindingErrorMessage (New-FindingError -Source 'shared:Invoke-PRAdvisoryGate' ` -Category 'NotFound' ` -Reason "Copilot triage plan file not found: $PlanPath" ` -Remediation 'Pass -CopilotTriagePlanPath to an existing plan JSON, or omit it to skip Copilot triage.')) } $raw = Get-Content -LiteralPath $PlanPath -Raw -Encoding utf8 if ([string]::IsNullOrWhiteSpace($raw)) { throw (Format-FindingErrorMessage (New-FindingError -Source 'shared:Invoke-PRAdvisoryGate' ` -Category 'ConfigurationError' ` -Reason "Copilot triage plan file is empty: $PlanPath" ` -Remediation 'Regenerate the triage plan with the Copilot triage workflow before invoking the advisory gate.')) } $plan = $raw | ConvertFrom-Json -Depth 30 if (-not $plan.PSObject.Properties['PlanHash'] -or [string]::IsNullOrWhiteSpace([string]$plan.PlanHash)) { $plan | Add-Member -NotePropertyName PlanHash -NotePropertyValue 'no-copilot-findings' -Force } if (-not $plan.PSObject.Properties['Items']) { $plan | Add-Member -NotePropertyName Items -NotePropertyValue @() -Force } if (-not $plan.PSObject.Properties['Summary']) { $plan | Add-Member -NotePropertyName Summary -NotePropertyValue ([pscustomobject]@{}) -Force } if (-not $plan.Summary.PSObject.Properties['AllCopilotThreadsAddressed']) { $plan.Summary | Add-Member -NotePropertyName AllCopilotThreadsAddressed -NotePropertyValue $true -Force } if (-not $plan.Summary.PSObject.Properties['UnaddressedCopilotThreads']) { $plan.Summary | Add-Member -NotePropertyName UnaddressedCopilotThreads -NotePropertyValue @() -Force } $plan } function Format-CopilotFindingsSection { [CmdletBinding()] [OutputType([string])] param( [AllowNull()] [object] $CopilotTriagePlan = $null ) if ($null -eq $CopilotTriagePlan -or -not $CopilotTriagePlan.PSObject.Properties['Items']) { return @" ## Copilot review findings - none "@ } $lines = [System.Collections.Generic.List[string]]::new() [void]$lines.Add('## Copilot review findings') [void]$lines.Add("Plan hash: $([string]$CopilotTriagePlan.PlanHash)") $items = @($CopilotTriagePlan.Items) if ($items.Count -eq 0) { [void]$lines.Add('- none') } else { foreach ($group in $items) { $category = [string]$group.Category [void]$lines.Add("- [$category] count=$([int]$group.Count)") foreach ($f in @($group.Findings)) { $path = [string]$f.Path $line = if ($null -eq $f.Line) { '?' } else { [string]$f.Line } $body = [string]$f.Body if ($body.Length -gt 260) { $body = $body.Substring(0, 260) + '...' } $body = $body -replace '\r?\n', ' ' [void]$lines.Add(" - ${path}:$line :: $body") } } } if ($CopilotTriagePlan.PSObject.Properties['Summary'] -and $CopilotTriagePlan.Summary) { $summary = $CopilotTriagePlan.Summary $allAddressed = [bool]$summary.AllCopilotThreadsAddressed [void]$lines.Add('') [void]$lines.Add("AllCopilotThreadsAddressed: $allAddressed") $unaddressed = @($summary.UnaddressedCopilotThreads) if ($unaddressed.Count -gt 0) { [void]$lines.Add('Unaddressed threads:') foreach ($t in $unaddressed) { [void]$lines.Add("- $([string]$t.ThreadId) (category=$([string]$t.Category))") } } } $lines -join "`n" } <# Squad-author heuristic ---------------------- A PR is "squad-authored" when ANY of these hold: 1. The login matches the squad-agent bot pattern `*-swe-agent[bot]` (for example `copilot-swe-agent[bot]`). 2. The login matches one of the squad agent identities (forge, atlas, iris, sage, sentinel, lead, scribe). 3. The login is listed in the comma-separated `SQUAD_AGENT_LOGINS` env var (escape hatch for repo-specific identities). Human PRs are skipped, the advisory gate is for AI-authored work only. #> function Test-SquadAuthor { [CmdletBinding()] [OutputType([bool])] param( [Parameter(Mandatory)] [AllowEmptyString()] [string] $Login ) if ([string]::IsNullOrWhiteSpace($Login)) { return $false } $normalized = $Login.Trim().ToLowerInvariant() $excludedAutomationBots = @( 'dependabot[bot]', 'renovate[bot]', 'github-actions[bot]' ) if ($excludedAutomationBots -contains $normalized) { return $false } if ($normalized -match '^[a-z0-9-]+-swe-agent\[bot\]$') { return $true } $builtIn = @( 'forge', 'atlas', 'iris', 'sage', 'sentinel', 'lead', 'scribe' ) if ($builtIn -contains $normalized) { return $true } $extra = $env:SQUAD_AGENT_LOGINS if (-not [string]::IsNullOrWhiteSpace($extra)) { foreach ($candidate in $extra.Split(',')) { $trimmed = $candidate.Trim().ToLowerInvariant() if (-not [string]::IsNullOrWhiteSpace($trimmed) -and $trimmed -eq $normalized) { return $true } } } return $false } function Test-SkipAdvisoryLabel { [CmdletBinding()] [OutputType([bool])] param( [Parameter(Mandatory)] [int] $PRNumber, [Parameter(Mandatory)] [string] $Repo ) $labels = [System.Collections.Generic.List[string]]::new() if (-not [string]::IsNullOrWhiteSpace($env:PR_LABELS)) { foreach ($name in ($env:PR_LABELS -split '[,\r\n]')) { $trimmed = [string]$name if (-not [string]::IsNullOrWhiteSpace($trimmed)) { [void]$labels.Add($trimmed.Trim()) } } } else { # `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 $rawLabels = & gh pr view $PRNumber --repo $Repo --json labels -q '.labels[].name' 2>$null if ($LASTEXITCODE -eq 0 -and -not [string]::IsNullOrWhiteSpace([string]$rawLabels)) { foreach ($name in ([string]$rawLabels -split '[\r\n]')) { $trimmed = [string]$name if (-not [string]::IsNullOrWhiteSpace($trimmed)) { [void]$labels.Add($trimmed.Trim()) } } } } foreach ($label in $labels) { if ($label.ToLowerInvariant() -eq 'skip-advisory') { return $true } } return $false } <# Severity tag enforcement (#108 contract). Untagged findings -> `[correctness]` (fail-safe). Tagged findings are returned as-is, with the tag normalized to lowercase so downstream regex matches stay simple. #> function Add-SeverityTag { [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [AllowEmptyString()] [string] $Finding ) if ([string]::IsNullOrWhiteSpace($Finding)) { return '[correctness] (empty finding)' } $trimmed = $Finding.TrimStart() $tagPattern = '^\[(blocker|correctness|security|style|nit)\]' if ($trimmed -match $tagPattern) { # Normalize tag casing while preserving the rest verbatim. $tag = $Matches[1].ToLowerInvariant() $rest = $trimmed.Substring($Matches[0].Length).TrimStart() return "[$tag] $rest" } return "[correctness] $trimmed" } <# Build the markdown body for the advisory comment. Idempotent so the workflow can update the same comment in place on each synchronize. #> function Format-AdvisoryComment { [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [int] $PRNumber, [string[]] $Findings = @(), [ValidateSet('clean', 'concerns', 'blockers')] [string] $Verdict = 'clean', [string] $HeadSha = '', [int] $Approves = 0, [int] $TotalModels = 0 ) $tagged = @() foreach ($f in @($Findings)) { $tagged += Add-SeverityTag -Finding $f } $hasVeto = $tagged | Where-Object { $_ -match '^\[(blocker|correctness|security)\]' } if ($hasVeto) { $Verdict = 'blockers' } elseif ($tagged.Count -gt 0 -and $Verdict -eq 'clean') { $Verdict = 'concerns' } $emoji = switch ($Verdict) { 'clean' { '[OK]' } 'concerns' { '[!]' } 'blockers' { '[X]' } } $lines = [System.Collections.Generic.List[string]]::new() [void]$lines.Add($script:AdvisoryMarker) if (-not [string]::IsNullOrWhiteSpace($HeadSha)) { [void]$lines.Add("<!-- head-sha: $HeadSha -->") } [void]$lines.Add('## Advisory review (3-model consensus)') [void]$lines.Add('') [void]$lines.Add("**Verdict:** $emoji $Verdict") if ($TotalModels -gt 0) { [void]$lines.Add("**Models APPROVE:** $Approves / $TotalModels") } if (-not [string]::IsNullOrWhiteSpace($HeadSha)) { $shortSha = if ($HeadSha.Length -ge 7) { $HeadSha.Substring(0, 7) } else { $HeadSha } [void]$lines.Add("**Head SHA:** ``$shortSha``") } [void]$lines.Add('') [void]$lines.Add('### Findings') if ($tagged.Count -eq 0) { [void]$lines.Add('- None. Triage bundle queued for the 3-model gate.') } else { foreach ($t in $tagged) { [void]$lines.Add("- $t") } } [void]$lines.Add('') [void]$lines.Add('> Advisory only. Does not block merge. Human / Copilot reviewer has final say.') [void]$lines.Add("> Severity tags follow the #108 taxonomy. Untagged findings are auto-tagged ``[correctness]`` (fail-safe).") [void]$lines.Add('') [void]$lines.Add("_PR #$PRNumber, generated by ``pr-advisory-gate.yml`` (#109)._") return ($lines -join "`n") } <# Locate an existing advisory comment by marker. Returns the comment id or $null when none exists. Pure wrapper around `gh api` paginated comments fetch, kept thin so the integration path stays simple. #> function Get-AdvisoryCommentId { [CmdletBinding()] [OutputType([nullable[long]])] param( [Parameter(Mandatory)] [int] $PRNumber, [Parameter(Mandatory)] [string] $Repo ) $endpoint = "repos/$Repo/issues/$PRNumber/comments" # See Test-IsSkipAdvisory: reset LASTEXITCODE so function-mocked `gh` # in Pester does not inherit a stale non-zero from a prior native call. $global:LASTEXITCODE = 0 $raw = & gh api $endpoint --paginate --slurp 2>$null if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace([string]$raw)) { return $null } $parsed = $raw | ConvertFrom-Json -ErrorAction Stop $pages = @($parsed) $comments = [System.Collections.Generic.List[object]]::new() foreach ($page in $pages) { if ($null -eq $page) { continue } if ($page -is [System.Collections.IEnumerable] -and -not ($page -is [string])) { foreach ($comment in @($page)) { [void]$comments.Add($comment) } continue } [void]$comments.Add($page) } $latestMatchId = $null foreach ($c in $comments) { $body = [string]$c.body if ($body -and $body.Contains($script:AdvisoryMarker)) { $id = [long]$c.id if ($null -eq $latestMatchId -or $id -gt $latestMatchId) { $latestMatchId = $id } } } return $latestMatchId } <# Post or update the advisory comment idempotently. #> function Publish-AdvisoryComment { [CmdletBinding()] param( [Parameter(Mandatory)] [int] $PRNumber, [Parameter(Mandatory)] [string] $Repo, [Parameter(Mandatory)] [string] $Body, [switch] $DryRun ) $safeBody = Remove-Credentials $Body if ($DryRun) { Write-Verbose "DryRun: would publish advisory comment on PR #$PRNumber ($($safeBody.Length) chars)." return $safeBody } $bodyFile = Join-Path ([System.IO.Path]::GetTempPath()) "advisory-$PRNumber-$([guid]::NewGuid().ToString('N')).md" Set-Content -Path $bodyFile -Value $safeBody -Encoding utf8 try { $existingId = Get-AdvisoryCommentId -PRNumber $PRNumber -Repo $Repo # See Test-IsSkipAdvisory: 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) { $endpoint = "repos/$Repo/issues/comments/$existingId" & gh api -X PATCH $endpoint -F "body=@$bodyFile" 1>$null } else { $endpoint = "repos/$Repo/issues/$PRNumber/comments" & gh api -X POST $endpoint -F "body=@$bodyFile" 1>$null } if ($LASTEXITCODE -ne 0) { throw (Format-FindingErrorMessage (New-FindingError -Source 'shared:Invoke-PRAdvisoryGate' ` -Category 'UnexpectedFailure' ` -Reason "gh api failed with exit code $LASTEXITCODE while publishing advisory comment." ` -Remediation 'Verify the GH_TOKEN scope includes pull-requests:write and that the PR exists.' ` -Details "endpoint=$endpoint")) } } finally { Remove-Item -Path $bodyFile -ErrorAction SilentlyContinue } return $safeBody } # --- Frontier rubber-duck roster + per-model invocation ------------------- # Strict allow-list. See `.copilot/copilot-instructions.md` -> "Frontier # Model Roster". DO NOT add opus-4.6, opus-4.5, sonnet-anything, # haiku-anything, mini-anything, or gpt-4.1 here. function Get-FrontierModelRoster { [CmdletBinding()] [OutputType([string[]])] param() @( 'claude-opus-4.7', 'gpt-5.3-codex', 'goldeneye' ) } <# Per-model rubber-duck invocation. TODO(#157 follow-up): swap the deterministic stub for a real provider call (GitHub Models REST or `gh copilot suggest`). Today we ship the gate scaffolding -- prompt persistence, roster, verdict aggregation, commit-status posting -- and stub the model verdict to APPROVE / no findings so the workflow is exercised end-to-end on every push. The prompt bundle written to `.squad/decisions/inbox/` is real, so the follow-up only needs to flip the inner call. #> function Invoke-RubberDuckModel { [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)] [string] $ModelName, [Parameter(Mandatory)] [string] $Prompt, [Parameter(Mandatory)] [int] $PRNumber, [Parameter(Mandatory)] [string] $HeadSha, [Parameter(Mandatory)] [string] $OutputPath, [string] $PlanHash = 'no-copilot-findings', [switch] $DryRun ) $safeModel = $ModelName -replace '[^A-Za-z0-9._-]', '-' $safeSha = if ([string]::IsNullOrWhiteSpace($HeadSha)) { 'no-sha' } else { ($HeadSha -replace '[^A-Za-z0-9]', '').Substring(0, [math]::Min(12, $HeadSha.Length)) } $safePlan = if ([string]::IsNullOrWhiteSpace($PlanHash)) { 'no-plan' } else { ($PlanHash -replace '[^A-Za-z0-9]', '').Substring(0, [math]::Min(16, $PlanHash.Length)) } $promptFile = Join-Path $OutputPath "$PRNumber-$safeSha-$safePlan-$safeModel.md" $responseFile = Join-Path $OutputPath "$PRNumber-$safeSha-$safePlan-$safeModel.response.json" if (-not $DryRun -and (Test-Path -LiteralPath $responseFile)) { $cachedRaw = Get-Content -LiteralPath $responseFile -Raw -Encoding utf8 if (-not [string]::IsNullOrWhiteSpace($cachedRaw)) { $cached = $cachedRaw | ConvertFrom-Json -Depth 20 return [pscustomobject]@{ Model = [string]$cached.Model Verdict = [string]$cached.Verdict Findings = @($cached.Findings) Stub = [bool]$cached.Stub Cached = $true } } } if (-not $DryRun) { New-Item -Path $OutputPath -ItemType Directory -Force | Out-Null $safePrompt = Remove-Credentials $Prompt Set-Content -Path $promptFile -Value $safePrompt -Encoding utf8 } $response = [pscustomobject]@{ Model = $ModelName Verdict = 'APPROVE' Findings = @() Stub = $true Cached = $false } if (-not $DryRun) { $safeResponse = Remove-Credentials ($response | ConvertTo-Json -Depth 10) Set-Content -Path $responseFile -Value $safeResponse -Encoding utf8 } return $response } <# Build the diff bundle, fan out across the frontier roster via the retry+swap chain, return raw per-model responses plus chain outcome metadata. Each run is keyed to the head SHA so re-runs on synchronize start from scratch. Returned object shape: @{ Outcome = 'Success' | 'ChainExhausted' | 'SwapLimitExceeded' Responses = pscustomobject[] # per-model { Verdict, Findings } Swaps = int } #> function Invoke-AdvisoryRubberDuck { [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)] [int] $PRNumber, [Parameter(Mandatory)] [string] $Repo, [Parameter(Mandatory)] [string] $HeadSha, [Parameter(Mandatory)] [string] $OutputPath, [AllowNull()] [object] $CopilotTriagePlan = $null, [scriptblock] $CallInvoker, [scriptblock] $Sleep = { param($s) Start-Sleep -Seconds $s }, [switch] $DryRun ) $diff = '' if (-not $DryRun) { try { # See Test-IsSkipAdvisory: reset LASTEXITCODE so function-mocked `gh` # in Pester does not inherit a stale non-zero from a prior native call. $global:LASTEXITCODE = 0 $rawDiff = & gh pr diff $PRNumber --repo $Repo 2>$null if ($LASTEXITCODE -eq 0 -and -not [string]::IsNullOrWhiteSpace([string]$rawDiff)) { $diff = [string]$rawDiff } } catch { $diff = '' } } if ([string]::IsNullOrWhiteSpace($diff)) { $diff = '(diff unavailable)' } $diff = Remove-Credentials $diff if ($diff.Length -gt 60000) { $diff = $diff.Substring(0, 60000) + "`n... (truncated)" } $context = @{ Diff = $diff PRNumber = $PRNumber HeadSha = $HeadSha OutputPath = $OutputPath DryRun = [bool]$DryRun PlanHash = if ($CopilotTriagePlan) { [string]$CopilotTriagePlan.PlanHash } else { 'no-copilot-findings' } CopilotSection = (Format-CopilotFindingsSection -CopilotTriagePlan $CopilotTriagePlan) } if ($null -eq $CallInvoker) { $CallInvoker = { param($model, $ctx) $prompt = @" You are model '$model' in the rubber-duck PR review gate for PR #$($ctx.PRNumber) @ $($ctx.HeadSha). Tag every finding with one of: [blocker] [correctness] [security] [style] [nit]. Untagged findings are auto-tagged [correctness] (fail-safe). Return strict JSON: { "verdict": "APPROVE" | "REQUEST_CHANGES", "findings": ["[tag] short message", ...] } DIFF: $($ctx.Diff) $($ctx.CopilotSection) "@ return Invoke-RubberDuckModel ` -ModelName $model ` -Prompt $prompt ` -PRNumber $ctx.PRNumber ` -HeadSha $ctx.HeadSha ` -OutputPath $ctx.OutputPath ` -PlanHash $ctx.PlanHash ` -DryRun:$ctx.DryRun } } $chainResult = Invoke-RubberDuckTrio ` -PRNumber $PRNumber ` -HeadSha $HeadSha ` -CallContext $context ` -CallInvoker $CallInvoker ` -OutputPath $OutputPath ` -Sleep $Sleep ` -DryRun:$DryRun $responses = @() foreach ($v in @($chainResult.Verdicts)) { if ($null -eq $v -or $null -eq $v.Response) { continue } $responses += $v.Response } [pscustomobject]@{ Outcome = $chainResult.Outcome Responses = @($responses) Swaps = $chainResult.Swaps } } <# Apply the Gate-pass criteria from `.copilot/copilot-instructions.md` -> "Review Severity Taxonomy" -> "Gate-pass criteria": Pass when ALL hold: 1. Zero [blocker] / [correctness] findings across all responses. 2. At least 2 of N models returned APPROVE. The aggregate verdict is `blockers` when any veto-class finding lands, `concerns` when only [style] / [nit] findings exist, otherwise `clean`. #> function Resolve-RubberDuckVerdict { [CmdletBinding()] [OutputType([pscustomobject])] param( [object[]] $Responses = @(), [bool] $AllCopilotThreadsAddressed = $true ) $arr = @($Responses | Where-Object { $_ }) $approves = @($arr | Where-Object { [string]$_.Verdict -eq 'APPROVE' }).Count $totalModels = $arr.Count $tagged = [System.Collections.Generic.List[string]]::new() foreach ($r in $arr) { foreach ($f in @($r.Findings)) { $line = Add-SeverityTag -Finding ([string]$f) [void]$tagged.Add($line) } } $hasVeto = @($tagged | Where-Object { $_ -match '^\[(blocker|correctness|security)\]' }).Count -gt 0 $verdict = 'clean' if ($hasVeto) { $verdict = 'blockers' } elseif ($tagged.Count -gt 0) { $verdict = 'concerns' } $passed = ($approves -ge 2) -and (-not $hasVeto) -and $AllCopilotThreadsAddressed [pscustomobject]@{ Passed = $passed Approves = $approves TotalModels = $totalModels Findings = @($tagged) Verdict = $verdict AllCopilotThreadsAddressed = $AllCopilotThreadsAddressed } } # --- Main entrypoint guard --- # Tests dot-source this file to exercise the pure functions. Skip the main # block in that case by checking whether we were invoked as a script. if ($MyInvocation.InvocationName -ne '.' -and $MyInvocation.MyCommand.Path -eq $PSCommandPath) { # Emit a default gate-state on every early-return path so the workflow's # `Post rubberduck-gate commit status` step always has a verdict to post # against the PR head SHA. Branch protection requires the status context # to exist on every PR (#173). Skipped runs are non-failures -> success. function script:Write-SkipGateOutput { param([string] $Reason) Write-Host "rubberduck-gate state: success (skipped: $Reason)" if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_OUTPUT)) { Add-Content -Path $env:GITHUB_OUTPUT -Value 'gate-state=success' Add-Content -Path $env:GITHUB_OUTPUT -Value "head-sha=$HeadSha" Add-Content -Path $env:GITHUB_OUTPUT -Value "skip-reason=$Reason" } } if (-not $Enabled) { Write-Host 'Advisory gate disabled (SQUAD_ADVISORY_GATE=0). Skipping.' Write-SkipGateOutput -Reason 'disabled' return } if ($PRNumber -le 0) { throw (Format-FindingErrorMessage (New-FindingError -Source 'shared:Invoke-PRAdvisoryGate' ` -Category 'InvalidParameter' ` -Reason 'PRNumber must be a positive integer.' ` -Remediation 'Pass -PRNumber <int> when invoking the advisory gate.')) } if (-not (Test-SquadAuthor -Login $PRAuthor)) { Write-Host "PR author '$PRAuthor' is not a squad agent / bot. Skipping advisory gate." Write-SkipGateOutput -Reason 'non-squad-author' return } if (Test-SkipAdvisoryLabel -PRNumber $PRNumber -Repo $Repo) { Write-SkipGateOutput -Reason 'skip-advisory-label' return } Write-Host "Squad-authored PR #$PRNumber detected (author: $PRAuthor). Building advisory triage bundle..." if (-not $DryRun) { New-Item -Path $OutputPath -ItemType Directory -Force | Out-Null } if ([string]::IsNullOrWhiteSpace($HeadSha)) { try { # See Test-IsSkipAdvisory: reset LASTEXITCODE so function-mocked `gh` # in Pester does not inherit a stale non-zero from a prior native call. $global:LASTEXITCODE = 0 $resolved = & gh pr view $PRNumber --repo $Repo --json headRefOid -q '.headRefOid' 2>$null if ($LASTEXITCODE -eq 0 -and -not [string]::IsNullOrWhiteSpace([string]$resolved)) { $HeadSha = ([string]$resolved).Trim() } } catch { $HeadSha = '' } } $copilotTriagePlan = Import-CopilotTriagePlan -PlanPath $CopilotTriagePlanPath $advisory = Invoke-AdvisoryRubberDuck ` -PRNumber $PRNumber ` -Repo $Repo ` -HeadSha $HeadSha ` -OutputPath $OutputPath ` -CopilotTriagePlan $copilotTriagePlan ` -DryRun:$DryRun if ($advisory.Outcome -in 'ChainExhausted', 'SwapLimitExceeded') { # Frontier model chain exhaustion / swap-budget exhaustion is an # infrastructure failure (provider outage, rate-limit, timeout), not # a verdict. The workflow's contract (see pr-advisory-gate.yml near # the rubberduck-gate status step) is explicit: treat infra failures # as non-blocking and surface the underlying error via the workflow # run. Emit gate-state=success + skip-reason so the commit status is # green, the sticky advisory comment still posts for human context, # and exit 0 so the advisory-gate check does not red-mark the PR. $stickyBody = Format-ChainExhaustedComment ` -PRNumber $PRNumber ` -HeadSha $HeadSha ` -Swaps $advisory.Swaps Publish-AdvisoryComment -PRNumber $PRNumber -Repo $Repo -Body $stickyBody -DryRun:$DryRun | Out-Null Write-Host "rubberduck-gate state: success (non-blocking infra: chain $($advisory.Outcome) after $($advisory.Swaps) swap(s))" if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_OUTPUT)) { Add-Content -Path $env:GITHUB_OUTPUT -Value 'gate-state=success' Add-Content -Path $env:GITHUB_OUTPUT -Value "head-sha=$HeadSha" Add-Content -Path $env:GITHUB_OUTPUT -Value "chain-outcome=$($advisory.Outcome)" Add-Content -Path $env:GITHUB_OUTPUT -Value "skip-reason=infra: frontier chain $($advisory.Outcome) after $($advisory.Swaps) swap(s)" } exit 0 } $resolution = Resolve-RubberDuckVerdict ` -Responses $advisory.Responses ` -AllCopilotThreadsAddressed ([bool]$copilotTriagePlan.Summary.AllCopilotThreadsAddressed) $copilotGateNotes = [System.Collections.Generic.List[string]]::new() foreach ($thread in @($copilotTriagePlan.Summary.UnaddressedCopilotThreads)) { $threadId = [string]$thread.ThreadId $category = [string]$thread.Category [void]$copilotGateNotes.Add("[correctness] Copilot thread unaddressed: $threadId (category=$category)") } $combinedFindings = @($resolution.Findings + $copilotGateNotes) $body = Format-AdvisoryComment ` -PRNumber $PRNumber ` -Findings $combinedFindings ` -Verdict $resolution.Verdict ` -HeadSha $HeadSha ` -Approves $resolution.Approves ` -TotalModels $resolution.TotalModels Publish-AdvisoryComment -PRNumber $PRNumber -Repo $Repo -Body $body -DryRun:$DryRun | Out-Null # Surface gate result to the workflow so it can post the # `rubberduck-gate` commit status against the head SHA. $gateState = if ($resolution.Passed) { 'success' } else { 'failure' } Write-Host "rubberduck-gate state: $gateState (approves=$($resolution.Approves)/$($resolution.TotalModels), verdict=$($resolution.Verdict))" if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_OUTPUT)) { Add-Content -Path $env:GITHUB_OUTPUT -Value "gate-state=$gateState" Add-Content -Path $env:GITHUB_OUTPUT -Value "head-sha=$HeadSha" Add-Content -Path $env:GITHUB_OUTPUT -Value "plan-hash=$([string]$copilotTriagePlan.PlanHash)" $allAddressedText = ([string]$resolution.AllCopilotThreadsAddressed).ToLowerInvariant() Add-Content -Path $env:GITHUB_OUTPUT -Value "all-copilot-threads-addressed=$allAddressedText" $unaddressedJson = Remove-Credentials ((@($copilotTriagePlan.Summary.UnaddressedCopilotThreads) | ConvertTo-Json -Depth 10 -Compress)) Add-Content -Path $env:GITHUB_OUTPUT -Value "unaddressed-copilot-threads=$unaddressedJson" } Write-Host "Advisory comment published / updated on PR #$PRNumber." } |