modules/Invoke-CopilotTriage.ps1
|
#Requires -Version 7.0 <# .SYNOPSIS Optional AI triage enrichment using the GitHub Copilot SDK. .DESCRIPTION Checks prerequisites (Python 3.10+, github-copilot-sdk, Copilot-scoped token), then calls the Python triage script. Never throws — returns $null on any failure so the main pipeline continues without AI enrichment. Requires a GitHub Copilot license (Individual, Business, or Enterprise). #> [CmdletBinding()] param( [string] $InputPath = (Join-Path $PSScriptRoot '..' 'output' 'results.json'), [string] $OutputPath = (Join-Path $PSScriptRoot '..' 'output' 'triage.json') ) Set-StrictMode -Version Latest $envelopePath = Join-Path $PSScriptRoot 'shared' 'New-WrapperEnvelope.ps1' if (Test-Path $envelopePath) { . $envelopePath } if (-not (Get-Command New-WrapperEnvelope -ErrorAction SilentlyContinue)) { function New-WrapperEnvelope { param([string]$Source,[string]$Status='Failed',[string]$Message='',[object[]]$FindingErrors=@()) return [PSCustomObject]@{ Source=$Source; SchemaVersion='1.0'; Status=$Status; Message=$Message; Findings=@(); Errors=@($FindingErrors) } } } # Bootstrap Invoke-WithTimeout for CLI timeout protection. # Lazy-load pattern (matches modules/shared/RemoteClone.ps1): we do NOT dot-source # CliTimeout.ps1 at module top, because that would shadow Pester `Mock Invoke-WithTimeout` # calls in tests (the dot-source binds the real implementation into this script's scope, # taking precedence over outer-scope mocks). Instead the real implementation is loaded # just-in-time inside the try block, only when no command of that name is already in scope # (i.e. no test mock active). $script:invokeWithTimeoutIsFallback = $false if (-not (Get-Command Invoke-WithTimeout -ErrorAction SilentlyContinue)) { function Invoke-WithTimeout { param ( [Parameter(Mandatory)][string]$Command, [Parameter(Mandatory)][string[]]$Arguments, [int]$TimeoutSec = 300 ) $output = & $Command @Arguments 2>&1 | Out-String [PSCustomObject]@{ ExitCode = $LASTEXITCODE Output = Remove-Credentials $output } } $script:invokeWithTimeoutIsFallback = $true } $errorsPath = Join-Path $PSScriptRoot 'shared' 'Errors.ps1' if (Test-Path $errorsPath) { . $errorsPath } if (-not (Get-Command New-FindingError -ErrorAction SilentlyContinue)) { function New-FindingError { param([string]$Source,[string]$Category,[string]$Reason,[string]$Remediation,[string]$Details) return [pscustomobject]@{ Source=$Source; Category=$Category; Reason=$Reason; Remediation=$Remediation; Details=$Details; TimestampUtc=(Get-Date).ToUniversalTime().ToString('o') } } } if (-not (Get-Command Format-FindingErrorMessage -ErrorAction SilentlyContinue)) { function Format-FindingErrorMessage { param([Parameter(Mandatory)]$FindingError) $line = "[{0}] {1}: {2}" -f $FindingError.Source, $FindingError.Category, $FindingError.Reason if ($FindingError.Remediation) { $line += " Action: $($FindingError.Remediation)" } return $line } } # --- Check 1: Python 3.10+ --- $py = $null try { $v = & python3 --version 2>&1 if ($LASTEXITCODE -eq 0 -and $v -match 'Python (\d+)\.(\d+)' -and [int]$Matches[1] -ge 3 -and [int]$Matches[2] -ge 10) { $py = 'python3' } } catch { } # best-effort: python3 not on PATH; fall through to 'python' if (-not $py) { try { $v = & python --version 2>&1 if ($LASTEXITCODE -eq 0 -and $v -match 'Python (\d+)\.(\d+)' -and [int]$Matches[1] -ge 3 -and [int]$Matches[2] -ge 10) { $py = 'python' } } catch { } # best-effort: python interpreter unavailable; handled by Write-Warning below } if (-not $py) { Write-Warning 'AI triage requires Python 3.10+. Skipping.' return New-WrapperEnvelope -Source 'copilot-triage' -Status 'Skipped' -Message 'Python 3.10+ not available' } # --- Check 2: github-copilot-sdk installed --- try { & $py -c 'import copilot' 2>&1 | Out-Null if ($LASTEXITCODE -ne 0) { throw } } catch { Write-Warning 'AI triage requires github-copilot-sdk. Install with: pip install github-copilot-sdk. Skipping.' return New-WrapperEnvelope -Source 'copilot-triage' -Status 'Skipped' -Message 'github-copilot-sdk not installed' } # --- Check 3: Copilot token available --- $tk = $env:COPILOT_GITHUB_TOKEN if (-not $tk) { $tk = $env:GH_TOKEN } if (-not $tk) { $tk = $env:GITHUB_TOKEN } if (-not $tk) { Write-Warning "AI triage requires a GitHub Copilot license. Set COPILOT_GITHUB_TOKEN with a PAT that has the 'copilot' scope. Skipping." return New-WrapperEnvelope -Source 'copilot-triage' -Status 'Skipped' -Message 'No Copilot token' } if ($tk.StartsWith('ghs_')) { Write-Warning "AI triage does not support GitHub Actions tokens (ghs_). Use a PAT with the 'copilot' scope. Skipping." return New-WrapperEnvelope -Source 'copilot-triage' -Status 'Skipped' -Message 'GitHub Actions tokens not supported' } # --- Validate inputs --- if (-not (Test-Path $InputPath)) { Write-Warning "AI triage: input file not found — $InputPath. Skipping." return New-WrapperEnvelope -Source 'copilot-triage' -Status 'Skipped' -Message 'Input file not found' } $scriptPath = Join-Path $PSScriptRoot 'Invoke-CopilotTriage.py' if (-not (Test-Path $scriptPath)) { Write-Warning 'AI triage: Python script not found. Skipping.' return New-WrapperEnvelope -Source 'copilot-triage' -Status 'Skipped' -Message 'Python script not found' } # --- Run triage --- Write-Host '' Write-Host ' ⚠ DATA NOTICE: Non-compliant finding data (titles, details, resource IDs)' -ForegroundColor Yellow Write-Host ' will be sent to GitHub Copilot services for AI analysis.' -ForegroundColor Yellow Write-Host '' Write-Host 'Running AI triage enrichment...' -ForegroundColor Magenta try { # Lazy-load real Invoke-WithTimeout (CliTimeout.ps1) only if we have just our own # fallback in scope; never dot-source over an active Pester mock. if ($script:invokeWithTimeoutIsFallback) { $cliTimeoutPath = Join-Path $PSScriptRoot 'shared' 'CliTimeout.ps1' if (Test-Path $cliTimeoutPath) { . $cliTimeoutPath $script:invokeWithTimeoutIsFallback = $false } } $args = @($scriptPath, '--input', $InputPath, '--output', $OutputPath) $result = Invoke-WithTimeout -Command $py -Arguments $args -TimeoutSec 300 if ($result.ExitCode -eq -1) { Write-Warning 'AI triage: Python subprocess timed out after 300s. Skipping.' $err = New-FindingError -Source 'wrapper:copilot-triage' ` -Category 'TimeoutExceeded' ` -Reason 'Python triage subprocess timed out after 300 seconds.' ` -Remediation 'Reduce finding count or check for network issues; re-run with -Verbose for detail.' ` -Details (Remove-Credentials $result.Output) return New-WrapperEnvelope -Source 'copilot-triage' -Status 'Failed' -Message 'Python subprocess timed out' -FindingErrors @($err) } if (-not [string]::IsNullOrWhiteSpace($result.Output)) { $result.Output -split "`n" | ForEach-Object { if (-not [string]::IsNullOrWhiteSpace($_)) { Write-Host " $_" -ForegroundColor DarkGray } } } if ($result.ExitCode -ne 0) { Write-Warning "AI triage: Python script exited with code $($result.ExitCode). Skipping." return New-WrapperEnvelope -Source 'copilot-triage' -Status 'Failed' -Message "Python script exited with non-zero" } if (-not (Test-Path $OutputPath)) { Write-Warning 'AI triage: triage.json was not created. Skipping.' return New-WrapperEnvelope -Source 'copilot-triage' -Status 'Failed' -Message 'triage.json not created' } $triage = Get-Content $OutputPath -Raw | ConvertFrom-Json -ErrorAction Stop Write-Host "AI triage complete — enriched findings written to $OutputPath" -ForegroundColor Green return $triage } catch { # Keep PR-author-visible Message generic to avoid leaking exception text # (paths, resource IDs, partial command lines). Sanitize and write the full # detail to the warning stream / Errors envelope where it belongs. $details = Remove-Credentials ([string]$_) Write-Warning "AI triage: unexpected error. Skipping. $details" $err = [PSCustomObject]@{ Source = 'wrapper:copilot-triage' Category = 'UnexpectedFailure' Reason = 'Unexpected error during AI triage subprocess' Remediation = 'Re-run with -Verbose; inspect Python script logs for the underlying cause.' Details = $details TimestampUtc = (Get-Date).ToUniversalTime().ToString('o') } return New-WrapperEnvelope -Source 'copilot-triage' -Status 'Failed' -Message 'Unexpected error during AI triage' -FindingErrors @($err) } |