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 " `"$alt`"" +} + +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()