diff --git a/.github/docs/agent-labels.md b/.github/docs/agent-labels.md
index 2a43521e4c14..d256bfc55425 100644
--- a/.github/docs/agent-labels.md
+++ b/.github/docs/agent-labels.md
@@ -62,9 +62,8 @@ Review-PR.ps1
│ ├── Validate → writes content.md
│ ├── Fix → writes content.md
│ └── Report → writes content.md
-├── Phase 2: PR Finalize (optional)
-├── Phase 3: Post Comments (optional)
-└── Phase 4: Apply Labels ← labels are applied here
+├── Phase 2: Post Comments (optional)
+└── Phase 3: Apply Labels ← labels are applied here
├── Parse content.md files
├── Determine outcome + signal labels
├── Apply via GitHub REST API
diff --git a/.github/scripts/Aggregate-CopilotTokenUsage.Tests.ps1 b/.github/scripts/Aggregate-CopilotTokenUsage.Tests.ps1
new file mode 100644
index 000000000000..9baf64b83113
--- /dev/null
+++ b/.github/scripts/Aggregate-CopilotTokenUsage.Tests.ps1
@@ -0,0 +1,90 @@
+#!/usr/bin/env pwsh
+#Requires -Modules Pester
+<#
+.SYNOPSIS
+ Pester tests for Aggregate-CopilotTokenUsage.ps1.
+#>
+
+Describe 'Aggregate-CopilotTokenUsage.ps1' {
+ BeforeEach {
+ $script:fixtureRoot = Join-Path ([System.IO.Path]::GetTempPath()) "token-usage-fixtures-$([guid]::NewGuid())"
+ $script:inputRoot = Join-Path $script:fixtureRoot 'input'
+ $script:outputRoot = Join-Path $script:fixtureRoot 'output'
+ New-Item -ItemType Directory -Path $script:inputRoot -Force | Out-Null
+ }
+
+ AfterEach {
+ Remove-Item -Path $script:fixtureRoot -Recurse -Force -ErrorAction SilentlyContinue
+ }
+
+ It 'writes raw and summarized artifacts with zero rows for stages without Copilot invocations' {
+ $nested = Join-Path $script:inputRoot 'CopilotLogs/copilot-token-usage/raw'
+ New-Item -ItemType Directory -Path $nested -Force | Out-Null
+
+ [ordered]@{
+ schemaVersion = 1
+ prNumber = 35677
+ pipeline = [ordered]@{ stageName = 'ReviewPR' }
+ scriptPhase = 'CopilotReview'
+ copilotStep = 'STEP 5a: TRY-FIX'
+ model = 'gpt-5.5'
+ durationMs = 5000
+ apiDurationMs = 2000
+ turnCount = 2
+ toolCount = 3
+ cliUsage = [ordered]@{
+ aicUsed = 7.5
+ contextWindow = 1100000
+ contextWindowRaw = '1.1M'
+ }
+ normalizedTokens = [ordered]@{
+ inputTokens = 100
+ outputTokens = 40
+ cachedInputTokens = 10
+ totalTokens = 140
+ }
+ } | ConvertTo-Json -Depth 10 | Set-Content (Join-Path $nested 'copilot-token-usage-a.json') -Encoding UTF8
+
+ $scriptPath = Join-Path $PSScriptRoot 'shared/Aggregate-CopilotTokenUsage.ps1'
+ & $scriptPath `
+ -InputRoot $script:inputRoot `
+ -OutputDir $script:outputRoot `
+ -PRNumber '35677' `
+ -ExpectedStages @('ReviewPR', 'RunDeepUITests', 'UpdateAISummaryComment', 'AnalyzeCopilotTokenUsage')
+
+ Test-Path (Join-Path $script:outputRoot 'token-usage-raw.jsonl') | Should -Be $true
+ Test-Path (Join-Path $script:outputRoot 'token-usage-summary.md') | Should -Be $true
+ Test-Path (Join-Path $script:outputRoot 'token-usage-by-step.csv') | Should -Be $true
+
+ $summary = Get-Content (Join-Path $script:outputRoot 'token-usage-summary.json') -Raw | ConvertFrom-Json
+ $summary.recordCount | Should -Be 1
+ $summary.totals.inputTokens | Should -Be 100
+ $summary.totals.outputTokens | Should -Be 40
+ $summary.totals.totalTokens | Should -Be 140
+ $summary.totals.aicUsed | Should -Be 7.5
+
+ $reviewStage = $summary.stages | Where-Object { $_.stageName -eq 'ReviewPR' }
+ $reviewStage.invocationCount | Should -Be 1
+ $reviewStage.totalTokens | Should -Be 140
+ $reviewStage.aicUsed | Should -Be 7.5
+
+ $deepStage = $summary.stages | Where-Object { $_.stageName -eq 'RunDeepUITests' }
+ $deepStage.invocationCount | Should -Be 0
+ $deepStage.totalTokens | Should -Be 0
+ $deepStage.aicUsed | Should -Be 0
+ $deepStage.note | Should -Be 'No Copilot invocation observed in this stage.'
+ }
+
+ It 'emits a no-record summary when the input artifact is missing' {
+ $scriptPath = Join-Path $PSScriptRoot 'shared/Aggregate-CopilotTokenUsage.ps1'
+ & $scriptPath `
+ -InputRoot (Join-Path $script:fixtureRoot 'missing') `
+ -OutputDir $script:outputRoot `
+ -PRNumber '35677'
+
+ $summary = Get-Content (Join-Path $script:outputRoot 'token-usage-summary.json') -Raw | ConvertFrom-Json
+ $summary.recordCount | Should -Be 0
+ ($summary.stages | Where-Object { $_.stageName -eq 'ReviewPR' }).invocationCount | Should -Be 0
+ Test-Path (Join-Path $script:outputRoot 'token-usage-by-step.csv') | Should -Be $true
+ }
+}
diff --git a/.github/scripts/Find-RegressionRisks.ps1 b/.github/scripts/Find-RegressionRisks.ps1
index eae088686e68..efa5da467bac 100644
--- a/.github/scripts/Find-RegressionRisks.ps1
+++ b/.github/scripts/Find-RegressionRisks.ps1
@@ -14,7 +14,7 @@
added → 🔴 REVERT. Same file but no line match → 🟡 OVERLAP. Otherwise → 🟢 CLEAN.
Outputs (when -OutputDir is provided):
- - content.md Markdown summary suitable for the wall-of-text PR comment.
+ - content.md Markdown summary suitable for the wall-of-text PR review.
- risks.json Structured findings for downstream agents.
- result.txt One token: CLEAN | OVERLAP | REVERT (used by Review-PR.ps1
for branching).
@@ -726,7 +726,7 @@ if ($OutputDir) {
} | ConvertTo-Json -Depth 6
$payload | Set-Content (Join-Path $OutputDir 'risks.json') -Encoding UTF8
- # content.md — markdown summary for the wall-of-text PR comment
+ # content.md — markdown summary for the wall-of-text PR review
$md = New-Object System.Text.StringBuilder
[void]$md.AppendLine("## 🔍 Regression Cross-Reference")
[void]$md.AppendLine()
diff --git a/.github/scripts/Post-AISummaryComment.Tests.ps1 b/.github/scripts/Post-AISummaryComment.Tests.ps1
index 81ebc4331f54..ee0d3767ba62 100644
--- a/.github/scripts/Post-AISummaryComment.Tests.ps1
+++ b/.github/scripts/Post-AISummaryComment.Tests.ps1
@@ -17,16 +17,24 @@ BeforeAll {
throw ($parseErrors | ForEach-Object { $_.Message }) -join [Environment]::NewLine
}
- $function = $ast.Find({
- $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] -and
- $args[0].Name -eq 'Test-PhaseContentIsNoOp'
- }, $true)
+ foreach ($functionName in @(
+ 'Test-PhaseContentIsNoOp',
+ 'Get-AIReviewEvent',
+ 'Test-HasNonPRWinner',
+ 'Get-AIReviewEventForRun',
+ 'New-FutureActionSection'
+ )) {
+ $function = $ast.Find({
+ $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] -and
+ $args[0].Name -eq $functionName
+ }, $true)
- if (-not $function) {
- throw "Function 'Test-PhaseContentIsNoOp' not found"
- }
+ if (-not $function) {
+ throw "Function '$functionName' not found"
+ }
- Invoke-Expression $function.Extent.Text
+ Invoke-Expression $function.Extent.Text
+ }
}
Describe 'Test-PhaseContentIsNoOp' {
@@ -73,3 +81,98 @@ Describe 'Test-PhaseContentIsNoOp' {
Should -BeFalse
}
}
+
+Describe 'Get-AIReviewEvent' {
+ It 'maps an exact approve recommendation to APPROVE' {
+ Get-AIReviewEvent -ReportContent "## ✅ Final Recommendation: APPROVE`n`nLooks good." |
+ Should -Be 'APPROVE'
+ }
+
+ It 'maps an exact request-changes recommendation to REQUEST_CHANGES' {
+ Get-AIReviewEvent -ReportContent "## ⚠️ Final Recommendation: REQUEST CHANGES`n`nNeeds the try-fix candidate." |
+ Should -Be 'REQUEST_CHANGES'
+ }
+
+ It 'falls back to COMMENT when the recommendation is missing or ambiguous' {
+ Get-AIReviewEvent -ReportContent '' | Should -Be 'COMMENT'
+ Get-AIReviewEvent -ReportContent 'Recommendation: APPROVE after manual review' | Should -Be 'COMMENT'
+ }
+}
+
+Describe 'Get-AIReviewEventForRun' {
+ BeforeEach {
+ $script:testDir = Join-Path ([System.IO.Path]::GetTempPath()) "ai-summary-tests-$([guid]::NewGuid())"
+ New-Item -ItemType Directory -Path $script:testDir -Force | Out-Null
+ }
+
+ AfterEach {
+ Remove-Item -LiteralPath $script:testDir -Recurse -Force -ErrorAction SilentlyContinue
+ }
+
+ It 'requests changes when a non-PR try-fix candidate wins and the report is otherwise comment-only' {
+ @{
+ winner = 'try-fix-1'
+ isPRFix = $false
+ candidateDiff = 'diff --git a/file.cs b/file.cs'
+ summary = 'Candidate fixes the issue more directly.'
+ } | ConvertTo-Json -Depth 5 | Set-Content (Join-Path $script:testDir 'winner.json') -Encoding UTF8
+
+ Get-AIReviewEventForRun -ReportContent 'Report still in progress.' -PRAgentDir $script:testDir |
+ Should -Be 'REQUEST_CHANGES'
+ }
+
+ It 'does not override an exact approve recommendation' {
+ @{
+ winner = 'try-fix-1'
+ isPRFix = $false
+ candidateDiff = 'diff --git a/file.cs b/file.cs'
+ } | ConvertTo-Json -Depth 5 | Set-Content (Join-Path $script:testDir 'winner.json') -Encoding UTF8
+
+ Get-AIReviewEventForRun -ReportContent 'Final Recommendation: APPROVE' -PRAgentDir $script:testDir |
+ Should -Be 'APPROVE'
+ }
+
+ It 'does not force changes for missing, malformed, or PR-fix winner files' {
+ Get-AIReviewEventForRun -ReportContent '' -PRAgentDir $script:testDir |
+ Should -Be 'COMMENT'
+
+ 'not json' | Set-Content (Join-Path $script:testDir 'winner.json') -Encoding UTF8
+ Get-AIReviewEventForRun -ReportContent '' -PRAgentDir $script:testDir |
+ Should -Be 'COMMENT'
+
+ @{
+ winner = 'pr'
+ isPRFix = $true
+ } | ConvertTo-Json -Depth 5 | Set-Content (Join-Path $script:testDir 'winner.json') -Encoding UTF8
+ Get-AIReviewEventForRun -ReportContent '' -PRAgentDir $script:testDir |
+ Should -Be 'COMMENT'
+ }
+}
+
+Describe 'New-FutureActionSection' {
+ BeforeEach {
+ $script:testDir = Join-Path ([System.IO.Path]::GetTempPath()) "future-action-tests-$([guid]::NewGuid())"
+ New-Item -ItemType Directory -Path $script:testDir -Force | Out-Null
+ }
+
+ AfterEach {
+ Remove-Item -LiteralPath $script:testDir -Recurse -Force -ErrorAction SilentlyContinue
+ }
+
+ It 'renders selected try-fix candidate guidance in the AI Summary Future Action section' {
+ @{
+ winner = 'try-fix-2'
+ isPRFix = $false
+ summary = 'Candidate avoids the regression.'
+ candidateDiff = "diff --git a/file.cs b/file.cs`n+fixed"
+ } | ConvertTo-Json -Depth 5 | Set-Content (Join-Path $script:testDir 'winner.json') -Encoding UTF8
+
+ $section = New-FutureActionSection -PRAgentDir $script:testDir
+
+ $section | Should -Match 'Future Action'
+ $section | Should -Match 'alternative fix proposed'
+ $section | Should -Match 'try-fix-2'
+ $section | Should -Match 'Candidate avoids the regression'
+ $section | Should -Match 'diff --git a/file.cs b/file.cs'
+ }
+}
diff --git a/.github/scripts/Remove-StaleMauiBotComments.Tests.ps1 b/.github/scripts/Remove-StaleMauiBotComments.Tests.ps1
new file mode 100644
index 000000000000..d46d4e13109e
--- /dev/null
+++ b/.github/scripts/Remove-StaleMauiBotComments.Tests.ps1
@@ -0,0 +1,76 @@
+#!/usr/bin/env pwsh
+#Requires -Modules Pester
+<#
+.SYNOPSIS
+ Pester tests for stale MauiBot artifact helper pure functions.
+#>
+
+BeforeAll {
+ $scriptPath = Join-Path $PSScriptRoot 'shared/Remove-StaleMauiBotComments.ps1'
+ $tokens = $null
+ $parseErrors = $null
+ $ast = [System.Management.Automation.Language.Parser]::ParseFile($scriptPath, [ref]$tokens, [ref]$parseErrors)
+ if ($parseErrors -and $parseErrors.Count -gt 0) {
+ throw ($parseErrors | ForEach-Object { $_.Message }) -join [Environment]::NewLine
+ }
+
+ . $scriptPath
+}
+
+Describe 'MauiBot artifact marker detection' {
+ It 'detects AI Summary artifacts by marker' {
+ Test-IsAISummaryCommentBody -Body "`n## AI Summary" |
+ Should -BeTrue
+ }
+
+ It 'detects try-fix artifacts by current and legacy text markers' {
+ Test-IsTryFixCommentBody -Body "`nBody" |
+ Should -BeTrue
+
+ Test-IsTryFixCommentBody -Body 'Automated review — alternative fix proposed' |
+ Should -BeTrue
+ }
+
+ It 'does not treat the merged AI Summary Future Action section as a standalone try-fix artifact' {
+ $body = @'
+
+
+
+Future Action — alternative fix proposed (try-fix-1)
+
+**Automated review — alternative fix proposed**
+
+Candidate diff (try-fix-1)
+
+
+'@
+
+ Test-IsTryFixCommentBody -Body $body |
+ Should -BeFalse
+ }
+}
+
+Describe 'Test-ShouldPreserveMauiBotArtifact' {
+ It 'preserves artifacts by node id or REST id' {
+ $artifact = [pscustomobject]@{
+ id = 123
+ node_id = 'PRR_test'
+ }
+
+ Test-ShouldPreserveMauiBotArtifact -Artifact $artifact -PreserveNodeIds @('PRR_test') |
+ Should -BeTrue
+
+ Test-ShouldPreserveMauiBotArtifact -Artifact $artifact -PreserveIds @('123') |
+ Should -BeTrue
+ }
+
+ It 'does not preserve unmatched artifacts' {
+ $artifact = [pscustomobject]@{
+ id = 123
+ node_id = 'PRR_test'
+ }
+
+ Test-ShouldPreserveMauiBotArtifact -Artifact $artifact -PreserveNodeIds @('other') -PreserveIds @('456') |
+ Should -BeFalse
+ }
+}
diff --git a/.github/scripts/Review-PR.Tests.ps1 b/.github/scripts/Review-PR.Tests.ps1
index f3674a0af24a..d4a4f50ebc3c 100644
--- a/.github/scripts/Review-PR.Tests.ps1
+++ b/.github/scripts/Review-PR.Tests.ps1
@@ -7,9 +7,10 @@
- Get-TrxResults (parses VSTest TRX produced by `dotnet test --logger trx`)
- Get-DotNetTestResults (legacy console-output scraper, still used as fallback
when TRX is missing)
+ - Copilot token usage helpers
These functions sit on the critical path of STEP 3 (UI Test Execution
- Results in the AI summary comment). A regression here can silently
+ Results in the AI summary review). A regression here can silently
misrender per-test counts (e.g. "1/1 (1 ❌)" instead of "75/619 (544 ❌)")
so they're worth pinning with focused tests.
@@ -41,6 +42,168 @@ BeforeAll {
Invoke-Expression (Get-FunctionBody -ScriptText $content -FunctionName 'Get-TrxResults')
Invoke-Expression (Get-FunctionBody -ScriptText $content -FunctionName 'Get-DotNetTestResults')
+ Invoke-Expression (Get-FunctionBody -ScriptText $content -FunctionName 'Test-IsNumericValue')
+ Invoke-Expression (Get-FunctionBody -ScriptText $content -FunctionName 'Get-ObjectMemberValue')
+ Invoke-Expression (Get-FunctionBody -ScriptText $content -FunctionName 'Get-CopilotUsageTokenFields')
+ Invoke-Expression (Get-FunctionBody -ScriptText $content -FunctionName 'Get-TokenFieldSum')
+ Invoke-Expression (Get-FunctionBody -ScriptText $content -FunctionName 'Get-CopilotTokenMetrics')
+ Invoke-Expression (Get-FunctionBody -ScriptText $content -FunctionName 'Convert-CopilotCompactNumber')
+ Invoke-Expression (Get-FunctionBody -ScriptText $content -FunctionName 'Get-CopilotCliUsageLineData')
+ Invoke-Expression (Get-FunctionBody -ScriptText $content -FunctionName 'Get-CopilotOtelTokenMetrics')
+ Invoke-Expression (Get-FunctionBody -ScriptText $content -FunctionName 'New-CopilotTokenUsageRecord')
+}
+
+Describe 'Copilot token usage helpers' {
+ It 'normalizes known token fields while preserving raw token field paths' {
+ $usage = [pscustomobject]@{
+ inputTokens = 100
+ outputTokens = 40
+ totalApiDurationMs = 1234
+ nested = [pscustomobject]@{
+ cachedInputTokens = 12
+ }
+ }
+
+ $metrics = Get-CopilotTokenMetrics -Usage $usage
+
+ $metrics.inputTokens | Should -Be 100
+ $metrics.outputTokens | Should -Be 40
+ $metrics.cachedInputTokens | Should -Be 12
+ $metrics.totalTokens | Should -Be 140
+ @($metrics.rawTokenFields).Count | Should -Be 3
+ @($metrics.rawTokenFields | Where-Object { $_.Path -eq 'nested.cachedInputTokens' }).Count | Should -Be 1
+ }
+
+ It 'parses Copilot CLI AIC and context footer lines' {
+ $aicLine = Get-CopilotCliUsageLineData -Line 'Session: 1030 AIC used'
+ $contextLine = Get-CopilotCliUsageLineData -Line 'GPT-5.5 • 1.1M context'
+
+ $aicLine.aicUsed | Should -Be 1030
+ $contextLine.model | Should -Be 'GPT-5.5'
+ $contextLine.contextWindowRaw | Should -Be '1.1M'
+ $contextLine.contextWindow | Should -Be 1100000
+ }
+
+ It 'reads token counts from Copilot OTel spans with both cache/reasoning naming variants' {
+ $otelPath = Join-Path ([System.IO.Path]::GetTempPath()) "copilot-otel-$([guid]::NewGuid()).jsonl"
+ try {
+ @(
+ [ordered]@{
+ type = 'span'
+ attributes = [ordered]@{
+ 'gen_ai.usage.input_tokens' = 1000
+ 'gen_ai.usage.output_tokens' = 200
+ 'gen_ai.usage.cache_read.input_tokens' = 800
+ 'gen_ai.usage.reasoning.output_tokens' = 50
+ 'github.copilot.cost' = 7.5
+ }
+ },
+ [ordered]@{
+ type = 'span'
+ attributes = [ordered]@{
+ 'gen_ai.usage.input_tokens' = 500
+ 'gen_ai.usage.output_tokens' = 40
+ 'gen_ai.usage.cache_read_input_tokens' = 400
+ 'gen_ai.usage.reasoning_output_tokens' = 10
+ }
+ }
+ ) | ForEach-Object { $_ | ConvertTo-Json -Depth 10 -Compress } | Set-Content $otelPath -Encoding UTF8
+
+ $metrics = Get-CopilotOtelTokenMetrics -Path $otelPath
+
+ $metrics.available | Should -Be $true
+ $metrics.inputTokens | Should -Be 1500
+ $metrics.outputTokens | Should -Be 240
+ $metrics.cachedInputTokens | Should -Be 1200
+ $metrics.reasoningOutputTokens | Should -Be 60
+ $metrics.totalTokens | Should -Be 1740
+ $metrics.copilotCost | Should -Be 7.5
+ } finally {
+ Remove-Item $otelPath -Force -ErrorAction SilentlyContinue
+ }
+ }
+
+ It 'builds a telemetry record with raw usage and no hardcoded cost estimate' {
+ $usage = [pscustomobject]@{
+ prompt_tokens = 25
+ completion_tokens = 15
+ total_tokens = 45
+ totalApiDurationMs = 2000
+ }
+
+ $record = New-CopilotTokenUsageRecord `
+ -PRNumber 35677 `
+ -Platform 'android' `
+ -Phase 'CopilotReview' `
+ -StepName 'STEP 5a: TRY-FIX' `
+ -ModelName 'gpt-5.5' `
+ -StartedAtUtc ([DateTimeOffset]::Parse('2026-06-05T10:00:00Z')) `
+ -EndedAtUtc ([DateTimeOffset]::Parse('2026-06-05T10:00:05Z')) `
+ -DurationMs 5000 `
+ -TurnCount 2 `
+ -ToolCount 3 `
+ -FailedToolCount 1 `
+ -Usage $usage `
+ -OtelMetrics $null `
+ -AicUsed 1030 `
+ -ContextWindow 1100000 `
+ -ContextWindowRaw '1.1M' `
+ -ResultEventSeen $true `
+ -ExitCode 0
+
+ $record.prNumber | Should -Be 35677
+ $record.scriptPhase | Should -Be 'CopilotReview'
+ $record.copilotStep | Should -Be 'STEP 5a: TRY-FIX'
+ $record.apiDurationMs | Should -Be 2000
+ $record.normalizedTokens.inputTokens | Should -Be 25
+ $record.normalizedTokens.outputTokens | Should -Be 15
+ $record.normalizedTokens.totalTokens | Should -Be 45
+ $record.cliUsage.aicUsed | Should -Be 1030
+ $record.cliUsage.contextWindow | Should -Be 1100000
+ $record.cliUsage.contextWindowRaw | Should -Be '1.1M'
+ $record.usage.total_tokens | Should -Be 45
+ $record.costEstimateAvailable | Should -Be $false
+ }
+
+ It 'uses OTel token metrics when result usage has no token fields' {
+ $otelMetrics = [ordered]@{
+ inputTokens = 500
+ outputTokens = 75
+ cachedInputTokens = 400
+ reasoningOutputTokens = 25
+ totalTokens = 575
+ copilotCost = 7.5
+ file = '/tmp/copilot-otel.jsonl'
+ }
+
+ $record = New-CopilotTokenUsageRecord `
+ -PRNumber 35677 `
+ -Platform 'android' `
+ -Phase 'CopilotReview' `
+ -StepName 'STEP 5a: TRY-FIX' `
+ -ModelName 'gpt-5.5' `
+ -StartedAtUtc ([DateTimeOffset]::Parse('2026-06-05T10:00:00Z')) `
+ -EndedAtUtc ([DateTimeOffset]::Parse('2026-06-05T10:00:05Z')) `
+ -DurationMs 5000 `
+ -TurnCount 2 `
+ -ToolCount 3 `
+ -FailedToolCount 0 `
+ -Usage ([pscustomobject]@{ totalApiDurationMs = 1000 }) `
+ -OtelMetrics $otelMetrics `
+ -AicUsed $null `
+ -ContextWindow $null `
+ -ContextWindowRaw $null `
+ -ResultEventSeen $true `
+ -ExitCode 0
+
+ $record.normalizedTokens.inputTokens | Should -Be 500
+ $record.normalizedTokens.outputTokens | Should -Be 75
+ $record.normalizedTokens.cachedInputTokens | Should -Be 400
+ $record.normalizedTokens.reasoningOutputTokens | Should -Be 25
+ $record.normalizedTokens.totalTokens | Should -Be 575
+ $record.normalizedTokens.otelFile | Should -Be '/tmp/copilot-otel.jsonl'
+ $record.cliUsage.aicUsed | Should -Be 7.5
+ }
}
Describe 'Get-TrxResults' {
diff --git a/.github/scripts/Review-PR.ps1 b/.github/scripts/Review-PR.ps1
index c966ce7d9218..4dcbbda429b3 100644
--- a/.github/scripts/Review-PR.ps1
+++ b/.github/scripts/Review-PR.ps1
@@ -37,6 +37,9 @@
.PARAMETER LogFile
Capture all output via Start-Transcript
+.PARAMETER TokenUsageOutputDir
+ Directory where Copilot CLI token-usage telemetry records should be written.
+
.EXAMPLE
.\Review-PR.ps1 -PRNumber 33687
.\Review-PR.ps1 -PRNumber 33687 -Platform ios
@@ -66,7 +69,10 @@ param(
[switch]$DryRun,
[Parameter(Mandatory = $false)]
- [string]$LogFile
+ [string]$LogFile,
+
+ [Parameter(Mandatory = $false)]
+ [string]$TokenUsageOutputDir
)
$ErrorActionPreference = 'Stop'
@@ -174,6 +180,10 @@ $autonomousRules = @"
$reviewBranch = "pr-review-$PRNumber"
+if ([string]::IsNullOrWhiteSpace($TokenUsageOutputDir)) {
+ $TokenUsageOutputDir = Join-Path $RepoRoot "CustomAgentLogsTmp/PRState/$PRNumber/PRAgent/token-usage/raw"
+}
+
# ─── Prerequisites ────────────────────────────────────────────────────────────
if ($runSetup) {
Write-Host "📋 Checking prerequisites..." -ForegroundColor Yellow
@@ -302,6 +312,16 @@ if ($DryRun) {
Write-Host " 🔀 Merging PR commits (squashed)..." -ForegroundColor Cyan
git merge --squash $tempBranch 2>&1 | Out-Null
if ($LASTEXITCODE -eq 0) {
+ # Ensure both staged and unstaged merge output is committed. Some
+ # squash merges can leave tracked files modified in the worktree rather
+ # than only staged; Gate later requires fix files to be committed so it
+ # can restore them with `git checkout HEAD`.
+ git add -A 2>&1 | Out-Null
+ if ($LASTEXITCODE -ne 0) {
+ git branch -D $tempBranch 2>$null
+ Write-Error "Failed to stage squashed PR changes"; exit 1
+ }
+
# Check if there's anything to commit (PR might already be merged)
$staged = git diff --cached --quiet 2>$null; $hasStagedChanges = $LASTEXITCODE -ne 0
if ($hasStagedChanges) {
@@ -315,6 +335,14 @@ if ($DryRun) {
Write-Host " ⚠️ No changes to merge (PR may already be up to date)" -ForegroundColor Yellow
}
+ git diff --quiet 2>$null; $hasWorktreeChanges = $LASTEXITCODE -ne 0
+ git diff --cached --quiet 2>$null; $hasIndexChanges = $LASTEXITCODE -ne 0
+ if ($hasWorktreeChanges -or $hasIndexChanges) {
+ Write-Error "Review branch has uncommitted tracked changes after setup. Gate cannot proceed safely."
+ git status --short
+ exit 1
+ }
+
if (Get-Command Remove-StaleMauiBotIssueComments -ErrorAction SilentlyContinue) {
Remove-StaleMauiBotIssueComments `
-PRNumber $PRNumber `
@@ -393,12 +421,25 @@ if ($Phase -and $Phase -ne 'Setup') {
Write-Error "Setup phase did not complete (sentinel not found at '$sentinelFile'). Cannot proceed with -Phase $Phase."
exit 1
}
+
+ if (-not $DryRun) {
+ git checkout $reviewBranch 2>$null | Out-Null
+ if ($LASTEXITCODE -ne 0) {
+ Write-Error "Failed to checkout review branch '$reviewBranch' before -Phase $Phase."
+ exit 1
+ }
+ git reset --hard HEAD 2>$null | Out-Null
+ if ($LASTEXITCODE -ne 0) {
+ Write-Error "Failed to reset review branch '$reviewBranch' before -Phase $Phase."
+ exit 1
+ }
+ }
}
# ─── Helper: Parse `dotnet test --logger "console;verbosity=detailed"` ──────
# Extracts per-test results (Passed/Failed/Skipped) plus failure messages and
# stack traces from raw stdout. Used by the RunDeepUITests stage and Gate so the
-# AI summary comment shows WHICH tests failed and WHY, not just an aggregate exit code.
+# AI summary review shows WHICH tests failed and WHY, not just an aggregate exit code.
function Get-DotNetTestResults {
param([string[]]$Lines)
@@ -553,6 +594,439 @@ function Get-TrxResults {
}
}
+# ─── Helper: Copilot token usage telemetry ────────────────────────────────────
+function Test-IsNumericValue {
+ param([object]$Value)
+
+ return (
+ $Value -is [byte] -or
+ $Value -is [sbyte] -or
+ $Value -is [int16] -or
+ $Value -is [uint16] -or
+ $Value -is [int] -or
+ $Value -is [uint32] -or
+ $Value -is [long] -or
+ $Value -is [uint64] -or
+ $Value -is [float] -or
+ $Value -is [double] -or
+ $Value -is [decimal]
+ )
+}
+
+function Get-ObjectMemberValue {
+ param(
+ [object]$InputObject,
+ [string[]]$Names
+ )
+
+ if ($null -eq $InputObject) { return $null }
+
+ foreach ($name in $Names) {
+ if ($InputObject -is [System.Collections.IDictionary] -and $InputObject.Contains($name)) {
+ return $InputObject[$name]
+ }
+
+ $property = $InputObject.PSObject.Properties[$name]
+ if ($property) {
+ return $property.Value
+ }
+ }
+
+ return $null
+}
+
+function Get-CopilotUsageTokenFields {
+ param(
+ [object]$Value,
+ [string]$Path = ''
+ )
+
+ $fields = New-Object System.Collections.ArrayList
+ if ($null -eq $Value) { return @() }
+
+ if (Test-IsNumericValue $Value) {
+ if ($Path -match '(?i)token') {
+ [void]$fields.Add([ordered]@{
+ Path = $Path
+ Value = [double]$Value
+ })
+ }
+ return @($fields.ToArray())
+ }
+
+ if ($Value -is [string]) { return @() }
+
+ if ($Value -is [System.Collections.IDictionary]) {
+ foreach ($key in $Value.Keys) {
+ $childPath = if ($Path) { "$Path.$key" } else { [string]$key }
+ foreach ($field in Get-CopilotUsageTokenFields -Value $Value[$key] -Path $childPath) {
+ [void]$fields.Add($field)
+ }
+ }
+ return @($fields.ToArray())
+ }
+
+ if ($Value -is [System.Collections.IEnumerable]) {
+ $index = 0
+ foreach ($item in $Value) {
+ $childPath = if ($Path) { "$Path[$index]" } else { "[$index]" }
+ foreach ($field in Get-CopilotUsageTokenFields -Value $item -Path $childPath) {
+ [void]$fields.Add($field)
+ }
+ $index++
+ }
+ return @($fields.ToArray())
+ }
+
+ foreach ($property in $Value.PSObject.Properties) {
+ if ($property.MemberType -notin @('NoteProperty', 'Property', 'AliasProperty')) {
+ continue
+ }
+
+ $childPath = if ($Path) { "$Path.$($property.Name)" } else { $property.Name }
+ foreach ($field in Get-CopilotUsageTokenFields -Value $property.Value -Path $childPath) {
+ [void]$fields.Add($field)
+ }
+ }
+
+ return @($fields.ToArray())
+}
+
+function Get-TokenFieldSum {
+ param([object[]]$Fields)
+
+ $items = @($Fields)
+ if ($items.Count -eq 0) { return $null }
+
+ $sum = 0.0
+ foreach ($item in $items) {
+ $sum += [double]$item.Value
+ }
+
+ return [long][Math]::Round($sum)
+}
+
+function Get-CopilotTokenMetrics {
+ param([object]$Usage)
+
+ $tokenFields = @(Get-CopilotUsageTokenFields -Value $Usage)
+ $inputFields = @($tokenFields | Where-Object {
+ $_.Path -match '(?i)(input|prompt)' -and
+ $_.Path -notmatch '(?i)(cache|cached)' -and
+ $_.Path -notmatch '(?i)total'
+ })
+ $outputFields = @($tokenFields | Where-Object {
+ $_.Path -match '(?i)(output|completion)' -and
+ $_.Path -notmatch '(?i)(cache|cached)' -and
+ $_.Path -notmatch '(?i)total'
+ })
+ $cachedInputFields = @($tokenFields | Where-Object {
+ $_.Path -match '(?i)(cache|cached)' -and
+ $_.Path -match '(?i)(input|prompt|read)'
+ })
+ $explicitTotalFields = @($tokenFields | Where-Object {
+ $_.Path -match '(?i)total' -and
+ $_.Path -match '(?i)token'
+ })
+
+ $inputTokens = Get-TokenFieldSum -Fields $inputFields
+ $outputTokens = Get-TokenFieldSum -Fields $outputFields
+ $cachedInputTokens = Get-TokenFieldSum -Fields $cachedInputFields
+ $totalTokens = Get-TokenFieldSum -Fields $explicitTotalFields
+ if ($null -eq $totalTokens -and ($null -ne $inputTokens -or $null -ne $outputTokens)) {
+ $totalTokens = [long](($inputTokens ?? 0) + ($outputTokens ?? 0))
+ }
+
+ return [ordered]@{
+ inputTokens = $inputTokens
+ outputTokens = $outputTokens
+ cachedInputTokens = $cachedInputTokens
+ totalTokens = $totalTokens
+ rawTokenFields = @($tokenFields)
+ }
+}
+
+function Convert-CopilotCompactNumber {
+ param([string]$Value)
+
+ if ([string]::IsNullOrWhiteSpace($Value)) { return $null }
+
+ $normalized = ($Value -replace ',', '').Trim()
+ if ($normalized -notmatch '^(?[0-9]+(?:\.[0-9]+)?)\s*(?[KMGkmg])?$') {
+ return $null
+ }
+
+ $number = [double]$Matches['number']
+ $multiplier = switch ($Matches['suffix'].ToUpperInvariant()) {
+ 'K' { 1000 }
+ 'M' { 1000000 }
+ 'G' { 1000000000 }
+ default { 1 }
+ }
+
+ return [long][Math]::Round($number * $multiplier)
+}
+
+function Get-CopilotCliUsageLineData {
+ param([string]$Line)
+
+ $data = [ordered]@{}
+ if ([string]::IsNullOrWhiteSpace($Line)) {
+ return $data
+ }
+
+ if ($Line -match 'Session:\s*(?[0-9]+(?:\.[0-9]+)?)\s*AIC\s+used') {
+ $data.aicUsed = [double]$Matches['aic']
+ }
+
+ if ($Line -match '^\s*(?.+?)\s*[\u2022\u00b7]\s*(?[0-9][0-9,]*(?:\.[0-9]+)?\s*[KMGkmg]?)\s+context\s*$') {
+ $contextRaw = $Matches['context'].Trim()
+ $data.model = $Matches['model'].Trim()
+ $data.contextWindowRaw = $contextRaw
+ $data.contextWindow = Convert-CopilotCompactNumber -Value $contextRaw
+ }
+
+ return $data
+}
+
+function Get-CopilotOtelTokenMetrics {
+ param([string]$Path)
+
+ $metrics = [ordered]@{
+ inputTokens = $null
+ outputTokens = $null
+ cachedInputTokens = $null
+ reasoningOutputTokens = $null
+ totalTokens = $null
+ copilotCost = $null
+ available = $false
+ file = $Path
+ }
+
+ if ([string]::IsNullOrWhiteSpace($Path) -or -not (Test-Path $Path)) {
+ return $metrics
+ }
+
+ $spanSums = @{
+ input = 0.0
+ output = 0.0
+ cached = 0.0
+ reasoning = 0.0
+ cost = 0.0
+ }
+ $spanSeen = @{
+ input = $false
+ output = $false
+ cached = $false
+ reasoning = $false
+ cost = $false
+ }
+
+ $metricSums = @{
+ input = 0.0
+ output = 0.0
+ cached = 0.0
+ }
+ $metricSeen = @{
+ input = $false
+ output = $false
+ cached = $false
+ }
+
+ foreach ($line in Get-Content -Path $Path -Encoding UTF8) {
+ if ([string]::IsNullOrWhiteSpace($line)) { continue }
+
+ try {
+ $entry = $line | ConvertFrom-Json -ErrorAction Stop
+ } catch {
+ continue
+ }
+
+ if ($entry.type -eq 'span' -and $entry.attributes) {
+ $attributes = $entry.attributes
+ $inputValue = Get-ObjectMemberValue -InputObject $attributes -Names @('gen_ai.usage.input_tokens')
+ $outputValue = Get-ObjectMemberValue -InputObject $attributes -Names @('gen_ai.usage.output_tokens')
+ $cachedValue = Get-ObjectMemberValue -InputObject $attributes -Names @('gen_ai.usage.cache_read.input_tokens', 'gen_ai.usage.cache_read_input_tokens')
+ $reasoningValue = Get-ObjectMemberValue -InputObject $attributes -Names @('gen_ai.usage.reasoning.output_tokens', 'gen_ai.usage.reasoning_output_tokens')
+ $costValue = Get-ObjectMemberValue -InputObject $attributes -Names @('github.copilot.cost')
+
+ if (Test-IsNumericValue $inputValue) { $spanSums.input += [double]$inputValue; $spanSeen.input = $true }
+ if (Test-IsNumericValue $outputValue) { $spanSums.output += [double]$outputValue; $spanSeen.output = $true }
+ if (Test-IsNumericValue $cachedValue) { $spanSums.cached += [double]$cachedValue; $spanSeen.cached = $true }
+ if (Test-IsNumericValue $reasoningValue) { $spanSums.reasoning += [double]$reasoningValue; $spanSeen.reasoning = $true }
+ if (Test-IsNumericValue $costValue) { $spanSums.cost += [double]$costValue; $spanSeen.cost = $true }
+ } elseif ($entry.type -eq 'metric' -and $entry.name -eq 'gen_ai.client.token.usage') {
+ foreach ($point in @($entry.dataPoints)) {
+ $tokenType = [string](Get-ObjectMemberValue -InputObject $point.attributes -Names @('gen_ai.token.type'))
+ $sumValue = Get-ObjectMemberValue -InputObject $point.value -Names @('sum')
+ if (-not (Test-IsNumericValue $sumValue)) { continue }
+
+ if ($tokenType -eq 'input') {
+ $metricSums.input += [double]$sumValue
+ $metricSeen.input = $true
+ } elseif ($tokenType -eq 'output') {
+ $metricSums.output += [double]$sumValue
+ $metricSeen.output = $true
+ } elseif ($tokenType -match '(?i)cache') {
+ $metricSums.cached += [double]$sumValue
+ $metricSeen.cached = $true
+ }
+ }
+ }
+ }
+
+ $inputTokens = if ($spanSeen.input) { [long][Math]::Round($spanSums.input) } elseif ($metricSeen.input) { [long][Math]::Round($metricSums.input) } else { $null }
+ $outputTokens = if ($spanSeen.output) { [long][Math]::Round($spanSums.output) } elseif ($metricSeen.output) { [long][Math]::Round($metricSums.output) } else { $null }
+ $cachedInputTokens = if ($spanSeen.cached) { [long][Math]::Round($spanSums.cached) } elseif ($metricSeen.cached) { [long][Math]::Round($metricSums.cached) } else { $null }
+ $reasoningOutputTokens = if ($spanSeen.reasoning) { [long][Math]::Round($spanSums.reasoning) } else { $null }
+ $copilotCost = if ($spanSeen.cost) { [Math]::Round($spanSums.cost, 3) } else { $null }
+
+ $totalTokens = if ($null -ne $inputTokens -or $null -ne $outputTokens) {
+ [long](($inputTokens ?? 0) + ($outputTokens ?? 0))
+ } else {
+ $null
+ }
+
+ $metrics.inputTokens = $inputTokens
+ $metrics.outputTokens = $outputTokens
+ $metrics.cachedInputTokens = $cachedInputTokens
+ $metrics.reasoningOutputTokens = $reasoningOutputTokens
+ $metrics.totalTokens = $totalTokens
+ $metrics.copilotCost = $copilotCost
+ $metrics.available = ($null -ne $inputTokens -or $null -ne $outputTokens -or $null -ne $cachedInputTokens -or $null -ne $copilotCost)
+
+ return $metrics
+}
+
+function New-CopilotTokenUsageRecord {
+ param(
+ [int]$PRNumber,
+ [string]$Platform,
+ [string]$Phase,
+ [string]$StepName,
+ [string]$ModelName,
+ [datetimeoffset]$StartedAtUtc,
+ [datetimeoffset]$EndedAtUtc,
+ [long]$DurationMs,
+ [int]$TurnCount,
+ [int]$ToolCount,
+ [int]$FailedToolCount,
+ [object]$Usage,
+ [object]$OtelMetrics,
+ [object]$AicUsed,
+ [object]$ContextWindow,
+ [string]$ContextWindowRaw,
+ [bool]$ResultEventSeen,
+ [int]$ExitCode
+ )
+
+ $apiDurationValue = Get-ObjectMemberValue -InputObject $Usage -Names @('totalApiDurationMs', 'total_api_duration_ms')
+ $apiDurationMs = if (Test-IsNumericValue $apiDurationValue) { [long]$apiDurationValue } else { $null }
+ $usageTokenMetrics = Get-CopilotTokenMetrics -Usage $Usage
+
+ $inputTokens = $usageTokenMetrics.inputTokens
+ $outputTokens = $usageTokenMetrics.outputTokens
+ $cachedInputTokens = $usageTokenMetrics.cachedInputTokens
+ $totalTokens = $usageTokenMetrics.totalTokens
+ $reasoningOutputTokens = $null
+ $copilotCost = $null
+ $otelFile = $null
+
+ if ($OtelMetrics) {
+ if ($null -eq $inputTokens -and $null -ne $OtelMetrics.inputTokens) { $inputTokens = $OtelMetrics.inputTokens }
+ if ($null -eq $outputTokens -and $null -ne $OtelMetrics.outputTokens) { $outputTokens = $OtelMetrics.outputTokens }
+ if ($null -eq $cachedInputTokens -and $null -ne $OtelMetrics.cachedInputTokens) { $cachedInputTokens = $OtelMetrics.cachedInputTokens }
+ if ($null -eq $totalTokens -and $null -ne $OtelMetrics.totalTokens) { $totalTokens = $OtelMetrics.totalTokens }
+ $reasoningOutputTokens = $OtelMetrics.reasoningOutputTokens
+ $copilotCost = $OtelMetrics.copilotCost
+ $otelFile = $OtelMetrics.file
+ }
+
+ $billingUnits = $AicUsed
+ if ($null -eq $billingUnits -and $null -ne $copilotCost) {
+ $billingUnits = $copilotCost
+ }
+ if ($null -eq $billingUnits) {
+ $premiumRequests = Get-ObjectMemberValue -InputObject $Usage -Names @('premiumRequests')
+ if (Test-IsNumericValue $premiumRequests) {
+ $billingUnits = [double]$premiumRequests
+ }
+ }
+
+ return [ordered]@{
+ schemaVersion = 1
+ generatedAtUtc = ([DateTimeOffset]::UtcNow).ToString('o')
+ prNumber = $PRNumber
+ platform = $Platform
+ pipeline = [ordered]@{
+ buildId = $env:BUILD_BUILDID
+ buildNumber = $env:BUILD_BUILDNUMBER
+ definitionName = $env:BUILD_DEFINITIONNAME
+ stageName = $env:SYSTEM_STAGENAME
+ jobName = $env:SYSTEM_JOBNAME
+ jobDisplayName = $env:SYSTEM_JOBDISPLAYNAME
+ taskInstanceId = $env:SYSTEM_TASKINSTANCEID
+ }
+ scriptPhase = if ($Phase) { $Phase } else { 'All' }
+ copilotStep = $StepName
+ model = $ModelName
+ startedAtUtc = $StartedAtUtc.ToString('o')
+ endedAtUtc = $EndedAtUtc.ToString('o')
+ durationMs = $DurationMs
+ apiDurationMs = $apiDurationMs
+ resultEventSeen = $ResultEventSeen
+ exitCode = $ExitCode
+ turnCount = $TurnCount
+ toolCount = $ToolCount
+ failedToolCount = $FailedToolCount
+ cliUsage = [ordered]@{
+ aicUsed = $billingUnits
+ contextWindow = $ContextWindow
+ contextWindowRaw = $ContextWindowRaw
+ }
+ normalizedTokens = [ordered]@{
+ inputTokens = $inputTokens
+ outputTokens = $outputTokens
+ cachedInputTokens = $cachedInputTokens
+ reasoningOutputTokens = $reasoningOutputTokens
+ totalTokens = $totalTokens
+ rawTokenFields = @($usageTokenMetrics.rawTokenFields)
+ otelFile = $otelFile
+ }
+ usage = $Usage
+ costEstimateAvailable = $false
+ costEstimateNote = 'Dollar cost not calculated; no trusted rate table configured.'
+ }
+}
+
+function Write-CopilotTokenUsageRecord {
+ param(
+ [string]$OutputDir,
+ [object]$Record
+ )
+
+ if ([string]::IsNullOrWhiteSpace($OutputDir) -or $null -eq $Record) {
+ return
+ }
+
+ try {
+ New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null
+ $stepName = [string]$Record.copilotStep
+ $safeStepName = ($stepName -replace '[^A-Za-z0-9._-]+', '-').Trim('-')
+ if ([string]::IsNullOrWhiteSpace($safeStepName)) {
+ $safeStepName = 'copilot-step'
+ }
+
+ $timestamp = [DateTimeOffset]::UtcNow.ToString('yyyyMMddTHHmmssfffZ')
+ $fileName = "copilot-token-usage-$timestamp-$safeStepName-$([guid]::NewGuid().ToString('N')).json"
+ $path = Join-Path $OutputDir $fileName
+ $Record | ConvertTo-Json -Depth 50 | Set-Content -Path $path -Encoding UTF8
+ Write-Host " Token usage record: $path" -ForegroundColor DarkGray
+ } catch {
+ Write-Host " WARNING: Failed to write Copilot token usage record: $_" -ForegroundColor Yellow
+ }
+}
+
# ─── Helper: Invoke Copilot ──────────────────────────────────────────────────
function Invoke-CopilotStep {
param([string]$StepName, [string]$Prompt)
@@ -568,12 +1042,18 @@ function Invoke-CopilotStep {
return 0
}
+ $startedAtUtc = [DateTimeOffset]::UtcNow
$stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
$toolCount = 0
$turnCount = 0
$currentIntent = ""
$modelName = ""
$failedTools = @()
+ $resultEventSeen = $false
+ $resultUsage = $null
+ $cliAicUsed = $null
+ $cliContextWindow = $null
+ $cliContextWindowRaw = $null
# Tool icon mapping for common tools
$toolIcons = @{
@@ -592,133 +1072,204 @@ function Invoke-CopilotStep {
# Model is overridable via $env:COPILOT_REVIEW_MODEL so contributors without internal-model access
# can run this script (e.g., with 'claude-opus-4.6' or 'claude-sonnet-4.6').
$copilotModel = if ($env:COPILOT_REVIEW_MODEL) { $env:COPILOT_REVIEW_MODEL } else { 'gpt-5.5' }
- & copilot -p $Prompt --allow-all --output-format json --model $copilotModel --secret-env-vars=GH_TOKEN,COPILOT_GITHUB_TOKEN,GITHUB_TOKEN 2>&1 | ForEach-Object {
- $line = $_.ToString()
- try {
- $event = $line | ConvertFrom-Json -ErrorAction Stop
- switch ($event.type) {
- 'session.tools_updated' {
- if ($event.data.model) {
- $modelName = $event.data.model
- Write-Host " ⚙️ Model: " -ForegroundColor DarkGray -NoNewline
- Write-Host $modelName -ForegroundColor DarkCyan
+ if ([string]::IsNullOrWhiteSpace($modelName)) {
+ $modelName = $copilotModel
+ }
+ $safeOtelStepName = ($StepName -replace '[^A-Za-z0-9._-]+', '-').Trim('-')
+ if ([string]::IsNullOrWhiteSpace($safeOtelStepName)) {
+ $safeOtelStepName = 'copilot-step'
+ }
+ $otelPath = $null
+ if (-not [string]::IsNullOrWhiteSpace($TokenUsageOutputDir)) {
+ New-Item -ItemType Directory -Path $TokenUsageOutputDir -Force | Out-Null
+ $otelPath = Join-Path $TokenUsageOutputDir "copilot-otel-$([DateTimeOffset]::UtcNow.ToString('yyyyMMddTHHmmssfffZ'))-$safeOtelStepName-$([guid]::NewGuid().ToString('N')).jsonl"
+ }
+
+ $savedOtel = @{
+ COPILOT_OTEL_FILE_EXPORTER_PATH = $env:COPILOT_OTEL_FILE_EXPORTER_PATH
+ COPILOT_OTEL_EXPORTER_TYPE = $env:COPILOT_OTEL_EXPORTER_TYPE
+ OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT = $env:OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT
+ }
+ try {
+ if ($otelPath) {
+ $env:COPILOT_OTEL_FILE_EXPORTER_PATH = $otelPath
+ $env:COPILOT_OTEL_EXPORTER_TYPE = 'file'
+ $env:OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT = 'false'
+ }
+
+ & copilot -p $Prompt --allow-all --output-format json --model $copilotModel --secret-env-vars=GH_TOKEN,COPILOT_GITHUB_TOKEN,GITHUB_TOKEN 2>&1 | ForEach-Object {
+ $line = $_.ToString()
+ try {
+ $event = $line | ConvertFrom-Json -ErrorAction Stop
+ switch ($event.type) {
+ 'session.tools_updated' {
+ if ($event.data.model) {
+ $modelName = $event.data.model
+ Write-Host " ⚙️ Model: " -ForegroundColor DarkGray -NoNewline
+ Write-Host $modelName -ForegroundColor DarkCyan
+ }
}
- }
- 'assistant.turn_start' {
- $turnCount++
- $elapsed = $stopwatch.Elapsed.ToString("mm\:ss")
- Write-Host ""
- Write-Host " ┌─ Turn $turnCount " -ForegroundColor DarkGray -NoNewline
- Write-Host "[$elapsed]" -ForegroundColor DarkYellow -NoNewline
- if ($currentIntent) {
- Write-Host " · $currentIntent" -ForegroundColor DarkCyan
- } else {
+ 'assistant.turn_start' {
+ $turnCount++
+ $elapsed = $stopwatch.Elapsed.ToString("mm\:ss")
Write-Host ""
+ Write-Host " ┌─ Turn $turnCount " -ForegroundColor DarkGray -NoNewline
+ Write-Host "[$elapsed]" -ForegroundColor DarkYellow -NoNewline
+ if ($currentIntent) {
+ Write-Host " · $currentIntent" -ForegroundColor DarkCyan
+ } else {
+ Write-Host ""
+ }
}
- }
- 'assistant.turn_end' {
- Write-Host " └─" -ForegroundColor DarkGray
- }
- 'tool.execution_start' {
- $toolName = $event.data.toolName
- $args_ = $event.data.arguments
-
- # Capture intent changes silently
- if ($toolName -eq 'report_intent') {
- $currentIntent = $args_.intent ?? $currentIntent
- Write-Host " │ 🎯 " -ForegroundColor DarkGray -NoNewline
- Write-Host $currentIntent -ForegroundColor Yellow
- break
+ 'assistant.turn_end' {
+ Write-Host " └─" -ForegroundColor DarkGray
}
+ 'tool.execution_start' {
+ $toolName = $event.data.toolName
+ $args_ = $event.data.arguments
+
+ # Capture intent changes silently
+ if ($toolName -eq 'report_intent') {
+ $currentIntent = $args_.intent ?? $currentIntent
+ Write-Host " │ 🎯 " -ForegroundColor DarkGray -NoNewline
+ Write-Host $currentIntent -ForegroundColor Yellow
+ break
+ }
- $toolCount++
- $icon = $toolIcons[$toolName]
- if (-not $icon) {
- # Prefix match for github-mcp-server-* and other compound names
- $icon = if ($toolName -like 'github-*') { '🔀' } else { '🔧' }
- }
+ $toolCount++
+ $icon = $toolIcons[$toolName]
+ if (-not $icon) {
+ # Prefix match for github-mcp-server-* and other compound names
+ $icon = if ($toolName -like 'github-*') { '🔀' } else { '🔧' }
+ }
- # Build a short display name for long tool names
- $displayName = $toolName -replace '^github-mcp-server-', 'gh/'
+ # Build a short display name for long tool names
+ $displayName = $toolName -replace '^github-mcp-server-', 'gh/'
- # Pick the most useful detail from arguments
- $detail = $args_.description ?? $args_.intent ?? ''
- if (-not $detail) {
- # Fallback: pick first informative arg
- $detail = $args_.command ?? $args_.pattern ?? $args_.query ?? $args_.path ?? $args_.prompt ?? ''
- }
- if ($detail) {
- $detail = $detail.Substring(0, [Math]::Min($detail.Length, 90))
- # Truncate at last word boundary if we cut mid-word
- if ($detail.Length -eq 90) {
- $lastSpace = $detail.LastIndexOf(' ')
- if ($lastSpace -gt 60) { $detail = $detail.Substring(0, $lastSpace) + "…" }
- else { $detail += "…" }
+ # Pick the most useful detail from arguments
+ $detail = $args_.description ?? $args_.intent ?? ''
+ if (-not $detail) {
+ # Fallback: pick first informative arg
+ $detail = $args_.command ?? $args_.pattern ?? $args_.query ?? $args_.path ?? $args_.prompt ?? ''
+ }
+ if ($detail) {
+ $detail = $detail.Substring(0, [Math]::Min($detail.Length, 90))
+ # Truncate at last word boundary if we cut mid-word
+ if ($detail.Length -eq 90) {
+ $lastSpace = $detail.LastIndexOf(' ')
+ if ($lastSpace -gt 60) { $detail = $detail.Substring(0, $lastSpace) + "…" }
+ else { $detail += "…" }
+ }
}
- }
- Write-Host " │ $icon " -ForegroundColor DarkGray -NoNewline
- Write-Host $displayName -ForegroundColor Cyan -NoNewline
- if ($detail) {
- Write-Host " $detail" -ForegroundColor DarkGray
- } else {
- Write-Host ""
+ Write-Host " │ $icon " -ForegroundColor DarkGray -NoNewline
+ Write-Host $displayName -ForegroundColor Cyan -NoNewline
+ if ($detail) {
+ Write-Host " $detail" -ForegroundColor DarkGray
+ } else {
+ Write-Host ""
+ }
}
- }
- 'tool.execution_complete' {
- if (-not $event.data.success) {
- $failedTool = $event.data.toolCallId
- $failedTools += $failedTool
- Write-Host " │ ❌ Tool failed" -ForegroundColor Red
+ 'tool.execution_complete' {
+ if (-not $event.data.success) {
+ $failedTool = $event.data.toolCallId
+ $failedTools += $failedTool
+ Write-Host " │ ❌ Tool failed" -ForegroundColor Red
+ }
}
- }
- 'assistant.message' {
- $content = $event.data.content
- # Show agent text responses (skip empty tool-request-only messages)
- if ($content -and $content.Trim()) {
- $preview = $content.Trim()
- if ($preview.Length -gt 400) {
- $preview = $preview.Substring(0, 400) + "…"
+ 'assistant.message' {
+ $content = $event.data.content
+ # Show agent text responses (skip empty tool-request-only messages)
+ if ($content -and $content.Trim()) {
+ $preview = $content.Trim()
+ if ($preview.Length -gt 400) {
+ $preview = $preview.Substring(0, 400) + "…"
+ }
+ Write-Host " │ 💬 " -ForegroundColor DarkGray -NoNewline
+ Write-Host $preview -ForegroundColor White
}
- Write-Host " │ 💬 " -ForegroundColor DarkGray -NoNewline
- Write-Host $preview -ForegroundColor White
}
- }
- 'result' {
- # Final stats — note: 'result' is a top-level event with no 'data' wrapper.
- $usage = $event.usage
- if ($usage) {
- $elapsed = $stopwatch.Elapsed.ToString("mm\:ss")
- $apiMs = if ($usage.totalApiDurationMs) { [math]::Round($usage.totalApiDurationMs / 1000, 1) } else { "?" }
- $changes = $usage.codeChanges
- $filesChanged = if ($changes -and $changes.filesModified) { @($changes.filesModified).Count } else { 0 }
- $linesAdded = if ($changes) { $changes.linesAdded } else { 0 }
- $linesRemoved = if ($changes) { $changes.linesRemoved } else { 0 }
-
- Write-Host ""
- Write-Host " ╭──────────────────────────────────────────╮" -ForegroundColor DarkGray
- Write-Host " │ ⏱ $elapsed elapsed ($($apiMs)s API)" -ForegroundColor DarkGray -NoNewline
- Write-Host " │ 🔧 $toolCount tools" -ForegroundColor DarkGray -NoNewline
- Write-Host " │ 🔄 $turnCount turns" -ForegroundColor DarkGray
- if ($filesChanged -gt 0 -or $linesAdded -gt 0 -or $linesRemoved -gt 0) {
- Write-Host " │ 📝 $filesChanged files " -ForegroundColor DarkGray -NoNewline
- Write-Host "+$linesAdded" -ForegroundColor Green -NoNewline
- Write-Host "/" -ForegroundColor DarkGray -NoNewline
- Write-Host "-$linesRemoved" -ForegroundColor Red
+ 'result' {
+ # Final stats — note: 'result' is a top-level event with no 'data' wrapper.
+ $resultEventSeen = $true
+ $usage = $event.usage
+ $resultUsage = $usage
+ if ($usage) {
+ $elapsed = $stopwatch.Elapsed.ToString("mm\:ss")
+ $apiMs = if ($usage.totalApiDurationMs) { [math]::Round($usage.totalApiDurationMs / 1000, 1) } else { "?" }
+ $changes = $usage.codeChanges
+ $filesChanged = if ($changes -and $changes.filesModified) { @($changes.filesModified).Count } else { 0 }
+ $linesAdded = if ($changes) { $changes.linesAdded } else { 0 }
+ $linesRemoved = if ($changes) { $changes.linesRemoved } else { 0 }
+
+ Write-Host ""
+ Write-Host " ╭──────────────────────────────────────────╮" -ForegroundColor DarkGray
+ Write-Host " │ ⏱ $elapsed elapsed ($($apiMs)s API)" -ForegroundColor DarkGray -NoNewline
+ Write-Host " │ 🔧 $toolCount tools" -ForegroundColor DarkGray -NoNewline
+ Write-Host " │ 🔄 $turnCount turns" -ForegroundColor DarkGray
+ if ($filesChanged -gt 0 -or $linesAdded -gt 0 -or $linesRemoved -gt 0) {
+ Write-Host " │ 📝 $filesChanged files " -ForegroundColor DarkGray -NoNewline
+ Write-Host "+$linesAdded" -ForegroundColor Green -NoNewline
+ Write-Host "/" -ForegroundColor DarkGray -NoNewline
+ Write-Host "-$linesRemoved" -ForegroundColor Red
+ }
+ Write-Host " ╰──────────────────────────────────────────╯" -ForegroundColor DarkGray
}
- Write-Host " ╰──────────────────────────────────────────╯" -ForegroundColor DarkGray
}
}
+ } catch {
+ $cliLineData = Get-CopilotCliUsageLineData -Line $line
+ if ($cliLineData.Contains('aicUsed')) {
+ $cliAicUsed = $cliLineData.aicUsed
+ }
+ if ($cliLineData.Contains('contextWindow')) {
+ $cliContextWindow = $cliLineData.contextWindow
+ $cliContextWindowRaw = $cliLineData.contextWindowRaw
+ }
+ if ($cliLineData.Contains('model') -and -not [string]::IsNullOrWhiteSpace([string]$cliLineData.model)) {
+ $modelName = [string]$cliLineData.model
+ }
+
+ # Non-JSON line (e.g. stats) — pass through as-is
+ if ($line.Trim()) {
+ Write-Host " $line" -ForegroundColor DarkGray
+ }
}
- } catch {
- # Non-JSON line (e.g. stats) — pass through as-is
- if ($line.Trim()) {
- Write-Host " $line" -ForegroundColor DarkGray
+ }
+ } finally {
+ foreach ($key in $savedOtel.Keys) {
+ if ($null -eq $savedOtel[$key]) {
+ Remove-Item -Path ("env:" + $key) -ErrorAction SilentlyContinue
+ } else {
+ Set-Item -Path ("env:" + $key) -Value $savedOtel[$key]
}
}
}
$exitCode = $LASTEXITCODE
$stopwatch.Stop()
+ $endedAtUtc = [DateTimeOffset]::UtcNow
+ $otelMetrics = Get-CopilotOtelTokenMetrics -Path $otelPath
+
+ $usageRecord = New-CopilotTokenUsageRecord `
+ -PRNumber $PRNumber `
+ -Platform $Platform `
+ -Phase $Phase `
+ -StepName $StepName `
+ -ModelName $modelName `
+ -StartedAtUtc $startedAtUtc `
+ -EndedAtUtc $endedAtUtc `
+ -DurationMs $stopwatch.ElapsedMilliseconds `
+ -TurnCount $turnCount `
+ -ToolCount $toolCount `
+ -FailedToolCount (@($failedTools).Count) `
+ -Usage $resultUsage `
+ -OtelMetrics $otelMetrics `
+ -AicUsed $cliAicUsed `
+ -ContextWindow $cliContextWindow `
+ -ContextWindowRaw $cliContextWindowRaw `
+ -ResultEventSeen $resultEventSeen `
+ -ExitCode $exitCode
+ Write-CopilotTokenUsageRecord -OutputDir $TokenUsageOutputDir -Record $usageRecord
if ($exitCode -eq 0) {
Write-Host " ✅ $StepName completed" -ForegroundColor Green
@@ -1010,7 +1561,7 @@ if ($risksData -and ($risksData.result -eq 'REVERT' -or $risksData.result -eq 'O
# inline-stages architecture. Both phases are expensive (build the whole
# repo, run agents on multiple candidates) and we just need STEPs 1-3 +
# STEP 6 (post comment) to validate that detectedCategories /
-# aiSummaryCommentId output variables flow through to the new
+# aiSummaryReviewId output variables flow through to the new
# RunDeepUITests + UpdateAISummaryComment stages. Flip $skipGateAndTryFix
# back to $false (or delete the wrapper) once the new pipeline stages
# are validated end-to-end.
@@ -1058,6 +1609,22 @@ for ($gateAttempt = 1; $gateAttempt -le $maxGateAttempts; $gateAttempt++) {
if ($gateAttempt -gt 1) {
Write-Host " 🔄 Retry $gateAttempt/$maxGateAttempts — previous attempt hit environment error" -ForegroundColor Yellow
}
+ if (-not $DryRun) {
+ # Each verification attempt mutates fix files while testing the without-fix
+ # state. If an attempt aborts before restoring those files, retries must
+ # start from the committed review branch or they fail immediately with
+ # "uncommitted changes detected in fix files".
+ git checkout $reviewBranch 2>$null | Out-Null
+ if ($LASTEXITCODE -ne 0) {
+ Write-Error "Failed to checkout review branch '$reviewBranch' before gate attempt $gateAttempt."
+ exit 1
+ }
+ git reset --hard HEAD 2>$null | Out-Null
+ if ($LASTEXITCODE -ne 0) {
+ Write-Error "Failed to reset review branch '$reviewBranch' before gate attempt $gateAttempt."
+ exit 1
+ }
+ }
# Clear previous attempt's report so a crash mid-run doesn't leak its classification into this one.
Remove-Item $gateContentFile -Force -ErrorAction SilentlyContinue
# Note: -RequireFullVerification is intentionally OMITTED. The verify script
@@ -1639,9 +2206,9 @@ if (-not $DryRun) {
}
# ═════════════════════════════════════════════════════════════════════════════
-# STEP 6: Post AI Summary Comment (direct script invocation)
+# STEP 6: Post AI Summary Review (direct script invocation)
# When DEFER_COMMENT_TO_STAGE3=true, skip posting here — Stage 3
-# (UpdateAISummaryComment) will post the full comment after deep tests.
+# (UpdateAISummaryComment) will post the full review after deep tests.
# ═════════════════════════════════════════════════════════════════════════════
Write-Host ""
@@ -1655,11 +2222,12 @@ if ($env:DEFER_COMMENT_TO_STAGE3 -eq 'true') {
Write-Host " ⏭️ Deferred to Stage 3 (DEFER_COMMENT_TO_STAGE3=true)" -ForegroundColor Gray
Write-Host " ℹ️ Content files saved in CopilotLogs artifact" -ForegroundColor Gray
# Still emit a dummy output var so Stage 3 condition works
- Write-Host "##vso[task.setvariable variable=aiSummaryCommentId;isOutput=true]DEFERRED"
+ Write-Host "##vso[task.setvariable variable=aiSummaryReviewId;isOutput=true]DEFERRED"
} else {
# Post PR review phases (pre-flight, try-fix, report)
-$aiSummaryCommentId = $null
+$aiSummaryReviewId = $null
+$aiSummaryReviewNodeId = $null
$reviewScript = Join-Path $summaryScriptsDir "post-ai-summary-comment.ps1"
if (Test-Path $reviewScript) {
try {
@@ -1669,20 +2237,28 @@ if (Test-Path $reviewScript) {
} else {
$reviewOutput = & $reviewScript -PRNumber $PRNumber
}
- # Capture comment ID from script output (format: COMMENT_ID=)
- $idLine = $reviewOutput | Where-Object { $_ -match '^COMMENT_ID=' } | Select-Object -Last 1
- if ($idLine -match '^COMMENT_ID=(\d+)$') {
- $aiSummaryCommentId = $Matches[1]
- Write-Host " ✅ PR review summary posted (comment ID: $aiSummaryCommentId)" -ForegroundColor Green
+ # Capture review ID from script output (format: AI_SUMMARY_REVIEW_ID=)
+ $idLine = $reviewOutput | Where-Object { $_ -match '^AI_SUMMARY_REVIEW_ID=' } | Select-Object -Last 1
+ $nodeLine = $reviewOutput | Where-Object { $_ -match '^AI_SUMMARY_REVIEW_NODE_ID=' } | Select-Object -Last 1
+ if ($idLine -match '^AI_SUMMARY_REVIEW_ID=(\d+)$') {
+ $aiSummaryReviewId = $Matches[1]
+ if ($nodeLine -match '^AI_SUMMARY_REVIEW_NODE_ID=(.+)$') {
+ $aiSummaryReviewNodeId = $Matches[1]
+ }
+ Write-Host " ✅ PR review summary posted (review ID: $aiSummaryReviewId)" -ForegroundColor Green
- # Persist comment ID + PR number to a known location and emit
+ # Persist review ID + PR number to a known location and emit
# as an output variable so the downstream UpdateAISummaryComment
- # stage in ci-copilot.yml can rewrite the UI tests section once
+ # stage in ci-copilot.yml can rewrite the review body once
# the deep UI tests finish on the platform-pool agents.
- $commentIdFile = Join-Path $RepoRoot "CustomAgentLogsTmp/PRState/$PRNumber/PRAgent/ai-summary-comment-id.txt"
- New-Item -ItemType Directory -Force -Path (Split-Path -Parent $commentIdFile) | Out-Null
- $aiSummaryCommentId | Set-Content $commentIdFile -Encoding UTF8
- Write-Host "##vso[task.setvariable variable=aiSummaryCommentId;isOutput=true]$aiSummaryCommentId"
+ $reviewIdFile = Join-Path $RepoRoot "CustomAgentLogsTmp/PRState/$PRNumber/PRAgent/ai-summary-review-id.txt"
+ New-Item -ItemType Directory -Force -Path (Split-Path -Parent $reviewIdFile) | Out-Null
+ $aiSummaryReviewId | Set-Content $reviewIdFile -Encoding UTF8
+ if (-not [string]::IsNullOrWhiteSpace($aiSummaryReviewNodeId)) {
+ $aiSummaryReviewNodeId | Set-Content (Join-Path (Split-Path -Parent $reviewIdFile) "ai-summary-review-node-id.txt") -Encoding UTF8
+ Write-Host "##vso[task.setvariable variable=aiSummaryReviewNodeId;isOutput=true]$aiSummaryReviewNodeId"
+ }
+ Write-Host "##vso[task.setvariable variable=aiSummaryReviewId;isOutput=true]$aiSummaryReviewId"
} else {
Write-Host " ✅ PR review summary posted" -ForegroundColor Green
}
@@ -1693,7 +2269,7 @@ if (Test-Path $reviewScript) {
Write-Host " ⚠️ post-ai-summary-comment.ps1 not found — skipping review summary" -ForegroundColor Yellow
}
-} # END DEFER_COMMENT_TO_STAGE3 else block (summary comment only — inline findings + labels always run below)
+} # END DEFER_COMMENT_TO_STAGE3 else block (summary review only — inline findings + labels always run below)
# Determine winning candidate (winner.json) — drives whether we post inline findings or request changes
$winnerFile = Join-Path $RepoRoot "CustomAgentLogsTmp/PRState/$PRNumber/PRAgent/winner.json"
@@ -1730,8 +2306,8 @@ if (Test-Path $winnerFile) {
$isPRWinner = (-not $winner) -or ($winner.isPRFix -eq $true)
-if (Get-Command Remove-StaleMauiBotIssueComments -ErrorAction SilentlyContinue) {
- Remove-StaleMauiBotIssueComments `
+if (Get-Command Hide-StaleMauiBotIssueComments -ErrorAction SilentlyContinue) {
+ Hide-StaleMauiBotIssueComments `
-PRNumber $PRNumber `
-IncludeTryFix `
-Reason "stale try-fix notice"
@@ -1765,79 +2341,10 @@ if ($isPRWinner) {
}
}
} else {
- # Non-PR candidate won — submit a REQUEST_CHANGES review with the candidate diff in the body
- Write-Host " 📝 Non-PR candidate won — submitting REQUEST_CHANGES review with candidate diff..." -ForegroundColor Cyan
-
- $maxDiffBytes = 55KB
- $diff = [string]$winner.candidateDiff
- $truncated = $false
- # Truncate by binary-searching the largest character count whose UTF-8
- # encoding fits within the byte budget (reserving room for the marker line).
- # O(log n) and much cheaper than the previous O(n²) trim-512-and-recount loop.
- $marker = "`n... [truncated]"
- $markerBytes = [System.Text.Encoding]::UTF8.GetByteCount($marker)
- $budget = $maxDiffBytes - $markerBytes
- if ([System.Text.Encoding]::UTF8.GetByteCount($diff) -gt $maxDiffBytes) {
- $lo = 0
- $hi = $diff.Length
- while ($lo -lt $hi) {
- $mid = [int](($lo + $hi + 1) / 2)
- $bytes = [System.Text.Encoding]::UTF8.GetByteCount($diff.Substring(0, $mid))
- if ($bytes -le $budget) { $lo = $mid } else { $hi = $mid - 1 }
- }
- $diff = $diff.Substring(0, $lo) + $marker
- $truncated = $true
- }
-
- # Compute an outer code fence longer than any backtick run inside the diff
- # (minimum 4) so the diff content cannot accidentally close the fence and
- # leak into the surrounding markdown. Preserves the diff text exactly.
- $maxBacktickRun = 0
- foreach ($m in [regex]::Matches($diff, '`+')) {
- if ($m.Length -gt $maxBacktickRun) { $maxBacktickRun = $m.Length }
- }
- $fenceLen = [Math]::Max(4, $maxBacktickRun + 1)
- $fence = '`' * $fenceLen
-
- $rationale = if ($winner.summary) { [string]$winner.summary } else { "Automated review identified a stronger candidate fix." }
- $reviewBody = @"
-
-🤖 **Automated review — alternative fix proposed**
-
-The expert-reviewer evaluation compared the PR fix against $($winner.winner -replace 'try-fix-','#') automatically generated candidates and selected ``$($winner.winner)`` as the strongest fix.
-
-**Why:** $rationale
-
-Please consider applying the candidate diff below (or use it as guidance). Once you push an update, this workflow will re-trigger and re-evaluate.
-
-Candidate diff (``$($winner.winner)``)
-
-${fence}diff
-$diff
-$fence
-
-
-$( if ($truncated) { "`n_The diff was truncated to fit GitHub's review body limit._" } )
-"@
-
- if ($DryRun) {
- Write-Host " [DryRun] Would POST review state=REQUEST_CHANGES with body length $($reviewBody.Length)" -ForegroundColor Yellow
- } else {
- try {
- $bodyJson = @{ body = $reviewBody; event = 'REQUEST_CHANGES' } | ConvertTo-Json -Compress -Depth 5
- $tmp = New-TemporaryFile
- Set-Content -LiteralPath $tmp -Value $bodyJson -Encoding utf8 -NoNewline
- $resp = & gh api -X POST "repos/dotnet/maui/pulls/$PRNumber/reviews" --input $tmp 2>&1
- Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue
- if ($LASTEXITCODE -eq 0) {
- Write-Host " ✅ REQUEST_CHANGES review submitted" -ForegroundColor Green
- } else {
- Write-Host " ⚠️ Failed to submit REQUEST_CHANGES review (non-fatal): $resp" -ForegroundColor Yellow
- }
- } catch {
- Write-Host " ⚠️ REQUEST_CHANGES submission threw (non-fatal): $_" -ForegroundColor Yellow
- }
- }
+ # Non-PR candidate details are now merged into the unified AI Summary
+ # Future Action section. Avoid a second MauiBot review so the PR has one
+ # source of truth for automated review guidance.
+ Write-Host " ⏭️ Non-PR candidate selected; Future Action is included in AI Summary" -ForegroundColor Cyan
Write-Host " ⏭️ Skipping inline findings (winner is not the PR fix)" -ForegroundColor Gray
}
diff --git a/.github/scripts/post-ai-summary-comment.ps1 b/.github/scripts/post-ai-summary-comment.ps1
index 9f9f691de062..cb22f4de9481 100644
--- a/.github/scripts/post-ai-summary-comment.ps1
+++ b/.github/scripts/post-ai-summary-comment.ps1
@@ -1,13 +1,13 @@
#!/usr/bin/env pwsh
<#
.SYNOPSIS
- Posts the AI review summary comment on a GitHub Pull Request.
+ Posts the AI review summary as a GitHub Pull Request review.
.DESCRIPTION
- Maintains ONE comment per PR, identified by marker.
- Before posting a fresh comment, any older generated AI Summary comments are
- removed. The replacement comment contains only the latest review session,
- keyed by the current HEAD commit SHA.
+ Creates a new PR review per run, identified by marker.
+ Before posting a fresh review, older generated AI Summary artifacts are
+ hidden as outdated. The replacement review contains only the latest review
+ session, keyed by the current HEAD commit SHA.
After posting, the PR author is @-mentioned so they know to review.
@@ -16,18 +16,18 @@
CustomAgentLogsTmp/PRState//PRAgent/{pre-flight,try-fix,report}/content.md
CustomAgentLogsTmp/PRState//PRAgent/pre-flight/code-review.md
- Gate is included as a section inside this unified comment — the script may
+ Gate is included as a section inside this unified review body — the script may
be called by Review-PR.ps1 twice per run: once after the gate completes
(gate-only update) and once after the review phases finish (full update).
Any standalone legacy "" comment from older versions of
- the script is deleted before the fresh comment is posted to avoid duplicates.
+ the script is hidden before the fresh review is posted to avoid duplicates.
.PARAMETER PRNumber
The pull request number (required)
.PARAMETER DryRun
- Print comment instead of posting
+ Print review body instead of posting
.EXAMPLE
./post-ai-summary-comment.ps1 -PRNumber 12345
@@ -58,11 +58,11 @@ if (Test-Path $commentCleanupScript) {
Write-Host "ℹ️ Loading phase content for PR #$PRNumber..." -ForegroundColor Cyan
+$RepoRoot = git rev-parse --show-toplevel 2>$null
$PRAgentDir = "CustomAgentLogsTmp/PRState/$PRNumber/PRAgent"
if (-not (Test-Path $PRAgentDir)) {
- $repoRoot = git rev-parse --show-toplevel 2>$null
- if ($repoRoot) {
- $PRAgentDir = Join-Path $repoRoot "CustomAgentLogsTmp/PRState/$PRNumber/PRAgent"
+ if ($RepoRoot) {
+ $PRAgentDir = Join-Path $RepoRoot "CustomAgentLogsTmp/PRState/$PRNumber/PRAgent"
}
}
@@ -71,12 +71,12 @@ if (-not (Test-Path $PRAgentDir)) {
}
$phases = [ordered]@{
- "uitests" = @{ File = "uitests/content.md"; Icon = "🧪"; Title = "UI Tests" }
- "regression-check" = @{ File = "regression-check/content.md"; Icon = "🔍"; Title = "Regression Cross-Reference" }
- "pre-flight" = @{ File = "pre-flight/content.md"; Icon = "🔍"; Title = "Pre-Flight — Context & Validation" }
- "code-review" = @{ File = "pre-flight/code-review.md"; Icon = "🔬"; Title = "Code Review — Deep Analysis" }
- "try-fix" = @{ File = "try-fix/content.md"; Icon = "🔧"; Title = "Fix — Analysis & Comparison" }
- "report" = @{ File = "report/content.md"; Icon = "📋"; Title = "Report — Final Recommendation" }
+ "uitests" = @{ File = "uitests/content.md"; Title = "UI Tests" }
+ "regression-check" = @{ File = "regression-check/content.md"; Title = "Regression Cross-Reference" }
+ "pre-flight" = @{ File = "pre-flight/content.md"; Title = "Pre-Flight — Context & Validation" }
+ "code-review" = @{ File = "pre-flight/code-review.md"; Title = "Code Review — Deep Analysis" }
+ "try-fix" = @{ File = "try-fix/content.md"; Title = "Fix — Analysis & Comparison" }
+ "report" = @{ File = "report/content.md"; Title = "Report — Final Recommendation" }
}
function Test-PhaseContentIsNoOp {
@@ -107,16 +107,346 @@ function Test-PhaseContentIsNoOp {
}
}
-# ─── Gate content (rendered first, always open) ───
+function Get-AIReviewEvent {
+ param([string]$ReportContent)
+
+ if ([string]::IsNullOrWhiteSpace($ReportContent)) {
+ return 'COMMENT'
+ }
+
+ $normalized = $ReportContent -replace "`r`n", "`n"
+ if ($normalized -match '(?im)^\s*(?:##\s*)?(?:✅\s*)?Final\s+Recommendation:\s*APPROVE\s*$') {
+ return 'APPROVE'
+ }
+
+ if ($normalized -match '(?im)^\s*(?:##\s*)?(?:⚠️\s*)?Final\s+Recommendation:\s*REQUEST\s+CHANGES\s*$') {
+ return 'REQUEST_CHANGES'
+ }
+
+ return 'COMMENT'
+}
+
+function ConvertTo-TitleCase {
+ param([string]$Value)
+
+ if ([string]::IsNullOrWhiteSpace($Value)) {
+ return $Value
+ }
+
+ $trimmed = $Value.Trim()
+ switch -Regex ($trimmed) {
+ '(?i)^android$' { return 'Android' }
+ '(?i)^ios$' { return 'iOS' }
+ '(?i)^maccatalyst$' { return 'MacCatalyst' }
+ '(?i)^windows$' { return 'Windows' }
+ '(?i)^all$' { return 'All' }
+ }
+
+ return (Get-Culture).TextInfo.ToTitleCase($trimmed.ToLowerInvariant())
+}
+
+function ConvertTo-ShieldsSegment {
+ param([string]$Value)
+
+ $encoded = [uri]::EscapeDataString($Value)
+ return ($encoded -replace '-', '--' -replace '_', '__')
+}
+
+function New-StatusChip {
+ param(
+ [Parameter(Mandatory = $true)][string]$Label,
+ [Parameter(Mandatory = $true)][string]$Value,
+ [Parameter(Mandatory = $true)][string]$Color
+ )
+
+ $labelSegment = ConvertTo-ShieldsSegment $Label
+ $valueSegment = ConvertTo-ShieldsSegment $Value
+ $alt = "$Label $Value" -replace '"', '"'
+ return "
"
+}
+
+function Get-GateStatus {
+ param([string]$GateContent)
+
+ if ([string]::IsNullOrWhiteSpace($GateContent)) {
+ return 'Unknown'
+ }
+
+ if ($GateContent -match '(?im)Gate Result:\s*(?:\S+\s*)?(FAILED|PASSED|SKIPPED)') {
+ return ConvertTo-TitleCase $Matches[1]
+ }
+
+ if ($GateContent -match '(?i)\bfailed\b') { return 'Failed' }
+ if ($GateContent -match '(?i)\bpassed\b') { return 'Passed' }
+ if ($GateContent -match '(?i)\bskipped\b') { return 'Skipped' }
+ return 'Unknown'
+}
+
+function Get-ConfidenceStatus {
+ param([string[]]$Contents)
+
+ foreach ($content in $Contents) {
+ if ([string]::IsNullOrWhiteSpace($content)) {
+ continue
+ }
+
+ if ($content -match '(?im)\*\*Confidence:\*\*\s*(high|medium|low|unknown)') {
+ return ConvertTo-TitleCase $Matches[1]
+ }
+ if ($content -match '(?im)^Confidence:\s*(high|medium|low|unknown)') {
+ return ConvertTo-TitleCase $Matches[1]
+ }
+ }
+
+ return 'Unknown'
+}
+
+function Get-PlatformStatus {
+ param([string[]]$Contents)
+
+ foreach ($content in $Contents) {
+ if ([string]::IsNullOrWhiteSpace($content)) {
+ continue
+ }
+
+ if ($content -match '(?im)\*\*Platform:\*\*\s*([A-Za-z, /]+)') {
+ return ConvertTo-TitleCase (($Matches[1] -split '[,/]')[0])
+ }
+ if ($content -match '(?im)\*\*Platforms Affected:\*\*\s*([A-Za-z, /]+)') {
+ return ConvertTo-TitleCase (($Matches[1] -split '[,/]')[0])
+ }
+ }
+
+ return 'Unknown'
+}
+
+function New-StatusChipRow {
+ param(
+ [string]$GateStatus,
+ [string]$ReviewStatus,
+ [string]$Confidence,
+ [string]$Platform
+ )
+
+ $gateColor = switch ($GateStatus) {
+ 'Passed' { '1a7f37' }
+ 'Skipped' { 'bf8700' }
+ default { 'd1242f' }
+ }
+ $reviewColor = switch ($ReviewStatus) {
+ 'LGTM' { '1a7f37' }
+ 'Approved' { '1a7f37' }
+ 'Needs Changes' { 'd1242f' }
+ default { '0969da' }
+ }
+ $confidenceColor = switch ($Confidence) {
+ 'High' { '0969da' }
+ 'Medium' { 'bf8700' }
+ 'Low' { 'd1242f' }
+ default { '57606a' }
+ }
+ $platformColor = if ($Platform -eq 'Unknown') { '57606a' } else { '8250df' }
+
+ $chips = @(
+ (New-StatusChip -Label 'Gate' -Value $GateStatus -Color $gateColor),
+ (New-StatusChip -Label 'Code Review' -Value $ReviewStatus -Color $reviewColor),
+ (New-StatusChip -Label 'Confidence' -Value $Confidence -Color $confidenceColor),
+ (New-StatusChip -Label 'Platform' -Value $Platform -Color $platformColor)
+ )
+
+ return @"
+
+$($chips -join "`n")
+
+"@
+}
+
+function New-FutureActionSection {
+ param(
+ [Parameter(Mandatory = $true)][string]$PRAgentDir
+ )
+
+ $winnerFile = Join-Path $PRAgentDir "winner.json"
+ if (-not (Test-Path $winnerFile)) {
+ return @"
+---
+
+
+Future Action — review latest findings
+
+
+No alternative fix was selected for this run. Review the session findings and CI results before merging.
+
+
+"@
+ }
+
+ try {
+ $winner = Get-Content -Raw -LiteralPath $winnerFile -Encoding UTF8 | ConvertFrom-Json
+ } catch {
+ return @"
+---
+
+
+Future Action — review latest findings
+
+
+The workflow could not parse the fix-selection result. Review the session findings and CI results before merging.
+
+
+"@
+ }
+
+ if ($winner.isPRFix -eq $true -or [string]::IsNullOrWhiteSpace([string]$winner.winner)) {
+ return @"
+---
+
+
+Future Action — review latest findings
+
+
+No alternative fix was selected for this run. Review the session findings and CI results before merging.
+
+
+"@
+ }
+
+ $selected = [string]$winner.winner
+ $rationale = if ($winner.summary) { [string]$winner.summary } else { "Automated review identified a stronger candidate fix." }
+ $diff = [string]$winner.candidateDiff
+ $truncated = $false
+
+ if ([string]::IsNullOrWhiteSpace($diff)) {
+ $diff = "Candidate diff was not available in winner.json."
+ } else {
+ $maxDiffBytes = 55KB
+ $marker = "`n... [truncated]"
+ $markerBytes = [System.Text.Encoding]::UTF8.GetByteCount($marker)
+ $budget = $maxDiffBytes - $markerBytes
+ if ([System.Text.Encoding]::UTF8.GetByteCount($diff) -gt $maxDiffBytes) {
+ $lo = 0
+ $hi = $diff.Length
+ while ($lo -lt $hi) {
+ $mid = [int](($lo + $hi + 1) / 2)
+ $bytes = [System.Text.Encoding]::UTF8.GetByteCount($diff.Substring(0, $mid))
+ if ($bytes -le $budget) { $lo = $mid } else { $hi = $mid - 1 }
+ }
+ $diff = $diff.Substring(0, $lo) + $marker
+ $truncated = $true
+ }
+ }
+
+ $maxBacktickRun = 0
+ foreach ($m in [regex]::Matches($diff, '`+')) {
+ if ($m.Length -gt $maxBacktickRun) { $maxBacktickRun = $m.Length }
+ }
+ $fenceLen = [Math]::Max(4, $maxBacktickRun + 1)
+ $fence = '`' * $fenceLen
+ $truncatedNote = if ($truncated) { "`n_The diff was truncated to fit GitHub's review body limit._" } else { "" }
+
+ return @"
+---
+
+
+Future Action — alternative fix proposed ($selected)
+
+
+**Automated review — alternative fix proposed**
+
+The expert-reviewer evaluation compared the PR fix against automatically generated candidates and selected $selected as the strongest fix.
+
+**Why:** $rationale
+
+Please consider applying the candidate diff below (or use it as guidance). Once you push an update, this workflow will re-trigger and re-evaluate.
+
+Candidate diff ($selected)
+
+${fence}diff
+$diff
+$fence
+
+
+$truncatedNote
+
+
+"@
+}
+
+function Test-HasNonPRWinner {
+ param(
+ [Parameter(Mandatory = $true)][string]$PRAgentDir
+ )
+
+ $winnerFile = Join-Path $PRAgentDir "winner.json"
+ if (-not (Test-Path $winnerFile)) {
+ return $false
+ }
+
+ try {
+ $winner = Get-Content -Raw -LiteralPath $winnerFile -Encoding UTF8 | ConvertFrom-Json
+ return ($winner.isPRFix -eq $false -and -not [string]::IsNullOrWhiteSpace([string]$winner.winner))
+ } catch {
+ return $false
+ }
+}
+
+function Get-AIReviewEventForRun {
+ param(
+ [string]$ReportContent,
+
+ [Parameter(Mandatory = $true)]
+ [string]$PRAgentDir
+ )
+
+ $reviewEvent = Get-AIReviewEvent -ReportContent $ReportContent
+ if ((Test-HasNonPRWinner -PRAgentDir $PRAgentDir) -and $reviewEvent -eq 'COMMENT') {
+ return 'REQUEST_CHANGES'
+ }
+
+ return $reviewEvent
+}
+
+function Invoke-PostPullRequestReview {
+ param(
+ [Parameter(Mandatory = $true)]
+ [int]$PRNumber,
+
+ [Parameter(Mandatory = $true)]
+ [string]$Body,
+
+ [Parameter(Mandatory = $true)]
+ [ValidateSet('APPROVE', 'REQUEST_CHANGES', 'COMMENT')]
+ [string]$Event
+ )
+
+ $tempFile = [System.IO.Path]::GetTempFileName()
+ try {
+ @{ body = $Body; event = $Event } |
+ ConvertTo-Json -Depth 10 |
+ Set-Content -Path $tempFile -Encoding UTF8
+
+ $response = gh api --method POST "repos/dotnet/maui/pulls/$PRNumber/reviews" --input $tempFile 2>&1
+ if ($LASTEXITCODE -ne 0) {
+ throw "POST review failed (exit code $LASTEXITCODE): $response"
+ }
+
+ return (($response -join [Environment]::NewLine) | ConvertFrom-Json)
+ } finally {
+ Remove-Item $tempFile -ErrorAction SilentlyContinue
+ }
+}
+
+# ─── Gate content (rendered first, collapsed) ───
$gateSection = $null
+$gateContent = $null
$gateFilePath = Join-Path $PRAgentDir "gate/content.md"
if (Test-Path $gateFilePath) {
$gateContent = Get-Content $gateFilePath -Raw -Encoding UTF8
if (-not [string]::IsNullOrWhiteSpace($gateContent)) {
Write-Host " ✅ gate ($((Get-Item $gateFilePath).Length) bytes)" -ForegroundColor Green
$gateSection = @"
-
-🚦 Gate — Test Before & After Fix
+
+Gate — Test Before & After Fix
$gateContent
@@ -131,6 +461,7 @@ $gateContent
}
$phaseSections = @()
+$phaseContentByKey = @{}
foreach ($key in $phases.Keys) {
$phase = $phases[$key]
@@ -144,13 +475,14 @@ foreach ($key in $phases.Keys) {
continue
}
+ $phaseContentByKey[$key] = $content
Write-Host " ✅ $key ($((Get-Item $filePath).Length) bytes)" -ForegroundColor Green
# For uitests, make title dynamic: "UI Tests — Cat1, Cat2"
- $phaseTitle = "$($phase.Icon) $($phase.Title)"
+ $phaseTitle = $phase.Title
if ($key -eq "uitests") {
$catMatch = [regex]::Match($content, 'Detected UI test categories:\*\*\s*`{1,2}([^`]+)`{1,2}')
if ($catMatch.Success) {
- $phaseTitle = "$($phase.Icon) $($phase.Title) — $($catMatch.Groups[1].Value)"
+ $phaseTitle = "$($phase.Title) — $($catMatch.Groups[1].Value)"
}
}
$phaseSections += @"
@@ -174,6 +506,9 @@ if (-not $gateSection -and $phaseSections.Count -eq 0) {
throw "No gate or phase content found. Ensure at least one of gate/content.md or {phase}/content.md exists in $PRAgentDir."
}
+$reviewEvent = Get-AIReviewEventForRun -ReportContent $phaseContentByKey['report'] -PRAgentDir $PRAgentDir
+Write-Host " 🧾 PR review event: $reviewEvent" -ForegroundColor Cyan
+
# ============================================================================
# FETCH PR METADATA (commit + author)
# ============================================================================
@@ -200,7 +535,7 @@ $timestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd HH:mm UTC")
# BUILD NEW SESSION BLOCK
# ============================================================================
-# Combine gate (always first, open) with phases (collapsed). When only one
+# Combine gate (always first) with phases (collapsed). When only one
# kind of content is available, the session still renders cleanly.
$sessionParts = @()
if ($gateSection) { $sessionParts += $gateSection }
@@ -210,28 +545,25 @@ $phaseContent = $sessionParts -join "`n`n---`n`n"
$sessionMarkerStart = ""
$sessionMarkerEnd = ""
-# The latest session is built with ; when merged into existing
-# sessions the script re-tags only the newest as "open".
$newSessionBlock = @"
$sessionMarkerStart
-
-📊 Review Session — $commitSha7 · $commitTitle · $timestamp
+
+Review Sessions — click to expand
$phaseContent
----
-
$sessionMarkerEnd
"@
# ============================================================================
-# FIND EXISTING COMMENT & BUILD FINAL BODY
+# FIND EXISTING AI SUMMARY ARTIFACTS & BUILD FINAL BODY
# ============================================================================
-Write-Host "Checking for existing review comment..." -ForegroundColor Yellow
+Write-Host "Checking for existing AI Summary artifacts..." -ForegroundColor Yellow
$existingCommentIds = @()
+$existingReviewIds = @()
$existingBodies = @()
$existingRaw = gh api "repos/dotnet/maui/issues/$PRNumber/comments" --paginate 2>$null
@@ -242,7 +574,16 @@ if ($existingRaw) {
if ($existingObjs.Count -gt 0) {
$existingCommentIds = @($existingObjs | ForEach-Object { $_.id })
$existingBodies = @($existingObjs | ForEach-Object { [string]$_.body })
- Write-Host "✓ Found existing AI Summary comment(s): $($existingCommentIds -join ', ')" -ForegroundColor Green
+ Write-Host "✓ Found existing AI Summary issue comment(s): $($existingCommentIds -join ', ')" -ForegroundColor Green
+ }
+
+ if (Get-Command Get-GitHubPullRequestReviews -ErrorAction SilentlyContinue) {
+ $existingReviewObjs = @(Get-GitHubPullRequestReviews -PRNumber $PRNumber | Where-Object { $_.body -and $_.body.Contains($MARKER) })
+ if ($existingReviewObjs.Count -gt 0) {
+ $existingReviewIds = @($existingReviewObjs | ForEach-Object { $_.id })
+ $existingBodies += @($existingReviewObjs | ForEach-Object { [string]$_.body })
+ Write-Host "✓ Found existing AI Summary review(s): $($existingReviewIds -join ', ')" -ForegroundColor Green
+ }
}
} catch {
Write-Host "⚠️ Could not parse comments: $_" -ForegroundColor Yellow
@@ -251,34 +592,41 @@ if ($existingRaw) {
$authorPing = ""
if ($prAuthor) {
- $authorPing = "> 👋 @$prAuthor — new AI review results are available. Please review the latest session below."
+ $authorPing = "> @$prAuthor — new AI review results are available based on this last commit: $commitSha7.`n> **$commitTitle**"
+ $authorPing += ' To request a fresh review after new comments or commits, comment `/review rerun`.'
}
-$finalizeSection = ""
-$finalizePattern = '(?s)(.*?)'
-if ($existingBodies -and $existingBodies.Count -gt 0) {
- for ($i = $existingBodies.Count - 1; $i -ge 0; $i--) {
- if ($existingBodies[$i] -match $finalizePattern) {
- $finalizeSection = "`n`n" + $Matches[1]
- break
- }
- }
+$reviewStatus = switch ($reviewEvent) {
+ 'APPROVE' { 'LGTM' }
+ 'REQUEST_CHANGES' { 'Needs Changes' }
+ default { 'In Review' }
}
+$summaryContent = @($gateContent) + @($phaseContentByKey.Values)
+$statusChipRow = New-StatusChipRow `
+ -GateStatus (Get-GateStatus -GateContent $gateContent) `
+ -ReviewStatus $reviewStatus `
+ -Confidence (Get-ConfidenceStatus -Contents $summaryContent) `
+ -Platform (Get-PlatformStatus -Contents $summaryContent)
+$futureActionSection = New-FutureActionSection -PRAgentDir $PRAgentDir
$commentBody = @"
$MARKER
-## 🤖 AI Summary
+## AI Review Summary
$authorPing
-$newSessionBlock$finalizeSection
+$statusChipRow
+
+$newSessionBlock
+
+$futureActionSection
"@
# Clean up excessive blank lines
$commentBody = $commentBody -replace "`n{4,}", "`n`n`n"
-Write-Host " ✅ Built comment ($($commentBody.Length) chars)" -ForegroundColor Green
+Write-Host " ✅ Built review body ($($commentBody.Length) chars)" -ForegroundColor Green
# ============================================================================
# DRY RUN
@@ -286,6 +634,7 @@ Write-Host " ✅ Built comment ($($commentBody.Length) chars)" -ForegroundColor
if ($DryRun) {
Write-Host ""
+ Write-Host "Review event: $reviewEvent" -ForegroundColor Cyan
Write-Host "=== COMMENT PREVIEW ===" -ForegroundColor Cyan
Write-Host $commentBody
Write-Host "=== END PREVIEW ===" -ForegroundColor Cyan
@@ -293,35 +642,53 @@ if ($DryRun) {
}
# ============================================================================
-# DELETE STALE GENERATED COMMENTS, THEN POST COMMENT
+# HIDE STALE GENERATED ARTIFACTS, THEN POST REVIEW
# ============================================================================
-$tempFile = [System.IO.Path]::GetTempFileName()
+if (Get-Command Hide-StaleMauiBotIssueComments -ErrorAction SilentlyContinue) {
+ Hide-StaleMauiBotIssueComments `
+ -PRNumber $PRNumber `
+ -IncludeAISummary `
+ -IncludeLegacyGate `
+ -IncludeMergeConflict `
+ -IncludeTryFix `
+ -Reason "stale generated PR review artifact"
+}
+
+if (Get-Command Hide-StaleMauiBotPullRequestReviews -ErrorAction SilentlyContinue) {
+ Hide-StaleMauiBotPullRequestReviews `
+ -PRNumber $PRNumber `
+ -IncludeAISummary `
+ -IncludeTryFix `
+ -Reason "stale generated PR review" `
+ -DismissFormalReviews
+}
+
+Write-Host "Creating new AI Summary PR review ($reviewEvent)..." -ForegroundColor Yellow
+$postedEvent = $reviewEvent
try {
- @{ body = $commentBody } | ConvertTo-Json -Depth 10 | Set-Content -Path $tempFile -Encoding UTF8
-
- if (Get-Command Remove-StaleMauiBotIssueComments -ErrorAction SilentlyContinue) {
- Remove-StaleMauiBotIssueComments `
- -PRNumber $PRNumber `
- -IncludeAISummary `
- -IncludeLegacyGate `
- -IncludeMergeConflict `
- -IncludeTryFix `
- -Reason "stale generated PR review comment"
+ $review = Invoke-PostPullRequestReview -PRNumber $PRNumber -Body $commentBody -Event $postedEvent
+} catch {
+ if ($postedEvent -eq 'COMMENT') {
+ throw
}
- if (Get-Command Dismiss-StaleMauiBotTryFixReviews -ErrorAction SilentlyContinue) {
- Dismiss-StaleMauiBotTryFixReviews -PRNumber $PRNumber
- }
+ Write-Host "⚠️ Formal $postedEvent review was rejected; retrying as COMMENT: $_" -ForegroundColor Yellow
+ $postedEvent = 'COMMENT'
+ $review = Invoke-PostPullRequestReview -PRNumber $PRNumber -Body $commentBody -Event $postedEvent
+}
- Write-Host "Creating new review comment..." -ForegroundColor Yellow
- $newJson = gh api --method POST "repos/dotnet/maui/issues/$PRNumber/comments" --input $tempFile
- if ($LASTEXITCODE -ne 0) {
- throw "Failed to post AI Summary comment"
- }
- $newId = ($newJson | ConvertFrom-Json).id
- Write-Host "✅ Review comment posted (ID: $newId)" -ForegroundColor Green
- Write-Output "COMMENT_ID=$newId"
-} finally {
- Remove-Item $tempFile -ErrorAction SilentlyContinue
+$reviewId = [string]$review.id
+$reviewNodeId = [string]$review.node_id
+
+if (-not [string]::IsNullOrWhiteSpace($reviewId)) {
+ Set-Content -Path (Join-Path $PRAgentDir "ai-summary-review-id.txt") -Value $reviewId -Encoding UTF8
}
+if (-not [string]::IsNullOrWhiteSpace($reviewNodeId)) {
+ Set-Content -Path (Join-Path $PRAgentDir "ai-summary-review-node-id.txt") -Value $reviewNodeId -Encoding UTF8
+}
+
+Write-Host "✅ AI Summary PR review posted (ID: $reviewId, event: $postedEvent)" -ForegroundColor Green
+Write-Output "AI_SUMMARY_REVIEW_ID=$reviewId"
+Write-Output "AI_SUMMARY_REVIEW_NODE_ID=$reviewNodeId"
+Write-Output "AI_SUMMARY_REVIEW_EVENT=$postedEvent"
diff --git a/.github/scripts/shared/Aggregate-CopilotTokenUsage.ps1 b/.github/scripts/shared/Aggregate-CopilotTokenUsage.ps1
new file mode 100644
index 000000000000..7de28c850802
--- /dev/null
+++ b/.github/scripts/shared/Aggregate-CopilotTokenUsage.ps1
@@ -0,0 +1,382 @@
+#!/usr/bin/env pwsh
+<#
+.SYNOPSIS
+ Aggregates Copilot CLI usage telemetry into publishable artifacts.
+
+.DESCRIPTION
+ Reads raw telemetry records emitted by Review-PR.ps1 and writes JSON,
+ Markdown, CSV, and JSONL summaries. Missing input is treated as a valid
+ no-usage report so the publishing stage can still produce artifacts after
+ partial pipeline failures.
+#>
+
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory = $true)]
+ [string]$InputRoot,
+
+ [Parameter(Mandatory = $true)]
+ [string]$OutputDir,
+
+ [Parameter(Mandatory = $false)]
+ [string]$PRNumber,
+
+ [Parameter(Mandatory = $false)]
+ [string[]]$ExpectedStages = @(
+ 'ReviewPR',
+ 'RunDeepUITests',
+ 'UpdateAISummaryComment',
+ 'AnalyzeCopilotTokenUsage'
+ )
+)
+
+$ErrorActionPreference = 'Stop'
+
+function Get-ObjectMemberValue {
+ param(
+ [object]$InputObject,
+ [string[]]$Names
+ )
+
+ if ($null -eq $InputObject) { return $null }
+
+ foreach ($name in $Names) {
+ if ($InputObject -is [System.Collections.IDictionary] -and $InputObject.Contains($name)) {
+ return $InputObject[$name]
+ }
+
+ $property = $InputObject.PSObject.Properties[$name]
+ if ($property) {
+ return $property.Value
+ }
+ }
+
+ return $null
+}
+
+function Get-NestedValue {
+ param(
+ [object]$InputObject,
+ [string[]]$Path
+ )
+
+ $current = $InputObject
+ foreach ($segment in $Path) {
+ $current = Get-ObjectMemberValue -InputObject $current -Names @($segment)
+ if ($null -eq $current) { return $null }
+ }
+
+ return $current
+}
+
+function Get-NumericOrNull {
+ param([object]$Value)
+
+ if ($null -eq $Value) { return $null }
+ if ($Value -is [byte] -or
+ $Value -is [sbyte] -or
+ $Value -is [int16] -or
+ $Value -is [uint16] -or
+ $Value -is [int] -or
+ $Value -is [uint32] -or
+ $Value -is [long] -or
+ $Value -is [uint64] -or
+ $Value -is [float] -or
+ $Value -is [double] -or
+ $Value -is [decimal]) {
+ return [double]$Value
+ }
+
+ $parsed = 0.0
+ if ([double]::TryParse([string]$Value, [ref]$parsed)) {
+ return $parsed
+ }
+
+ return $null
+}
+
+function Get-NullableSum {
+ param([object[]]$Values)
+
+ $hasValue = $false
+ $sum = 0.0
+ foreach ($value in @($Values)) {
+ $numeric = Get-NumericOrNull -Value $value
+ if ($null -ne $numeric) {
+ $hasValue = $true
+ $sum += $numeric
+ }
+ }
+
+ if (-not $hasValue) { return $null }
+ return [long][Math]::Round($sum)
+}
+
+function Get-NullableDecimalSum {
+ param([object[]]$Values)
+
+ $hasValue = $false
+ $sum = 0.0
+ foreach ($value in @($Values)) {
+ $numeric = Get-NumericOrNull -Value $value
+ if ($null -ne $numeric) {
+ $hasValue = $true
+ $sum += $numeric
+ }
+ }
+
+ if (-not $hasValue) { return $null }
+ return [Math]::Round($sum, 3)
+}
+
+function Get-RecordStageName {
+ param([object]$Record)
+
+ $stageName = [string](Get-NestedValue -InputObject $Record -Path @('pipeline', 'stageName'))
+ if ([string]::IsNullOrWhiteSpace($stageName)) {
+ return 'ReviewPR'
+ }
+
+ return $stageName
+}
+
+function Get-RecordTokenValue {
+ param(
+ [object]$Record,
+ [string]$Name
+ )
+
+ return Get-NestedValue -InputObject $Record -Path @('normalizedTokens', $Name)
+}
+
+function Get-RecordAicUsed {
+ param([object]$Record)
+
+ return Get-NestedValue -InputObject $Record -Path @('cliUsage', 'aicUsed')
+}
+
+function Read-CopilotTokenUsageRecords {
+ param([string]$Root)
+
+ $records = New-Object System.Collections.ArrayList
+ if ([string]::IsNullOrWhiteSpace($Root) -or -not (Test-Path $Root)) {
+ return @()
+ }
+
+ $files = Get-ChildItem -Path $Root -Recurse -File -Filter 'copilot-token-usage-*.json' -ErrorAction SilentlyContinue |
+ Sort-Object FullName
+
+ foreach ($file in @($files)) {
+ try {
+ $record = Get-Content -Path $file.FullName -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop
+ $record | Add-Member -NotePropertyName sourceFile -NotePropertyValue $file.FullName -Force
+ [void]$records.Add($record)
+ } catch {
+ Write-Warning "Skipping malformed token usage record '$($file.FullName)': $_"
+ }
+ }
+
+ return @($records.ToArray())
+}
+
+function New-StageSummaryRows {
+ param(
+ [object[]]$Records,
+ [string[]]$ExpectedStages
+ )
+
+ $stageNames = New-Object System.Collections.ArrayList
+ foreach ($stage in @($ExpectedStages)) {
+ if (-not [string]::IsNullOrWhiteSpace($stage) -and -not $stageNames.Contains($stage)) {
+ [void]$stageNames.Add($stage)
+ }
+ }
+
+ foreach ($record in @($Records)) {
+ $stage = Get-RecordStageName -Record $record
+ if (-not $stageNames.Contains($stage)) {
+ [void]$stageNames.Add($stage)
+ }
+ }
+
+ $rows = New-Object System.Collections.ArrayList
+ foreach ($stage in @($stageNames.ToArray())) {
+ $stageRecords = @($Records | Where-Object { (Get-RecordStageName -Record $_) -eq $stage })
+ $hasRecords = $stageRecords.Count -gt 0
+ [void]$rows.Add([pscustomobject][ordered]@{
+ stageName = $stage
+ invocationCount = $stageRecords.Count
+ inputTokens = if ($hasRecords) { Get-NullableSum -Values @($stageRecords | ForEach-Object { Get-RecordTokenValue -Record $_ -Name 'inputTokens' }) } else { 0 }
+ outputTokens = if ($hasRecords) { Get-NullableSum -Values @($stageRecords | ForEach-Object { Get-RecordTokenValue -Record $_ -Name 'outputTokens' }) } else { 0 }
+ cachedInputTokens = if ($hasRecords) { Get-NullableSum -Values @($stageRecords | ForEach-Object { Get-RecordTokenValue -Record $_ -Name 'cachedInputTokens' }) } else { 0 }
+ totalTokens = if ($hasRecords) { Get-NullableSum -Values @($stageRecords | ForEach-Object { Get-RecordTokenValue -Record $_ -Name 'totalTokens' }) } else { 0 }
+ aicUsed = if ($hasRecords) { Get-NullableDecimalSum -Values @($stageRecords | ForEach-Object { Get-RecordAicUsed -Record $_ }) } else { 0 }
+ durationMs = if ($hasRecords) { Get-NullableSum -Values @($stageRecords | ForEach-Object { $_.durationMs }) } else { 0 }
+ apiDurationMs = if ($hasRecords) { Get-NullableSum -Values @($stageRecords | ForEach-Object { $_.apiDurationMs }) } else { 0 }
+ turnCount = if ($hasRecords) { Get-NullableSum -Values @($stageRecords | ForEach-Object { $_.turnCount }) } else { 0 }
+ toolCount = if ($hasRecords) { Get-NullableSum -Values @($stageRecords | ForEach-Object { $_.toolCount }) } else { 0 }
+ note = if ($hasRecords) { '' } else { 'No Copilot invocation observed in this stage.' }
+ })
+ }
+
+ return @($rows.ToArray())
+}
+
+function New-StepSummaryRows {
+ param([object[]]$Records)
+
+ $groups = @{}
+ foreach ($record in @($Records)) {
+ $stage = Get-RecordStageName -Record $record
+ $step = [string]$record.copilotStep
+ $model = [string]$record.model
+ $key = "$stage|$step|$model"
+ if (-not $groups.ContainsKey($key)) {
+ $groups[$key] = New-Object System.Collections.ArrayList
+ }
+ [void]$groups[$key].Add($record)
+ }
+
+ $rows = New-Object System.Collections.ArrayList
+ foreach ($key in ($groups.Keys | Sort-Object)) {
+ $items = @($groups[$key].ToArray())
+ $first = $items[0]
+ [void]$rows.Add([pscustomobject][ordered]@{
+ stageName = Get-RecordStageName -Record $first
+ scriptPhase = [string]$first.scriptPhase
+ copilotStep = [string]$first.copilotStep
+ model = [string]$first.model
+ invocationCount = $items.Count
+ inputTokens = Get-NullableSum -Values @($items | ForEach-Object { Get-RecordTokenValue -Record $_ -Name 'inputTokens' })
+ outputTokens = Get-NullableSum -Values @($items | ForEach-Object { Get-RecordTokenValue -Record $_ -Name 'outputTokens' })
+ totalTokens = Get-NullableSum -Values @($items | ForEach-Object { Get-RecordTokenValue -Record $_ -Name 'totalTokens' })
+ aicUsed = Get-NullableDecimalSum -Values @($items | ForEach-Object { Get-RecordAicUsed -Record $_ })
+ durationMs = Get-NullableSum -Values @($items | ForEach-Object { $_.durationMs })
+ apiDurationMs = Get-NullableSum -Values @($items | ForEach-Object { $_.apiDurationMs })
+ turnCount = Get-NullableSum -Values @($items | ForEach-Object { $_.turnCount })
+ toolCount = Get-NullableSum -Values @($items | ForEach-Object { $_.toolCount })
+ })
+ }
+
+ return @($rows.ToArray())
+}
+
+function New-CopilotTokenUsageSummary {
+ param(
+ [object[]]$Records,
+ [string[]]$ExpectedStages,
+ [string]$PRNumber
+ )
+
+ $stageRows = @(New-StageSummaryRows -Records $Records -ExpectedStages $ExpectedStages)
+ $stepRows = @(New-StepSummaryRows -Records $Records)
+
+ return [ordered]@{
+ schemaVersion = 1
+ generatedAtUtc = ([DateTimeOffset]::UtcNow).ToString('o')
+ prNumber = $PRNumber
+ costEstimateAvailable = $false
+ costEstimateNote = 'Dollar cost not calculated; no trusted rate table configured.'
+ recordCount = @($Records).Count
+ expectedStages = @($ExpectedStages)
+ totals = [ordered]@{
+ invocationCount = @($Records).Count
+ inputTokens = Get-NullableSum -Values @($Records | ForEach-Object { Get-RecordTokenValue -Record $_ -Name 'inputTokens' })
+ outputTokens = Get-NullableSum -Values @($Records | ForEach-Object { Get-RecordTokenValue -Record $_ -Name 'outputTokens' })
+ cachedInputTokens = Get-NullableSum -Values @($Records | ForEach-Object { Get-RecordTokenValue -Record $_ -Name 'cachedInputTokens' })
+ totalTokens = Get-NullableSum -Values @($Records | ForEach-Object { Get-RecordTokenValue -Record $_ -Name 'totalTokens' })
+ aicUsed = Get-NullableDecimalSum -Values @($Records | ForEach-Object { Get-RecordAicUsed -Record $_ })
+ durationMs = Get-NullableSum -Values @($Records | ForEach-Object { $_.durationMs })
+ apiDurationMs = Get-NullableSum -Values @($Records | ForEach-Object { $_.apiDurationMs })
+ turnCount = Get-NullableSum -Values @($Records | ForEach-Object { $_.turnCount })
+ toolCount = Get-NullableSum -Values @($Records | ForEach-Object { $_.toolCount })
+ }
+ stages = @($stageRows)
+ steps = @($stepRows)
+ }
+}
+
+function Format-UsageValue {
+ param([object]$Value)
+
+ if ($null -eq $Value -or [string]::IsNullOrWhiteSpace([string]$Value)) {
+ return 'n/a'
+ }
+
+ return [string]$Value
+}
+
+function New-CopilotTokenUsageMarkdown {
+ param([object]$Summary)
+
+ $lines = New-Object System.Collections.ArrayList
+ [void]$lines.Add('# Copilot token usage')
+ [void]$lines.Add('')
+ [void]$lines.Add("- PR: $(if ($Summary.prNumber) { $Summary.prNumber } else { 'n/a' })")
+ [void]$lines.Add("- Records: $($Summary.recordCount)")
+ [void]$lines.Add("- Cost estimate: not calculated (no trusted rate table configured)")
+ [void]$lines.Add('')
+ [void]$lines.Add('## Totals')
+ [void]$lines.Add('')
+ [void]$lines.Add('| Metric | Value |')
+ [void]$lines.Add('|---|---:|')
+ [void]$lines.Add("| Invocations | $($Summary.totals.invocationCount) |")
+ [void]$lines.Add("| Input tokens | $(Format-UsageValue $Summary.totals.inputTokens) |")
+ [void]$lines.Add("| Output tokens | $(Format-UsageValue $Summary.totals.outputTokens) |")
+ [void]$lines.Add("| Cached input tokens | $(Format-UsageValue $Summary.totals.cachedInputTokens) |")
+ [void]$lines.Add("| Total tokens | $(Format-UsageValue $Summary.totals.totalTokens) |")
+ [void]$lines.Add("| AIC used | $(Format-UsageValue $Summary.totals.aicUsed) |")
+ [void]$lines.Add("| Elapsed ms | $(Format-UsageValue $Summary.totals.durationMs) |")
+ [void]$lines.Add("| API duration ms | $(Format-UsageValue $Summary.totals.apiDurationMs) |")
+ [void]$lines.Add("| Turns | $(Format-UsageValue $Summary.totals.turnCount) |")
+ [void]$lines.Add("| Tools | $(Format-UsageValue $Summary.totals.toolCount) |")
+ [void]$lines.Add('')
+ [void]$lines.Add('## By stage')
+ [void]$lines.Add('')
+ [void]$lines.Add('| Stage | Invocations | Input | Output | Cached input | Total | AIC used | Elapsed ms | API ms | Note |')
+ [void]$lines.Add('|---|---:|---:|---:|---:|---:|---:|---:|---:|---|')
+ foreach ($stage in @($Summary.stages)) {
+ [void]$lines.Add("| $($stage.stageName) | $($stage.invocationCount) | $(Format-UsageValue $stage.inputTokens) | $(Format-UsageValue $stage.outputTokens) | $(Format-UsageValue $stage.cachedInputTokens) | $(Format-UsageValue $stage.totalTokens) | $(Format-UsageValue $stage.aicUsed) | $(Format-UsageValue $stage.durationMs) | $(Format-UsageValue $stage.apiDurationMs) | $($stage.note) |")
+ }
+ [void]$lines.Add('')
+ [void]$lines.Add('## By Copilot step')
+ [void]$lines.Add('')
+ if (@($Summary.steps).Count -eq 0) {
+ [void]$lines.Add('No Copilot invocations were recorded.')
+ } else {
+ [void]$lines.Add('| Stage | Phase | Step | Model | Invocations | Input | Output | Total | AIC used | Elapsed ms | API ms |')
+ [void]$lines.Add('|---|---|---|---|---:|---:|---:|---:|---:|---:|---:|')
+ foreach ($step in @($Summary.steps)) {
+ [void]$lines.Add("| $($step.stageName) | $($step.scriptPhase) | $($step.copilotStep) | $($step.model) | $($step.invocationCount) | $(Format-UsageValue $step.inputTokens) | $(Format-UsageValue $step.outputTokens) | $(Format-UsageValue $step.totalTokens) | $(Format-UsageValue $step.aicUsed) | $(Format-UsageValue $step.durationMs) | $(Format-UsageValue $step.apiDurationMs) |")
+ }
+ }
+
+ return ($lines -join [Environment]::NewLine) + [Environment]::NewLine
+}
+
+New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null
+
+$records = @(Read-CopilotTokenUsageRecords -Root $InputRoot)
+$summary = New-CopilotTokenUsageSummary -Records $records -ExpectedStages $ExpectedStages -PRNumber $PRNumber
+
+$rawJsonlPath = Join-Path $OutputDir 'token-usage-raw.jsonl'
+if ($records.Count -gt 0) {
+ $records | ForEach-Object { $_ | ConvertTo-Json -Depth 50 -Compress } |
+ Set-Content -Path $rawJsonlPath -Encoding UTF8
+} else {
+ '' | Set-Content -Path $rawJsonlPath -Encoding UTF8
+}
+
+$summary | ConvertTo-Json -Depth 50 | Set-Content -Path (Join-Path $OutputDir 'token-usage-summary.json') -Encoding UTF8
+New-CopilotTokenUsageMarkdown -Summary $summary | Set-Content -Path (Join-Path $OutputDir 'token-usage-summary.md') -Encoding UTF8
+
+$csvPath = Join-Path $OutputDir 'token-usage-by-step.csv'
+if (@($summary.steps).Count -gt 0) {
+ @($summary.steps) | Export-Csv -Path $csvPath -NoTypeInformation -Encoding UTF8
+} else {
+ 'stageName,scriptPhase,copilotStep,model,invocationCount,inputTokens,outputTokens,totalTokens,aicUsed,durationMs,apiDurationMs,turnCount,toolCount' |
+ Set-Content -Path $csvPath -Encoding UTF8
+}
+
+Write-Host "Copilot token usage records: $($summary.recordCount)"
+Write-Host "Copilot token usage artifact directory: $OutputDir"
diff --git a/.github/scripts/shared/Detect-TestsInDiff.ps1 b/.github/scripts/shared/Detect-TestsInDiff.ps1
index b7a141d4e034..daf638970163 100644
--- a/.github/scripts/shared/Detect-TestsInDiff.ps1
+++ b/.github/scripts/shared/Detect-TestsInDiff.ps1
@@ -107,6 +107,7 @@ $UnitTestProjects = @{
"SourceGen.UnitTests" = "src/Controls/tests/SourceGen.UnitTests/"
"Core.UnitTests" = "src/Core/tests/UnitTests/"
"Essentials.UnitTests" = "src/Essentials/test/UnitTests/"
+ "MauiBlazorWebView.UnitTests" = "src/BlazorWebView/tests/MauiBlazorWebView.UnitTests/"
"Graphics.Tests" = "src/Graphics/tests/Graphics.Tests/"
"Resizetizer.UnitTests" = "src/SingleProject/Resizetizer/test/UnitTests/"
"Compatibility.Core.UnitTests" = "src/Compatibility/Core/tests/Compatibility.UnitTests/"
@@ -120,6 +121,7 @@ $UnitTestProjectPaths = @{
"SourceGen.UnitTests" = "src/Controls/tests/SourceGen.UnitTests/SourceGen.UnitTests.csproj"
"Core.UnitTests" = "src/Core/tests/UnitTests/Core.UnitTests.csproj"
"Essentials.UnitTests" = "src/Essentials/test/UnitTests/Essentials.UnitTests.csproj"
+ "MauiBlazorWebView.UnitTests" = "src/BlazorWebView/tests/MauiBlazorWebView.UnitTests/MauiBlazorWebView.UnitTests.csproj"
"Graphics.Tests" = "src/Graphics/tests/Graphics.Tests/Graphics.Tests.csproj"
"Resizetizer.UnitTests" = "src/SingleProject/Resizetizer/test/UnitTests/Resizetizer.UnitTests.csproj"
"Compatibility.Core.UnitTests" = "src/Compatibility/Core/tests/Compatibility.UnitTests/Compatibility.Core.UnitTests.csproj"
diff --git a/.github/scripts/shared/Remove-StaleMauiBotComments.ps1 b/.github/scripts/shared/Remove-StaleMauiBotComments.ps1
index 850801483647..1ca3cbe16a8e 100644
--- a/.github/scripts/shared/Remove-StaleMauiBotComments.ps1
+++ b/.github/scripts/shared/Remove-StaleMauiBotComments.ps1
@@ -41,11 +41,94 @@ function Test-IsTryFixCommentBody {
return $false
}
+ if ($Body.Contains($script:AiSummaryCommentMarker)) {
+ return $false
+ }
+
return $Body.Contains($script:TryFixCommentMarker) -or
($Body.Contains('Automated review') -and $Body.Contains('alternative fix proposed')) -or
($Body.Contains('try-fix-') -and $Body.Contains('Candidate diff'))
}
+function Test-IsAISummaryCommentBody {
+ param([string]$Body)
+
+ if ([string]::IsNullOrWhiteSpace($Body)) {
+ return $false
+ }
+
+ return $Body.Contains($script:AiSummaryCommentMarker)
+}
+
+function Test-ShouldPreserveMauiBotArtifact {
+ param(
+ [object]$Artifact,
+ [string[]]$PreserveNodeIds = @(),
+ [string[]]$PreserveIds = @()
+ )
+
+ $nodeId = [string]$Artifact.node_id
+ $id = [string]$Artifact.id
+
+ return (
+ (-not [string]::IsNullOrWhiteSpace($nodeId) -and $PreserveNodeIds -contains $nodeId) -or
+ (-not [string]::IsNullOrWhiteSpace($id) -and $PreserveIds -contains $id)
+ )
+}
+
+function Invoke-GitHubMinimizeComment {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$SubjectNodeId,
+
+ [ValidateSet('SPAM', 'ABUSE', 'OFF_TOPIC', 'OUTDATED', 'DUPLICATE', 'RESOLVED', 'LOW_QUALITY')]
+ [string]$Classifier = 'OUTDATED',
+
+ [string]$Reason = 'stale MauiBot artifact',
+
+ [switch]$DryRun
+ )
+
+ if ([string]::IsNullOrWhiteSpace($SubjectNodeId)) {
+ Write-Host " Warning: cannot hide $Reason because node_id is empty" -ForegroundColor Yellow
+ return $false
+ }
+
+ if ($DryRun) {
+ Write-Host " [DryRun] Would hide $Reason (node_id: $SubjectNodeId, classifier: $Classifier)" -ForegroundColor Magenta
+ return $true
+ }
+
+ $query = @'
+mutation MinimizeComment($subjectId: ID!, $classifier: ReportedContentClassifiers!) {
+ minimizeComment(input: { subjectId: $subjectId, classifier: $classifier }) {
+ minimizedComment {
+ isMinimized
+ minimizedReason
+ }
+ }
+}
+'@
+
+ try {
+ Write-Host " Hiding $Reason (node_id: $SubjectNodeId, classifier: $Classifier)..." -ForegroundColor Gray
+ $output = gh api graphql `
+ -f query="$query" `
+ -F subjectId="$SubjectNodeId" `
+ -F classifier="$Classifier" 2>&1
+
+ if ($LASTEXITCODE -ne 0) {
+ throw "minimizeComment failed (exit code $LASTEXITCODE): $output"
+ }
+
+ return $true
+ } catch {
+ Write-Host " Warning: could not hide $Reason with node_id ${SubjectNodeId}: $_" -ForegroundColor Yellow
+ return $false
+ }
+}
+
function Get-GitHubIssueComments {
param([Parameter(Mandatory = $true)][int]$PRNumber)
@@ -62,7 +145,7 @@ function Get-GitHubIssueComments {
}
}
-function Remove-StaleMauiBotIssueComments {
+function Hide-StaleMauiBotIssueComments {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
@@ -73,6 +156,12 @@ function Remove-StaleMauiBotIssueComments {
[switch]$IncludeMergeConflict,
[switch]$IncludeTryFix,
+ [string[]]$PreserveNodeIds = @(),
+ [string[]]$PreserveIds = @(),
+
+ [ValidateSet('SPAM', 'ABUSE', 'OFF_TOPIC', 'OUTDATED', 'DUPLICATE', 'RESOLVED', 'LOW_QUALITY')]
+ [string]$Classifier = 'OUTDATED',
+
[string]$Reason = 'stale MauiBot comment',
[switch]$DryRun
)
@@ -84,13 +173,17 @@ function Remove-StaleMauiBotIssueComments {
$staleComments = @()
foreach ($comment in $comments) {
+ if (Test-ShouldPreserveMauiBotArtifact -Artifact $comment -PreserveNodeIds $PreserveNodeIds -PreserveIds $PreserveIds) {
+ continue
+ }
+
$body = [string]$comment.body
if ([string]::IsNullOrWhiteSpace($body)) {
continue
}
$matchesGeneratedMarker =
- ($IncludeAISummary -and $body.Contains($script:AiSummaryCommentMarker)) -or
+ ($IncludeAISummary -and (Test-IsAISummaryCommentBody $body)) -or
($IncludeLegacyGate -and $body.Contains($script:AiGateCommentMarker))
$matchesBotOnlyContent =
@@ -105,23 +198,48 @@ function Remove-StaleMauiBotIssueComments {
}
foreach ($comment in $staleComments) {
- if ($DryRun) {
- Write-Host " [DryRun] Would delete $Reason (comment ID: $($comment.id))" -ForegroundColor Magenta
- continue
- }
-
- try {
- Write-Host " Deleting $Reason (comment ID: $($comment.id))..." -ForegroundColor Gray
- $deleteOutput = gh api --method DELETE "repos/dotnet/maui/issues/comments/$($comment.id)" 2>&1
- if ($LASTEXITCODE -ne 0) {
- throw "DELETE failed (exit code $LASTEXITCODE): $deleteOutput"
- }
- } catch {
- Write-Host " Warning: could not delete $Reason comment $($comment.id): $_" -ForegroundColor Yellow
- }
+ Invoke-GitHubMinimizeComment `
+ -SubjectNodeId ([string]$comment.node_id) `
+ -Classifier $Classifier `
+ -Reason "$Reason comment $($comment.id)" `
+ -DryRun:$DryRun | Out-Null
}
}
+function Remove-StaleMauiBotIssueComments {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]
+ [int]$PRNumber,
+
+ [switch]$IncludeAISummary,
+ [switch]$IncludeLegacyGate,
+ [switch]$IncludeMergeConflict,
+ [switch]$IncludeTryFix,
+
+ [string[]]$PreserveNodeIds = @(),
+ [string[]]$PreserveIds = @(),
+
+ [ValidateSet('SPAM', 'ABUSE', 'OFF_TOPIC', 'OUTDATED', 'DUPLICATE', 'RESOLVED', 'LOW_QUALITY')]
+ [string]$Classifier = 'OUTDATED',
+
+ [string]$Reason = 'stale MauiBot comment',
+ [switch]$DryRun
+ )
+
+ Hide-StaleMauiBotIssueComments `
+ -PRNumber $PRNumber `
+ -IncludeAISummary:$IncludeAISummary `
+ -IncludeLegacyGate:$IncludeLegacyGate `
+ -IncludeMergeConflict:$IncludeMergeConflict `
+ -IncludeTryFix:$IncludeTryFix `
+ -PreserveNodeIds $PreserveNodeIds `
+ -PreserveIds $PreserveIds `
+ -Classifier $Classifier `
+ -Reason $Reason `
+ -DryRun:$DryRun
+}
+
function Get-GitHubPullRequestReviews {
param([Parameter(Mandatory = $true)][int]$PRNumber)
@@ -138,13 +256,63 @@ function Get-GitHubPullRequestReviews {
}
}
-function Dismiss-StaleMauiBotTryFixReviews {
+function Dismiss-MauiBotPullRequestReview {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[int]$PRNumber,
- [string]$Reason = 'superseded MauiBot try-fix review',
+ [Parameter(Mandatory = $true)]
+ [object]$Review,
+
+ [string]$Reason = 'Superseded by a newer MauiBot review run.',
+ [switch]$DryRun
+ )
+
+ if ($DryRun) {
+ Write-Host " [DryRun] Would dismiss stale review ID $($Review.id)" -ForegroundColor Magenta
+ return $true
+ }
+
+ $tmp = New-TemporaryFile
+ try {
+ @{ message = $Reason } |
+ ConvertTo-Json -Compress |
+ Set-Content -LiteralPath $tmp -Encoding UTF8 -NoNewline
+
+ Write-Host " Dismissing stale review ID $($Review.id)..." -ForegroundColor Gray
+ $dismissOutput = gh api --method PUT "repos/dotnet/maui/pulls/$PRNumber/reviews/$($Review.id)/dismissals" --input $tmp.FullName 2>&1
+ if ($LASTEXITCODE -ne 0) {
+ throw "dismissal failed (exit code $LASTEXITCODE): $dismissOutput"
+ }
+
+ return $true
+ } catch {
+ Write-Host " Warning: could not dismiss review $($Review.id): $_" -ForegroundColor Yellow
+ return $false
+ } finally {
+ Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue
+ }
+}
+
+function Hide-StaleMauiBotPullRequestReviews {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]
+ [int]$PRNumber,
+
+ [switch]$IncludeAISummary,
+ [switch]$IncludeTryFix,
+
+ [string[]]$PreserveNodeIds = @(),
+ [string[]]$PreserveIds = @(),
+
+ [ValidateSet('SPAM', 'ABUSE', 'OFF_TOPIC', 'OUTDATED', 'DUPLICATE', 'RESOLVED', 'LOW_QUALITY')]
+ [string]$Classifier = 'OUTDATED',
+
+ [string]$Reason = 'stale MauiBot review',
+ [switch]$DismissChangesRequested,
+ [switch]$DismissFormalReviews,
[switch]$DryRun
)
@@ -153,33 +321,68 @@ function Dismiss-StaleMauiBotTryFixReviews {
return
}
- $staleReviews = @($reviews | Where-Object {
- (Test-IsMauiBotCommentAuthor $_) -and
- ([string]$_.state -ieq 'CHANGES_REQUESTED') -and
- (Test-IsTryFixCommentBody ([string]$_.body))
- })
+ $staleReviews = @()
+ foreach ($review in $reviews) {
+ if (Test-ShouldPreserveMauiBotArtifact -Artifact $review -PreserveNodeIds $PreserveNodeIds -PreserveIds $PreserveIds) {
+ continue
+ }
- foreach ($review in $staleReviews) {
- if ($DryRun) {
- Write-Host " [DryRun] Would dismiss $Reason (review ID: $($review.id))" -ForegroundColor Magenta
+ $body = [string]$review.body
+ if ([string]::IsNullOrWhiteSpace($body) -or -not (Test-IsMauiBotCommentAuthor $review)) {
continue
}
- $tmp = New-TemporaryFile
- try {
- @{ message = 'Superseded by a newer MauiBot review run.' } |
- ConvertTo-Json -Compress |
- Set-Content -LiteralPath $tmp -Encoding UTF8 -NoNewline
-
- Write-Host " Dismissing $Reason (review ID: $($review.id))..." -ForegroundColor Gray
- $dismissOutput = gh api --method PUT "repos/dotnet/maui/pulls/$PRNumber/reviews/$($review.id)/dismissals" --input $tmp.FullName 2>&1
- if ($LASTEXITCODE -ne 0) {
- throw "dismissal failed (exit code $LASTEXITCODE): $dismissOutput"
- }
- } catch {
- Write-Host " Warning: could not dismiss $Reason review $($review.id): $_" -ForegroundColor Yellow
- } finally {
- Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue
+ $matchesReview =
+ ($IncludeAISummary -and (Test-IsAISummaryCommentBody $body)) -or
+ ($IncludeTryFix -and (Test-IsTryFixCommentBody $body))
+
+ if ($matchesReview) {
+ $staleReviews += $review
+ }
+ }
+
+ foreach ($review in $staleReviews) {
+ Invoke-GitHubMinimizeComment `
+ -SubjectNodeId ([string]$review.node_id) `
+ -Classifier $Classifier `
+ -Reason "$Reason review $($review.id)" `
+ -DryRun:$DryRun | Out-Null
+
+ $reviewState = [string]$review.state
+ $shouldDismiss =
+ ($DismissFormalReviews -and $reviewState -in @('APPROVED', 'CHANGES_REQUESTED')) -or
+ ($DismissChangesRequested -and $reviewState -ieq 'CHANGES_REQUESTED')
+
+ if ($shouldDismiss) {
+ Dismiss-MauiBotPullRequestReview `
+ -PRNumber $PRNumber `
+ -Review $review `
+ -Reason 'Superseded by a newer MauiBot review run.' `
+ -DryRun:$DryRun | Out-Null
}
}
}
+
+function Dismiss-StaleMauiBotTryFixReviews {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]
+ [int]$PRNumber,
+
+ [string[]]$PreserveNodeIds = @(),
+ [string[]]$PreserveIds = @(),
+
+ [string]$Reason = 'superseded MauiBot try-fix review',
+ [switch]$DryRun
+ )
+
+ Hide-StaleMauiBotPullRequestReviews `
+ -PRNumber $PRNumber `
+ -IncludeTryFix `
+ -PreserveNodeIds $PreserveNodeIds `
+ -PreserveIds $PreserveIds `
+ -Classifier OUTDATED `
+ -Reason $Reason `
+ -DismissChangesRequested `
+ -DryRun:$DryRun
+}
diff --git a/.github/skills/code-review/SKILL.md b/.github/skills/code-review/SKILL.md
index 1a8d1bb0b37b..dc252d78a705 100644
--- a/.github/skills/code-review/SKILL.md
+++ b/.github/skills/code-review/SKILL.md
@@ -200,13 +200,13 @@ pwsh .github/scripts/post-inline-review.ps1 -PRNumber -DryRun
pwsh .github/scripts/post-inline-review.ps1 -PRNumber
```
-**Wall-of-text summary** (phase content assembled into a single PR comment):
+**Wall-of-text summary** (phase content assembled into a PR review body):
```bash
# Called by Review-PR.ps1 automatically:
pwsh .github/scripts/post-ai-summary-comment.ps1
```
-In CI (`eng/pipelines/ci-copilot.yml`), `Review-PR.ps1` calls both `post-inline-review.ps1` (for inline findings) and `post-ai-summary-comment.ps1` (for the wall-of-text from `{phase}/content.md` files), using `GH_COMMENT_TOKEN`.
+In CI (`eng/pipelines/ci-copilot.yml`), `Review-PR.ps1` calls both `post-inline-review.ps1` (for inline findings) and `post-ai-summary-comment.ps1` (for the wall-of-text from `{phase}/content.md` files), using `GH_COMMENT_TOKEN`. The trusted posting script may submit `APPROVE` or `REQUEST_CHANGES` from the final recommendation; the agent itself must not run review commands directly.
---
diff --git a/.github/skills/find-regression-risk/SKILL.md b/.github/skills/find-regression-risk/SKILL.md
index 506e1ff74634..d458232359e8 100644
--- a/.github/skills/find-regression-risk/SKILL.md
+++ b/.github/skills/find-regression-risk/SKILL.md
@@ -49,7 +49,7 @@ When `-OutputDir` is specified:
## Integration
-The script runs as **STEP 4** in `Review-PR.ps1` (Regression Cross-Reference, after UI test detection and before the Gate step). Its `content.md` is assembled into the AI summary comment by `post-ai-summary-comment.ps1`.
+The script runs as **STEP 4** in `Review-PR.ps1` (Regression Cross-Reference, after UI test detection and before the Gate step). Its `content.md` is assembled into the AI summary review by `post-ai-summary-comment.ps1`.
When REVERT risks are detected, the regression tests from the reverted fix PRs are executed:
- **UI tests** → `BuildAndRunHostApp.ps1 -Platform -TestFilter `
diff --git a/.github/skills/pr-finalize/SKILL.md b/.github/skills/pr-finalize/SKILL.md
index 9932ac6534c5..3dc0d6ec33a2 100644
--- a/.github/skills/pr-finalize/SKILL.md
+++ b/.github/skills/pr-finalize/SKILL.md
@@ -39,7 +39,7 @@ Ensures PR title and description accurately reflect the implementation, and perf
**Correct workflow:**
1. **This skill**: Analyze PR, produce findings and write to `pr-finalize-summary.md`
-2. **Review-PR.ps1** calls `post-pr-finalize-comment.ps1` to post the summary
+2. **Human-controlled follow-up**: PR finalization is not part of the automated `Review-PR.ps1` flow. Only post or use the summary when a user explicitly asks for PR finalization.
**Only humans control when comments are posted.** Your job is to analyze and present findings.
@@ -366,7 +366,7 @@ gh pr diff XXXXX -- path/to/file.cs
**Workflow:**
1. **This skill**: Analyze PR, produce findings and write to `pr-finalize-summary.md`
-2. **Review-PR.ps1** calls `post-pr-finalize-comment.ps1` to post the summary
+2. **Human-controlled follow-up**: PR finalization is not part of the automated `Review-PR.ps1` flow. Only post or use the summary when a user explicitly asks for PR finalization.
The user controls when comments are posted. Your job is to analyze and present findings.
diff --git a/.github/skills/run-device-tests/SKILL.md b/.github/skills/run-device-tests/SKILL.md
index 5ad415535a11..1a4dc10098da 100644
--- a/.github/skills/run-device-tests/SKILL.md
+++ b/.github/skills/run-device-tests/SKILL.md
@@ -157,7 +157,7 @@ pwsh .github/skills/run-device-tests/scripts/Run-DeviceTests.ps1 -Project Core -
- Windows tests run directly on the local machine
- Simulator/emulator selection and boot logic is handled by `.github/scripts/shared/Start-Emulator.ps1`
- xharness manages test execution and reporting for iOS/MacCatalyst/Android
-- Windows uses vstest for test execution
+- Windows runs the built device-test app directly and reads its xUnit XML results, matching `eng/devices/windows.cake`
## Test Filtering
@@ -191,7 +191,7 @@ Test filtering is implemented in `src/Core/tests/DeviceTests.Shared/DeviceTestSh
|----------|---------------------|-------------------|
| **iOS/MacCatalyst** | `--set-env=TestFilter=...` | `NSProcessInfo.ProcessInfo.Environment["TestFilter"]` |
| **Android** | `--arg TestFilter=...` | `MauiTestInstrumentation.Current.Arguments.GetString("TestFilter")` |
-| **Windows** | `--filter "Category=..."` | Native vstest filter |
+| **Windows Controls** | App argument selects discovered category index | `ControlsHeadlessTestRunner` category loop |
### Available Test Categories
@@ -258,7 +258,7 @@ The script automatically handles XHarness device targeting for iOS and Android:
### Windows
- No device/emulator needed
-- Uses vstest (`dotnet test`) for test execution
+- Runs the built device-test app directly and parses `TestResults-*.xml`
**Why both --target and --device for iOS?**
- XHarness requires `--target ios-simulator-64` (or `ios-simulator-64_VERSION`) to specify platform type
diff --git a/.github/skills/run-device-tests/scripts/Run-DeviceTests.Tests.ps1 b/.github/skills/run-device-tests/scripts/Run-DeviceTests.Tests.ps1
new file mode 100644
index 000000000000..5590ed3deea2
--- /dev/null
+++ b/.github/skills/run-device-tests/scripts/Run-DeviceTests.Tests.ps1
@@ -0,0 +1,86 @@
+#!/usr/bin/env pwsh
+#Requires -Modules Pester
+
+BeforeAll {
+ $scriptPath = Join-Path $PSScriptRoot 'Run-DeviceTests.ps1'
+ $tokens = $null
+ $parseErrors = $null
+ $ast = [System.Management.Automation.Language.Parser]::ParseFile($scriptPath, [ref]$tokens, [ref]$parseErrors)
+ if ($parseErrors -and $parseErrors.Count -gt 0) {
+ throw ($parseErrors | ForEach-Object { $_.Message }) -join [Environment]::NewLine
+ }
+
+ foreach ($functionName in @(
+ 'Get-CategoryFiltersFromTestFilter',
+ 'Select-WindowsDeviceTestCategories',
+ 'Get-WindowsDeviceTestResultSummary'
+ )) {
+ $function = $ast.Find({
+ $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] -and
+ $args[0].Name -eq $functionName
+ }, $true)
+
+ if (-not $function) {
+ throw "Function '$functionName' not found"
+ }
+
+ Invoke-Expression $function.Extent.Text
+ }
+}
+
+Describe 'Windows device test category filtering' {
+ It 'extracts Category filters from VSTest-style expressions' {
+ Get-CategoryFiltersFromTestFilter -Filter 'Category=Window|Category=Button' |
+ Should -Be @('Window', 'Button')
+ }
+
+ It 'selects matching discovered categories case-insensitively' {
+ Select-WindowsDeviceTestCategories `
+ -AllCategories @('Button', 'Window', 'Shell') `
+ -Filter 'Category=window' |
+ Should -Be @('Window')
+ }
+
+ It 'returns all categories when no category filter is supplied' {
+ Select-WindowsDeviceTestCategories `
+ -AllCategories @('Button', 'Window') `
+ -Filter '' |
+ Should -Be @('Button', 'Window')
+ }
+}
+
+Describe 'Get-WindowsDeviceTestResultSummary' {
+ BeforeEach {
+ $script:testDir = Join-Path ([System.IO.Path]::GetTempPath()) "windows-device-results-$([guid]::NewGuid())"
+ New-Item -ItemType Directory -Path $script:testDir -Force | Out-Null
+ }
+
+ AfterEach {
+ Remove-Item -LiteralPath $script:testDir -Recurse -Force -ErrorAction SilentlyContinue
+ }
+
+ It 'aggregates xUnit assembly counters from Windows device-test XML files' {
+ $file1 = Join-Path $script:testDir 'TestResults-One.xml'
+ $file2 = Join-Path $script:testDir 'TestResults-Two.xml'
+
+ @'
+
+
+
+'@ | Set-Content $file1 -Encoding UTF8
+
+ @'
+
+
+
+'@ | Set-Content $file2 -Encoding UTF8
+
+ $summary = Get-WindowsDeviceTestResultSummary -ResultFiles @($file1, $file2)
+
+ $summary.Total | Should -Be 5
+ $summary.Passed | Should -Be 3
+ $summary.Failed | Should -Be 1
+ $summary.Skipped | Should -Be 1
+ $summary.Errors | Should -Be 0
+ }
+}
diff --git a/.github/skills/run-device-tests/scripts/Run-DeviceTests.ps1 b/.github/skills/run-device-tests/scripts/Run-DeviceTests.ps1
index 5a94bf00fb2a..9d8cd1c5eb1f 100644
--- a/.github/skills/run-device-tests/scripts/Run-DeviceTests.ps1
+++ b/.github/skills/run-device-tests/scripts/Run-DeviceTests.ps1
@@ -1,6 +1,6 @@
<#
.SYNOPSIS
- Builds and runs .NET MAUI device tests locally using xharness (Apple/Android) or vstest (Windows).
+ Builds and runs .NET MAUI device tests locally using xharness (Apple/Android) or the Windows device-test app directly.
.DESCRIPTION
This script builds a specified MAUI device test project for the target platform
@@ -140,6 +140,224 @@ $AppNames = @{
"AI" = "Microsoft.Maui.Essentials.AI.DeviceTests"
}
+$WindowsDeviceTestPackageIds = @{
+ "Controls" = "Microsoft.Maui.Controls.DeviceTests"
+ "Core" = "Microsoft.Maui.Core.DeviceTests"
+ "Essentials" = "Microsoft.Maui.Essentials.DeviceTests"
+ "Graphics" = "Microsoft.Maui.Graphics.DeviceTests"
+ "BlazorWebView" = "Microsoft.Maui.MauiBlazorWebView.DeviceTests"
+ "AI" = "Microsoft.Maui.Essentials.AI.DeviceTests"
+}
+
+function Get-CategoryFiltersFromTestFilter {
+ param([string]$Filter)
+
+ if ([string]::IsNullOrWhiteSpace($Filter)) {
+ return @()
+ }
+
+ $categories = @()
+ $matches = [regex]::Matches($Filter, '(?i)\bCategory\s*=\s*([^\|&(),]+)')
+ foreach ($match in $matches) {
+ $value = $match.Groups[1].Value.Trim().Trim('"', "'")
+ if (-not [string]::IsNullOrWhiteSpace($value)) {
+ $categories += $value
+ }
+ }
+
+ if ($categories.Count -eq 0 -and $Filter -notmatch '[=~]') {
+ $categories = @($Filter -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
+ }
+
+ return @($categories | Select-Object -Unique)
+}
+
+function Select-WindowsDeviceTestCategories {
+ param(
+ [string[]]$AllCategories,
+ [string]$Filter
+ )
+
+ $filters = @(Get-CategoryFiltersFromTestFilter -Filter $Filter)
+ if ($filters.Count -eq 0) {
+ return @($AllCategories)
+ }
+
+ return @($AllCategories | Where-Object {
+ $category = $_
+ @($filters | Where-Object {
+ $category.Equals($_, [System.StringComparison]::OrdinalIgnoreCase) -or
+ $category.IndexOf($_, [System.StringComparison]::OrdinalIgnoreCase) -ge 0
+ }).Count -gt 0
+ })
+}
+
+function Wait-ForPath {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$Path,
+
+ [Parameter(Mandatory = $true)]
+ [int]$TimeoutSeconds,
+
+ [System.Diagnostics.Process]$Process
+ )
+
+ $stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
+ while ($stopwatch.Elapsed.TotalSeconds -lt $TimeoutSeconds) {
+ if (Test-Path $Path) {
+ return $true
+ }
+
+ if ($Process -and $Process.HasExited) {
+ Start-Sleep -Seconds 1
+ if (Test-Path $Path) {
+ return $true
+ }
+ return $false
+ }
+
+ Start-Sleep -Seconds 1
+ }
+
+ return (Test-Path $Path)
+}
+
+function Get-WindowsDeviceTestResultSummary {
+ param([Parameter(Mandatory = $true)][string[]]$ResultFiles)
+
+ $summary = @{
+ Total = 0
+ Passed = 0
+ Failed = 0
+ Skipped = 0
+ Errors = 0
+ }
+
+ foreach ($file in $ResultFiles) {
+ if (-not (Test-Path $file)) {
+ continue
+ }
+
+ [xml]$xml = Get-Content $file -Raw
+ $assemblies = @($xml.SelectNodes('/assemblies/assembly'))
+ foreach ($assembly in $assemblies) {
+ $summary.Total += [int]($assembly.total ?? 0)
+ $summary.Passed += [int]($assembly.passed ?? 0)
+ $summary.Failed += [int]($assembly.failed ?? 0)
+ $summary.Skipped += [int]($assembly.skipped ?? 0)
+ $summary.Errors += [int]($assembly.errors ?? 0)
+ }
+ }
+
+ return $summary
+}
+
+function Invoke-WindowsDeviceTestApp {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$AppPath,
+
+ [Parameter(Mandatory = $true)]
+ [string]$Project,
+
+ [Parameter(Mandatory = $true)]
+ [string]$AppName,
+
+ [Parameter(Mandatory = $true)]
+ [string]$OutputDirectory,
+
+ [string]$TestFilter,
+
+ [string]$Timeout = "01:00:00"
+ )
+
+ $timeoutSeconds = [int][TimeSpan]::Parse($Timeout).TotalSeconds
+ if ($timeoutSeconds -le 0) {
+ $timeoutSeconds = 3600
+ }
+
+ if (-not (Test-Path $OutputDirectory)) {
+ New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null
+ }
+
+ $packageId = $WindowsDeviceTestPackageIds[$Project]
+ if (-not $packageId) {
+ $packageId = $AppName
+ }
+
+ $resultBase = Join-Path $OutputDirectory "TestResults-$($packageId.Replace('.', '_'))"
+ $resultFile = "$resultBase.xml"
+ $categoriesFile = Join-Path $OutputDirectory "devicetestcategories.txt"
+ Remove-Item -LiteralPath $categoriesFile -Force -ErrorAction SilentlyContinue
+ Remove-Item -Path "$resultBase*.xml" -Force -ErrorAction SilentlyContinue
+
+ $resultFiles = @()
+ if ($Project -eq "Controls") {
+ Write-Host "Discovering Windows device test categories..." -ForegroundColor Gray
+ $discoveryProcess = Start-Process -FilePath $AppPath -ArgumentList @($resultFile, "-1") -PassThru
+ if (-not (Wait-ForPath -Path $categoriesFile -TimeoutSeconds 120 -Process $discoveryProcess)) {
+ if ($discoveryProcess -and -not $discoveryProcess.HasExited) {
+ Stop-Process -Id $discoveryProcess.Id -Force -ErrorAction SilentlyContinue
+ }
+ throw "Windows device test category discovery did not create $categoriesFile"
+ }
+
+ $allCategories = @(Get-Content $categoriesFile | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
+ $selectedCategories = @(Select-WindowsDeviceTestCategories -AllCategories $allCategories -Filter $TestFilter)
+ if ($selectedCategories.Count -eq 0) {
+ throw "Test filter '$TestFilter' matched 0 Windows device test categories. Available categories: $($allCategories -join ', ')"
+ }
+
+ Write-Host "Running $($selectedCategories.Count) of $($allCategories.Count) Windows device test categor$(if ($selectedCategories.Count -eq 1) { 'y' } else { 'ies' }): $($selectedCategories -join ', ')" -ForegroundColor Yellow
+
+ foreach ($category in $selectedCategories) {
+ $categoryIndex = [Array]::IndexOf($allCategories, $category)
+ if ($categoryIndex -lt 0) {
+ throw "Could not find category '$category' in discovered category list."
+ }
+
+ $categoryResultFile = "$resultBase`_$category.xml"
+ Remove-Item -LiteralPath $categoryResultFile -Force -ErrorAction SilentlyContinue
+ Write-Host "Running Windows device test category '$category' (index $categoryIndex)..." -ForegroundColor Gray
+ $process = Start-Process -FilePath $AppPath -ArgumentList @($resultFile, [string]$categoryIndex) -PassThru
+ if (-not (Wait-ForPath -Path $categoryResultFile -TimeoutSeconds $timeoutSeconds -Process $process)) {
+ if ($process -and -not $process.HasExited) {
+ Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
+ }
+ throw "Windows device test category '$category' did not create $categoryResultFile"
+ }
+
+ $resultFiles += $categoryResultFile
+ }
+ } else {
+ if ($TestFilter) {
+ Write-Warning "Windows non-Controls device tests do not support dynamic category filtering; running the full $Project device test app."
+ }
+
+ Write-Host "Running Windows device test app directly..." -ForegroundColor Gray
+ $process = Start-Process -FilePath $AppPath -ArgumentList @($resultFile) -PassThru
+ if (-not (Wait-ForPath -Path $resultFile -TimeoutSeconds $timeoutSeconds -Process $process)) {
+ if ($process -and -not $process.HasExited) {
+ Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
+ }
+ throw "Windows device test app did not create $resultFile"
+ }
+
+ $resultFiles += $resultFile
+ }
+
+ $summary = Get-WindowsDeviceTestResultSummary -ResultFiles $resultFiles
+ $script:WindowsDeviceTestSummary = $summary
+ $script:WindowsDeviceTestResultFiles = $resultFiles
+
+ if (($summary.Failed + $summary.Errors) -eq 0) {
+ return 0
+ }
+
+ return 1
+}
+
# Android package names (lowercase)
$AndroidPackageNames = @{
"Controls" = "com.microsoft.maui.controls.devicetests"
@@ -576,32 +794,29 @@ try {
$testExitCode = $LASTEXITCODE
} else {
# ═══════════════════════════════════════════════════════════
- # VSTEST EXECUTION (Windows)
+ # WINDOWS DEVICE TEST EXECUTION
# ═══════════════════════════════════════════════════════════
-
- Write-Host "Running tests with vstest..." -ForegroundColor Gray
- Write-Host ""
-
- $vstestArgs = @(
- "test"
- $projectPath
- "-c", $Configuration
- "-f", $platformConfig.Tfm
- "--no-build"
- "--logger", "trx;LogFileName=TestResults.trx"
- "--results-directory", $OutputDirectory
- )
- if ($TestFilter) {
- $vstestArgs += "--filter", $TestFilter
- }
-
- Write-Host "Running: dotnet $($vstestArgs -join ' ')" -ForegroundColor Gray
+ Write-Host "Running Windows device test app directly..." -ForegroundColor Gray
+ Write-Host "This matches eng/devices/windows.cake and avoids VSTest/testhost for MAUI Windows device apps." -ForegroundColor Gray
Write-Host ""
- & dotnet @vstestArgs
+ $testExitCode = Invoke-WindowsDeviceTestApp `
+ -AppPath $appPath `
+ -Project $Project `
+ -AppName $appName `
+ -OutputDirectory $OutputDirectory `
+ -TestFilter $TestFilter `
+ -Timeout $Timeout
- $testExitCode = $LASTEXITCODE
+ if ($script:WindowsDeviceTestSummary) {
+ Write-Host ""
+ Write-Output " Passed: $($script:WindowsDeviceTestSummary.Passed)"
+ Write-Output " Failed: $($script:WindowsDeviceTestSummary.Failed + $script:WindowsDeviceTestSummary.Errors)"
+ Write-Output " Skipped: $($script:WindowsDeviceTestSummary.Skipped)"
+ Write-Output " Total: $($script:WindowsDeviceTestSummary.Total)"
+ Write-Host " Result file(s): $($script:WindowsDeviceTestResultFiles -join ', ')" -ForegroundColor Gray
+ }
}
# ═══════════════════════════════════════════════════════════
diff --git a/.github/skills/verify-tests-fail-without-fix/scripts/verify-tests-fail.ps1 b/.github/skills/verify-tests-fail-without-fix/scripts/verify-tests-fail.ps1
index e54d900a1cf1..333f8c7bde11 100644
--- a/.github/skills/verify-tests-fail-without-fix/scripts/verify-tests-fail.ps1
+++ b/.github/skills/verify-tests-fail-without-fix/scripts/verify-tests-fail.ps1
@@ -210,6 +210,7 @@ $script:UnitTestProjectMap = @{
"SourceGen.UnitTests" = "src/Controls/tests/SourceGen.UnitTests/SourceGen.UnitTests.csproj"
"Core.UnitTests" = "src/Core/tests/UnitTests/Core.UnitTests.csproj"
"Essentials.UnitTests" = "src/Essentials/test/UnitTests/Essentials.UnitTests.csproj"
+ "MauiBlazorWebView.UnitTests" = "src/BlazorWebView/tests/MauiBlazorWebView.UnitTests/MauiBlazorWebView.UnitTests.csproj"
"Graphics.Tests" = "src/Graphics/tests/Graphics.Tests/Graphics.Tests.csproj"
"Resizetizer.UnitTests" = "src/SingleProject/Resizetizer/test/UnitTests/Resizetizer.UnitTests.csproj"
"Compatibility.Core.UnitTests" = "src/Compatibility/Core/tests/Compatibility.UnitTests/Compatibility.Core.UnitTests.csproj"
diff --git a/eng/pipelines/ci-copilot.yml b/eng/pipelines/ci-copilot.yml
index 8ee93ed66321..0745323e92e1 100644
--- a/eng/pipelines/ci-copilot.yml
+++ b/eng/pipelines/ci-copilot.yml
@@ -614,6 +614,7 @@ stages:
# Create artifacts directory
mkdir -p $(Build.ArtifactStagingDirectory)/copilot-logs
+ mkdir -p $(Build.ArtifactStagingDirectory)/copilot-token-usage/raw
# Copy trusted scripts from the checked-out commit so later tasks
# (which may be on a merged/modified worktree) use the same .github/
@@ -633,6 +634,7 @@ stages:
-Platform "${{ parameters.Platform }}" \
-Phase Setup \
-TrustedScriptsDir "$TRUSTED" \
+ -TokenUsageOutputDir "$(Build.ArtifactStagingDirectory)/copilot-token-usage/raw" \
-LogFile "$(Build.ArtifactStagingDirectory)/copilot-logs/copilot_review_output.md"
SETUP_EXIT=$?
set -e
@@ -663,6 +665,7 @@ stages:
-Platform "${{ parameters.Platform }}" \
-Phase Gate \
-TrustedScriptsDir "$TRUSTED" \
+ -TokenUsageOutputDir "$(Build.ArtifactStagingDirectory)/copilot-token-usage/raw" \
-LogFile "$(Build.ArtifactStagingDirectory)/copilot-logs/copilot_review_output.md"
GATE_EXIT=$?
set -e
@@ -694,6 +697,7 @@ stages:
-Platform "${{ parameters.Platform }}" \
-Phase CopilotReview \
-TrustedScriptsDir "$TRUSTED" \
+ -TokenUsageOutputDir "$(Build.ArtifactStagingDirectory)/copilot-token-usage/raw" \
-LogFile "$(Build.ArtifactStagingDirectory)/copilot-logs/copilot_review_output.md"
REVIEW_EXIT=$?
set -e
@@ -724,6 +728,7 @@ stages:
-Platform "${{ parameters.Platform }}" \
-Phase Post \
-TrustedScriptsDir "$TRUSTED" \
+ -TokenUsageOutputDir "$(Build.ArtifactStagingDirectory)/copilot-token-usage/raw" \
-LogFile "$(Build.ArtifactStagingDirectory)/copilot-logs/copilot_review_output.md"
POST_EXIT=$?
set -e
@@ -734,7 +739,7 @@ stages:
fi
echo "Review output saved to $(Build.ArtifactStagingDirectory)/copilot-logs/"
- name: RunPost # Stage 3 (UpdateAISummaryComment) reads aiSummaryCommentId via $(stageDependencies.ReviewPR.CopilotReview.outputs['RunPost.aiSummaryCommentId']). Note: detectedCategories comes from RunGate, not RunPost.
+ name: RunPost # Stage 3 (UpdateAISummaryComment) reads aiSummaryReviewId via $(stageDependencies.ReviewPR.CopilotReview.outputs['RunPost.aiSummaryReviewId']). Note: detectedCategories comes from RunGate, not RunPost.
displayName: 'Task 4: Post (comments + labels)'
env:
GH_TOKEN: $(GH_COMMENT_TOKEN)
@@ -761,6 +766,13 @@ stages:
Copy-Item -Path ".github/agent-pr-session" -Destination $logsDir -Recurse -Force -ErrorAction SilentlyContinue
}
+ # Copilot token usage raw records
+ $tokenUsageDir = "$(Build.ArtifactStagingDirectory)/copilot-token-usage"
+ if (Test-Path $tokenUsageDir) {
+ Write-Host "Copying copilot-token-usage..."
+ Copy-Item -Path $tokenUsageDir -Destination (Join-Path $logsDir "copilot-token-usage") -Recurse -Force -ErrorAction SilentlyContinue
+ }
+
# Review_Feedback files
Get-ChildItem -Path . -Filter "Review_Feedback_*.md" -Recurse -ErrorAction SilentlyContinue |
ForEach-Object { Copy-Item $_.FullName $logsDir -ErrorAction SilentlyContinue }
@@ -809,7 +821,7 @@ stages:
# STAGE: RunDeepUITests
# ─────────────────────────────────────────────────────────────────────────────
# After the Copilot review agent has detected UI test categories and posted
- # an initial AI summary comment with in-process per-category results, this
+ # an initial AI summary review with in-process per-category results, this
# stage re-runs those same categories on a real platform-appropriate pool
# (Tahoe iOS sim / Ubuntu Android emu / Windows-2022 / macOS-14) instead of
# whatever VM the Copilot agent happened to land on. Each category becomes
@@ -1278,7 +1290,7 @@ stages:
}
if ($hadFailure) {
- # Don't fail the stage — the AI summary comment is the
+ # Don't fail the stage — the AI summary review is the
# deliverable; failed tests get reported there. Stage-level
# failure would prevent the UpdateAISummaryComment stage
# from running.
@@ -1310,26 +1322,26 @@ stages:
# STAGE: PostAISummaryComment
# ─────────────────────────────────────────────────────────────────────────────
# Final stage. Depends on both ReviewPR (which posted the initial AI
- # summary comment and emitted aiSummaryCommentId) and RunDeepUITests
+ # summary review and emitted aiSummaryReviewId) and RunDeepUITests
# (which produced the TRX artifacts on the right pool). Downloads the
# artifacts, parses them via Aggregate-UITestArtifacts.ps1, and edits
- # the existing PR comment to replace the in-process STEP 3 section
+ # the existing PR review to replace the in-process STEP 3 section
# with the deep-test results.
- stage: UpdateAISummaryComment
- displayName: 'Post AI Summary Comment'
+ displayName: 'Post AI Summary Review'
dependsOn:
- ReviewPR
- RunDeepUITests
- condition: and(in(dependencies.RunDeepUITests.result, 'Succeeded', 'SucceededWithIssues', 'Failed', 'Skipped'), or(ne(dependencies.ReviewPR.outputs['CopilotReview.RunPost.aiSummaryCommentId'], ''), in(dependencies.RunDeepUITests.result, 'Succeeded', 'SucceededWithIssues', 'Failed')))
+ condition: and(in(dependencies.RunDeepUITests.result, 'Succeeded', 'SucceededWithIssues', 'Failed', 'Skipped'), or(ne(dependencies.ReviewPR.outputs['CopilotReview.RunPost.aiSummaryReviewId'], ''), in(dependencies.RunDeepUITests.result, 'Succeeded', 'SucceededWithIssues', 'Failed')))
jobs:
- job: UpdateComment
- displayName: 'Post AI summary with review + deep test results'
+ displayName: 'Post AI summary review with deep test results'
# Job-level variables can use $[ stageDependencies... ] (cross-stage,
# job context). The stage condition above already gated emptiness;
- # this just makes the value available as $(aiSummaryCommentId)
+ # this just makes the value available as $(aiSummaryReviewId)
# inside the steps.
variables:
- aiSummaryCommentId: $[ stageDependencies.ReviewPR.CopilotReview.outputs['RunPost.aiSummaryCommentId'] ]
+ aiSummaryReviewId: $[ stageDependencies.ReviewPR.CopilotReview.outputs['RunPost.aiSummaryReviewId'] ]
pool:
name: Azure Pipelines
vmImage: ubuntu-22.04
@@ -1366,12 +1378,12 @@ stages:
$artDir = "$(Pipeline.Workspace)/drop-deep-uitests"
$copilotLogsDir = "$(Pipeline.Workspace)/CopilotLogs"
$prNumber = "${{ parameters.PRNumber }}"
- $commentId = "$(aiSummaryCommentId)"
- $isDeferred = ($commentId -eq 'DEFERRED')
+ $reviewId = "$(aiSummaryReviewId)"
+ $isDeferred = ([string]::IsNullOrWhiteSpace($reviewId) -or $reviewId -eq 'DEFERRED')
# Diagnostic logging for Stage 3 debugging
Write-Host "=== Stage 3 Diagnostics ===" -ForegroundColor Cyan
- Write-Host " commentId: '$commentId'"
+ Write-Host " reviewId: '$reviewId'"
Write-Host " isDeferred: $isDeferred"
Write-Host " artDir exists: $(Test-Path $artDir)"
Write-Host " copilotLogsDir exists: $(Test-Path $copilotLogsDir)"
@@ -1383,16 +1395,16 @@ stages:
}
}
- if ([string]::IsNullOrWhiteSpace($commentId)) {
- # Reviewer crashed before posting the initial comment. If deep
+ if ([string]::IsNullOrWhiteSpace($reviewId)) {
+ # Reviewer crashed before posting the initial review. If deep
# tests produced results, fall back to DEFERRED mode to post
- # a degraded comment with test results only.
+ # a degraded review with test results only.
if (Test-Path $artDir) {
- Write-Host "No AI summary comment ID but deep test artifacts exist — falling back to DEFERRED mode"
- $commentId = 'DEFERRED'
+ Write-Host "No AI summary review ID but deep test artifacts exist — falling back to DEFERRED mode"
+ $reviewId = 'DEFERRED'
$isDeferred = $true
} else {
- Write-Host "No AI summary comment ID and no deep test artifacts — nothing to do"
+ Write-Host "No AI summary review ID and no deep test artifacts — nothing to do"
exit 0
}
}
@@ -1590,13 +1602,13 @@ stages:
if ($isDeferred) {
# Keep deferred mode even if a prior AI Summary exists. The
- # posting script preserves existing sessions, deletes stale
- # generated comments, then posts a fresh unified comment.
- Write-Host "Deferred AI Summary posting will clean up any stale generated comments before posting"
+ # posting script preserves current-run artifacts, hides stale
+ # generated comments/reviews, then posts a fresh summary review.
+ Write-Host "Deferred AI Summary posting will hide stale generated artifacts before posting"
}
if ($isDeferred) {
- # ── DEFERRED MODE (first run): Post full comment ──
+ # ── DEFERRED MODE (first run): Post full review ──
# Find the PRAgent content dir from CopilotLogs artifact
$prAgentDir = Get-ChildItem -Path $copilotLogsDir -Recurse -Directory -Filter "PRAgent" | Select-Object -First 1
if (-not $prAgentDir) {
@@ -1629,7 +1641,7 @@ stages:
Write-Host "Replaced in-process results with deep results"
}
} else {
- Write-Host "No deep results — posting review-only comment"
+ Write-Host "No deep results — posting review-only summary"
}
# Copy PRAgent dir to expected location for post-ai-summary-comment.ps1
@@ -1637,13 +1649,13 @@ stages:
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $targetDir) | Out-Null
Copy-Item -Path $prAgentDir.FullName -Destination $targetDir -Recurse -Force
- # Post the full comment
+ # Post the full review
$postScript = ".github/scripts/post-ai-summary-comment.ps1"
if (Test-Path $postScript) {
- Write-Host "Posting full AI summary comment with deep results..."
+ Write-Host "Posting full AI summary review with deep results..."
$output = & $postScript -PRNumber $prNumber
$output | ForEach-Object { Write-Host $_ }
- Write-Host "✅ Full AI summary comment posted with deep results"
+ Write-Host "✅ Full AI summary review posted with deep results"
}
# Apply labels
@@ -1659,14 +1671,14 @@ stages:
}
}
} else {
- # ── PATCH MODE: Update existing comment with deep results ──
+ # ── PATCH MODE: Update existing review with deep results ──
if (-not $deepBlock) {
- Write-Host "No deep results and comment already exists — nothing to patch"
+ Write-Host "No deep results and review already exists — nothing to patch"
exit 0
}
- $existing = (gh api "repos/dotnet/maui/issues/comments/$commentId" --jq '.body') -join [Environment]::NewLine
+ $existing = (gh api "repos/dotnet/maui/pulls/$prNumber/reviews/$reviewId" --jq '.body') -join [Environment]::NewLine
if ([string]::IsNullOrWhiteSpace($existing)) {
- Write-Host "Could not fetch comment body — aborting"
+ Write-Host "Could not fetch review body — aborting"
exit 0
}
@@ -1692,9 +1704,62 @@ stages:
$tmp = New-TemporaryFile
@{ body = $newBody } | ConvertTo-Json -Depth 4 -Compress | Set-Content $tmp -Encoding UTF8
- gh api -X PATCH "repos/dotnet/maui/issues/comments/$commentId" --input $tmp.FullName | Out-Null
- Write-Host "✅ Patched comment $commentId with deep UI test results ($totalPassed/$($totalPassed + $totalFailed))"
+ gh api -X PATCH "repos/dotnet/maui/pulls/$prNumber/reviews/$reviewId" --input $tmp.FullName | Out-Null
+ Write-Host "✅ Patched review $reviewId with deep UI test results ($totalPassed/$($totalPassed + $totalFailed))"
}
- displayName: 'Post AI summary comment'
+ displayName: 'Post AI summary review'
env:
GH_TOKEN: $(GH_COMMENT_TOKEN)
+
+ - stage: AnalyzeCopilotTokenUsage
+ displayName: 'Analyze Copilot token usage'
+ dependsOn:
+ - ReviewPR
+ - RunDeepUITests
+ - UpdateAISummaryComment
+ condition: always()
+ jobs:
+ - job: AnalyzeTokenUsage
+ displayName: 'Publish Copilot token usage artifact'
+ pool:
+ name: Azure Pipelines
+ vmImage: ubuntu-22.04
+ timeoutInMinutes: 10
+ steps:
+ - checkout: self
+ persistCredentials: false
+
+ - task: DownloadPipelineArtifact@2
+ displayName: 'Download CopilotLogs'
+ inputs:
+ buildType: 'current'
+ artifactName: 'CopilotLogs'
+ targetPath: '$(Pipeline.Workspace)/CopilotLogs'
+ continueOnError: true
+
+ - pwsh: |
+ $ErrorActionPreference = 'Stop'
+ $inputRoot = "$(Pipeline.Workspace)/CopilotLogs"
+ $outputDir = "$(Build.ArtifactStagingDirectory)/copilot-token-usage"
+ $script = ".github/scripts/shared/Aggregate-CopilotTokenUsage.ps1"
+ if (-not (Test-Path $script)) { throw "$script missing" }
+
+ & $script `
+ -InputRoot $inputRoot `
+ -OutputDir $outputDir `
+ -PRNumber "${{ parameters.PRNumber }}" `
+ -ExpectedStages @(
+ 'ReviewPR',
+ 'RunDeepUITests',
+ 'UpdateAISummaryComment',
+ 'AnalyzeCopilotTokenUsage'
+ )
+ displayName: 'Aggregate Copilot token usage'
+
+ - task: PublishPipelineArtifact@1
+ displayName: 'Publish CopilotTokenUsage'
+ inputs:
+ targetPath: '$(Build.ArtifactStagingDirectory)/copilot-token-usage'
+ artifact: 'CopilotTokenUsage'
+ publishLocation: 'pipeline'
+ condition: always()