diff --git a/.github/scripts/BuildAndRunHostApp.ps1 b/.github/scripts/BuildAndRunHostApp.ps1 index b349753980eb..70f92a297f9a 100644 --- a/.github/scripts/BuildAndRunHostApp.ps1 +++ b/.github/scripts/BuildAndRunHostApp.ps1 @@ -349,7 +349,8 @@ Write-Step "Collecting test artifacts (screenshots, page source)..." $testAssemblyDirs = @( (Join-Path $RepoRoot "artifacts/bin/Controls.TestCases.Android.Tests/Debug/net10.0"), (Join-Path $RepoRoot "artifacts/bin/Controls.TestCases.iOS.Tests/Debug/net10.0"), - (Join-Path $RepoRoot "artifacts/bin/Controls.TestCases.Mac.Tests/Debug/net10.0") + (Join-Path $RepoRoot "artifacts/bin/Controls.TestCases.Mac.Tests/Debug/net10.0"), + (Join-Path $RepoRoot "artifacts/bin/Controls.TestCases.WinUI.Tests/Debug/net10.0-windows10.0.19041.0") ) $copiedCount = 0 @@ -441,6 +442,8 @@ if (Test-Path $deviceLogFile) { Write-Host " iOS Simulator Logs (Last 100 lines)" -ForegroundColor Cyan } elseif ($Platform -eq "catalyst") { Write-Host " MacCatalyst App Logs (Last 100 lines)" -ForegroundColor Cyan + } elseif ($Platform -eq "windows") { + Write-Host " Windows App Logs (Last 100 lines)" -ForegroundColor Cyan } Write-Host "═══════════════════════════════════════════════════════" -ForegroundColor Cyan diff --git a/.github/scripts/Review-PR.ps1 b/.github/scripts/Review-PR.ps1 index e8547572e751..d13e2929ab5f 100644 --- a/.github/scripts/Review-PR.ps1 +++ b/.github/scripts/Review-PR.ps1 @@ -43,7 +43,7 @@ param( [int]$PRNumber, [Parameter(Mandatory = $false)] - [ValidateSet('android', 'ios', 'windows', 'maccatalyst')] + [ValidateSet('android', 'ios', 'windows', 'maccatalyst', 'catalyst')] [string]$Platform, [Parameter(Mandatory = $false)] @@ -482,12 +482,20 @@ $summaryScriptsDir = Join-Path $RepoRoot ".github/scripts" $dryRunFlag = if ($DryRun) { @('-DryRun') } else { @() } # 3a: Post PR review phases (pre-flight, gate, try-fix, report) +$aiSummaryCommentId = $null $reviewScript = Join-Path $summaryScriptsDir "post-ai-summary-comment.ps1" if (Test-Path $reviewScript) { try { Write-Host " 📝 Posting PR review summary..." -ForegroundColor Cyan - & $reviewScript -PRNumber $PRNumber @dryRunFlag - Write-Host " ✅ PR review summary posted" -ForegroundColor Green + $reviewOutput = & $reviewScript -PRNumber $PRNumber @dryRunFlag + # 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 + } else { + Write-Host " ✅ PR review summary posted" -ForegroundColor Green + } } catch { Write-Host " ⚠️ PR review summary posting failed (non-fatal): $_" -ForegroundColor Yellow } @@ -500,7 +508,11 @@ $finalizeScript = Join-Path $summaryScriptsDir "post-pr-finalize-comment.ps1" if (Test-Path $finalizeScript) { try { Write-Host " 📝 Posting PR finalize summary..." -ForegroundColor Cyan - & $finalizeScript -PRNumber $PRNumber @dryRunFlag + $finalizeArgs = @('-PRNumber', $PRNumber) + $dryRunFlag + if ($aiSummaryCommentId) { + $finalizeArgs += @('-ExistingCommentId', $aiSummaryCommentId) + } + & $finalizeScript @finalizeArgs Write-Host " ✅ PR finalize summary posted" -ForegroundColor Green } catch { Write-Host " ⚠️ PR finalize summary posting failed (non-fatal): $_" -ForegroundColor Yellow diff --git a/.github/scripts/post-ai-summary-comment.ps1 b/.github/scripts/post-ai-summary-comment.ps1 index f10e6b022c57..5b10707cc743 100644 --- a/.github/scripts/post-ai-summary-comment.ps1 +++ b/.github/scripts/post-ai-summary-comment.ps1 @@ -831,10 +831,30 @@ if ($existingComment) { $tempFile = [System.IO.Path]::GetTempFileName() @{ body = $commentBody } | ConvertTo-Json -Depth 10 | Set-Content -Path $tempFile -Encoding UTF8 - gh api --method PATCH "repos/dotnet/maui/issues/comments/$($existingComment.id)" --input $tempFile | Out-Null - Remove-Item $tempFile + $patchResult = $null + $commentId = $existingComment.id + try { + $patchResult = gh api --method PATCH "repos/dotnet/maui/issues/comments/$($existingComment.id)" --input $tempFile 2>&1 + if ($LASTEXITCODE -ne 0) { throw "PATCH failed (exit code $LASTEXITCODE): $patchResult" } + Write-Host "✅ Review comment updated successfully" -ForegroundColor Green + } catch { + Write-Host "⚠️ Could not update comment (no edit permission?) — creating new comment instead: $_" -ForegroundColor Yellow + + $newTempFile = [System.IO.Path]::GetTempFileName() + try { + @{ body = $commentBody } | ConvertTo-Json -Depth 10 | Set-Content -Path $newTempFile -Encoding UTF8 + $newCommentJson = gh api --method POST "repos/dotnet/maui/issues/$PRNumber/comments" --input $newTempFile + $commentId = ($newCommentJson | ConvertFrom-Json).id + Write-Host "✅ Review comment posted as new (ID: $commentId)" -ForegroundColor Green + } finally { + Remove-Item $newTempFile -ErrorAction SilentlyContinue + } + } finally { + Remove-Item $tempFile -ErrorAction SilentlyContinue + } - Write-Host "✅ Review comment updated successfully" -ForegroundColor Green + # Output the comment ID so callers can pass it to subsequent scripts + Write-Output "COMMENT_ID=$commentId" } else { Write-Host "Creating new review comment..." -ForegroundColor Yellow @@ -842,8 +862,12 @@ if ($existingComment) { $tempFile = [System.IO.Path]::GetTempFileName() @{ body = $commentBody } | ConvertTo-Json -Depth 10 | Set-Content -Path $tempFile -Encoding UTF8 - gh api --method POST "repos/dotnet/maui/issues/$PRNumber/comments" --input $tempFile | Out-Null + $newCommentJson = gh api --method POST "repos/dotnet/maui/issues/$PRNumber/comments" --input $tempFile Remove-Item $tempFile - Write-Host "✅ Review comment posted successfully" -ForegroundColor Green + # Extract and output the new comment ID so callers can pass it to subsequent scripts + $newCommentId = ($newCommentJson | ConvertFrom-Json).id + Write-Host "✅ Review comment posted successfully (ID: $newCommentId)" -ForegroundColor Green + + Write-Output "COMMENT_ID=$newCommentId" } diff --git a/.github/scripts/post-pr-finalize-comment.ps1 b/.github/scripts/post-pr-finalize-comment.ps1 index 7e80d48a6b27..1dca0ca8bf42 100644 --- a/.github/scripts/post-pr-finalize-comment.ps1 +++ b/.github/scripts/post-pr-finalize-comment.ps1 @@ -39,6 +39,9 @@ param( [Parameter(Mandatory=$false)] [string]$SummaryFile, + [Parameter(Mandatory=$false)] + [string]$ExistingCommentId, + [Parameter(Mandatory=$false)] [switch]$DryRun, @@ -128,21 +131,41 @@ Write-Host "`nInjecting into AI Summary comment on #$PRNumber..." -ForegroundCol # Find existing unified comment $existingUnifiedComment = $null -try { - $commentsJson = gh api "repos/dotnet/maui/issues/$PRNumber/comments?per_page=100" 2>$null - $comments = $commentsJson | ConvertFrom-Json - foreach ($comment in $comments) { - if ($comment.body -match [regex]::Escape($MAIN_MARKER)) { - $existingUnifiedComment = $comment - Write-Host "✓ Found unified AI Summary comment (ID: $($comment.id))" -ForegroundColor Green - break + +# If caller passed the comment ID (avoids GitHub API eventual-consistency race), fetch it directly +if (-not [string]::IsNullOrWhiteSpace($ExistingCommentId)) { + try { + $commentJson = gh api "repos/dotnet/maui/issues/comments/$ExistingCommentId" 2>$null + $directComment = $commentJson | ConvertFrom-Json + if ($directComment.body -match [regex]::Escape($MAIN_MARKER)) { + $existingUnifiedComment = $directComment + Write-Host "✓ Found unified AI Summary comment via passed ID (ID: $($directComment.id))" -ForegroundColor Green + } else { + Write-Host "⚠️ Passed comment ID $ExistingCommentId does not contain AI Summary marker — falling back to search" -ForegroundColor Yellow } + } catch { + Write-Host "⚠️ Could not fetch comment by ID $ExistingCommentId — falling back to search" -ForegroundColor Yellow } - if (-not $existingUnifiedComment) { - Write-Host "✓ No existing AI Summary comment found — will create new" -ForegroundColor Yellow +} + +# Fallback: search through all PR comments +if (-not $existingUnifiedComment) { + try { + $commentsJson = gh api "repos/dotnet/maui/issues/$PRNumber/comments?per_page=100" 2>$null + $comments = $commentsJson | ConvertFrom-Json + foreach ($comment in $comments) { + if ($comment.body -match [regex]::Escape($MAIN_MARKER)) { + $existingUnifiedComment = $comment + Write-Host "✓ Found unified AI Summary comment (ID: $($comment.id))" -ForegroundColor Green + break + } + } + if (-not $existingUnifiedComment) { + Write-Host "✓ No existing AI Summary comment found — will create new" -ForegroundColor Yellow + } + } catch { + Write-Host "⚠️ Could not fetch comments: $_" -ForegroundColor Yellow } -} catch { - Write-Host "⚠️ Could not fetch comments: $_" -ForegroundColor Yellow } # Build the final comment body @@ -186,14 +209,38 @@ if ($DryRun) { $tempFile = [System.IO.Path]::GetTempFileName() @{ body = $finalComment } | ConvertTo-Json -Depth 10 | Set-Content -Path $tempFile -Encoding UTF8 -if ($existingUnifiedComment) { - Write-Host "Updating unified comment ID $($existingUnifiedComment.id)..." -ForegroundColor Yellow - $result = gh api --method PATCH "repos/dotnet/maui/issues/comments/$($existingUnifiedComment.id)" --input $tempFile --jq '.html_url' - Write-Host "✅ PR finalize section updated: $result" -ForegroundColor Green -} else { - Write-Host "Creating new unified comment on PR #$PRNumber..." -ForegroundColor Yellow - $result = gh api --method POST "repos/dotnet/maui/issues/$PRNumber/comments" --input $tempFile --jq '.html_url' - Write-Host "✅ Unified comment posted: $result" -ForegroundColor Green -} +try { + if ($existingUnifiedComment) { + Write-Host "Updating unified comment ID $($existingUnifiedComment.id)..." -ForegroundColor Yellow + $patchResult = $null + try { + $patchResult = gh api --method PATCH "repos/dotnet/maui/issues/comments/$($existingUnifiedComment.id)" --input $tempFile --jq '.html_url' 2>&1 + if ($LASTEXITCODE -ne 0) { throw "PATCH failed (exit code $LASTEXITCODE): $patchResult" } + Write-Host "✅ PR finalize section updated: $patchResult" -ForegroundColor Green + } catch { + Write-Host "⚠️ Could not update comment (no edit permission?) — creating new comment instead: $_" -ForegroundColor Yellow + # Rebuild the comment body as a standalone new comment (not an injection into existing) + $standaloneBody = @" +$MAIN_MARKER -Remove-Item $tempFile +## 🤖 AI Summary + +$finalizeSection +"@ + $standaloneTempFile = [System.IO.Path]::GetTempFileName() + try { + @{ body = $standaloneBody } | ConvertTo-Json -Depth 10 | Set-Content -Path $standaloneTempFile -Encoding UTF8 + $result = gh api --method POST "repos/dotnet/maui/issues/$PRNumber/comments" --input $standaloneTempFile --jq '.html_url' + Write-Host "✅ Unified comment posted (new): $result" -ForegroundColor Green + } finally { + Remove-Item $standaloneTempFile -ErrorAction SilentlyContinue + } + } + } else { + Write-Host "Creating new unified comment on PR #$PRNumber..." -ForegroundColor Yellow + $result = gh api --method POST "repos/dotnet/maui/issues/$PRNumber/comments" --input $tempFile --jq '.html_url' + Write-Host "✅ Unified comment posted: $result" -ForegroundColor Green + } +} finally { + Remove-Item $tempFile -ErrorAction SilentlyContinue +} diff --git a/eng/pipelines/ci-copilot.yml b/eng/pipelines/ci-copilot.yml index bbd7fdad5c29..a6777956a5db 100644 --- a/eng/pipelines/ci-copilot.yml +++ b/eng/pipelines/ci-copilot.yml @@ -22,13 +22,31 @@ parameters: values: - android - ios + - catalyst + - windows - - name: pool + - name: androidPool type: object default: name: Azure Pipelines vmImage: ubuntu-22.04 + - name: iosPool + type: object + default: + name: AcesShared + + - name: macPool + type: object + default: + name: AcesShared + + - name: windowsPool + type: object + default: + name: Azure Pipelines + vmImage: windows-2022 + variables: - template: /eng/pipelines/common/variables.yml@self - name: Codeql.Enabled @@ -46,14 +64,24 @@ stages: jobs: - job: CopilotReview displayName: 'Run Copilot PR Reviewer Agent' - pool: ${{ parameters.pool }} + ${{ if eq(parameters.Platform, 'android') }}: + pool: ${{ parameters.androidPool }} + ${{ elseif eq(parameters.Platform, 'ios') }}: + pool: ${{ parameters.iosPool }} + ${{ elseif eq(parameters.Platform, 'catalyst') }}: + pool: ${{ parameters.macPool }} + ${{ elseif eq(parameters.Platform, 'windows') }}: + pool: ${{ parameters.windowsPool }} + ${{ else }}: + pool: ${{ parameters.windowsPool }} # fallback — should not be reached; AzDO parameter validation prevents unknown values timeoutInMinutes: 360 steps: - checkout: self fetchDepth: 0 persistCredentials: true - - script: | + # Validate Parameters + - bash: | echo "Validating PR Number parameter..." if [ -z "${{ parameters.PRNumber }}" ]; then echo "##vso[task.logissue type=error]PRNumber parameter is required" @@ -62,7 +90,7 @@ stages: echo "PR Number: ${{ parameters.PRNumber }}" displayName: 'Validate Parameters' - - script: | + - bash: | echo "##vso[build.updatebuildnumber]PR ${{ parameters.PRNumber }} ${{ parameters.Platform }}" displayName: 'Set Pipeline Run Title' @@ -73,7 +101,7 @@ stages: # Provision SDKs (same parameters as ui-tests-steps.yml) - template: common/provision.yml parameters: - skipXcode: ${{ eq(parameters.Platform, 'android') }} + skipXcode: ${{ or(eq(parameters.Platform, 'android'), eq(parameters.Platform, 'windows')) }} skipProvisionator: true skipJdk: ${{ ne(parameters.Platform, 'android') }} skipAndroidCommonSdks: ${{ ne(parameters.Platform, 'android') }} @@ -82,7 +110,7 @@ stages: skipAndroidEmulatorImages: ${{ ne(parameters.Platform, 'android') }} skipAndroidCreateAvds: true androidEmulatorApiLevel: '30' - skipSimulatorSetup: ${{ eq(parameters.Platform, 'android') }} + skipSimulatorSetup: ${{ or(eq(parameters.Platform, 'android'), eq(parameters.Platform, 'windows'), eq(parameters.Platform, 'catalyst')) }} skipCertificates: true # Install .NET and workloads via build.ps1 @@ -105,9 +133,16 @@ stages: PRIVATE_BUILD: $(PrivateBuild) # Restore .NET tools (includes xharness) - - script: dotnet tool restore + - bash: dotnet tool restore displayName: 'Restore .NET Tools' + # Set screen resolution on Windows (same as ui-tests-steps.yml) + - ${{ if eq(parameters.Platform, 'windows') }}: + - pwsh: | + $scriptPath = Join-Path "$(System.DefaultWorkingDirectory)" "eng" "scripts" "Set-ScreenResolution.ps1" + & $scriptPath -Width 1920 -Height 1080 + displayName: 'Set screen resolution' + # Create AVD and boot Android Emulator - ${{ if eq(parameters.Platform, 'android') }}: # Free disk space on hosted agents (emulator needs ~7GB for userdata partition) @@ -285,17 +320,23 @@ stages: env: APPIUM_HOME: $(APPIUM_HOME) - - script: | + - bash: | echo "Installing GitHub CLI..." - brew install gh - if ! gh --version; then - echo "##vso[task.logissue type=error]Failed to install GitHub CLI" + # gh is pre-installed on Azure Pipelines hosted images (ubuntu, macOS, windows) + # Attempt upgrade if available, otherwise install + if command -v gh &> /dev/null; then + echo "GitHub CLI already installed: $(gh --version | head -1)" + elif command -v brew &> /dev/null; then + brew install gh + else + echo "##vso[task.logissue type=error]GitHub CLI not found and no package manager available" exit 1 fi - echo "GitHub CLI installed successfully" + gh --version + echo "GitHub CLI ready" displayName: 'Install GitHub CLI' - - script: | + - bash: | echo "Authenticating with GitHub CLI..." if [ -z "$(GH_CLI_TOKEN)" ]; then echo "##vso[task.logissue type=error]GH_CLI_TOKEN is not set. Please configure the pipeline variable." @@ -317,7 +358,7 @@ stages: env: GH_CLI_TOKEN: $(GH_CLI_TOKEN) - - script: | + - bash: | echo "Installing GitHub Copilot CLI..." npm install -g @github/copilot # Ensure npm global bin is on PATH for subsequent steps (Linux UseNode installs to toolcache) @@ -331,7 +372,7 @@ stages: # Boot iOS Simulator (only for iOS platform) # UI test baseline screenshots are captured on iPhone Xs - must use same device - - script: | + - bash: | echo "=== Booting iOS Simulator ===" # Find the latest stable iOS runtime (prefer 18.x, fallback to 17.x) @@ -424,7 +465,7 @@ stages: # Warm up the emulator right before the agent runs. # The emulator may have been idle for 15-30 min while Appium/Node/CLI were installed. # Without this, SystemUI can ANR when the agent first touches it. - - script: | + - bash: | set -e DEVICE_ID="$(DEVICE_UDID)" if [ -z "$DEVICE_ID" ]; then @@ -484,20 +525,37 @@ stages: condition: and(succeeded(), eq('${{ parameters.Platform }}', 'android')) timeoutInMinutes: 3 - - script: | + - bash: | echo "Running Copilot PR Reviewer Agent via Review-PR.ps1..." echo "Reviewing PR #${{ parameters.PRNumber }}..." # Ensure copilot CLI is accessible to pwsh subprocess. # npm global install on Linux goes to UseNode@1 toolcache path which may not # be on PATH inside pwsh even when exported from bash. Create a symlink in - # /usr/local/bin which is universally on PATH for all shells. - COPILOT_PATH=$(which copilot 2>/dev/null || find /opt/hostedtoolcache/node -name copilot -type f 2>/dev/null | head -1) - if [ -n "$COPILOT_PATH" ] && [ ! -f /usr/local/bin/copilot ]; then - sudo ln -sf "$COPILOT_PATH" /usr/local/bin/copilot - echo "Symlinked copilot to /usr/local/bin/copilot" + # /usr/local/bin (Unix) or verify PATH (Windows). + if [[ "$(uname -o 2>/dev/null || uname -s)" == *"Msys"* ]] || [[ "$(uname -o 2>/dev/null || uname -s)" == *"Windows"* ]] || [[ "$(uname -o 2>/dev/null || uname -s)" == *"MINGW"* ]]; then + # Windows (Git Bash): npm global bin is usually already on PATH + echo "Windows detected — verifying copilot is on PATH..." + COPILOT_PATH=$(which copilot 2>/dev/null || true) + echo "copilot location: ${COPILOT_PATH:-not found}" + if [ -z "$COPILOT_PATH" ]; then + echo "##vso[task.logissue type=error]copilot CLI not found on PATH. Failing early." + exit 1 + fi + else + # Linux/macOS: symlink to /usr/local/bin + COPILOT_PATH=$(which copilot 2>/dev/null || find /opt/hostedtoolcache/node -name copilot -type f 2>/dev/null | head -1) + if [ -n "$COPILOT_PATH" ] && [ ! -f /usr/local/bin/copilot ]; then + sudo ln -sf "$COPILOT_PATH" /usr/local/bin/copilot + echo "Symlinked copilot to /usr/local/bin/copilot" + fi + COPILOT_PATH=$(which copilot 2>/dev/null || true) + echo "copilot location: ${COPILOT_PATH:-not found}" + if [ -z "$COPILOT_PATH" ]; then + echo "##vso[task.logissue type=error]copilot CLI not found on PATH. Failing early." + exit 1 + fi fi - echo "copilot location: $(which copilot 2>/dev/null || echo 'not found')" # Verify pwsh can find it pwsh -NoProfile -c 'Write-Host "pwsh sees copilot at: $(Get-Command copilot -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source)"' @@ -506,15 +564,15 @@ stages: git config user.name "Copilot CI" echo "Git identity configured" - # Create Directory.Build.Override.props to skip Xcode version check - # AcesShared agents may have a newer Xcode than the .NET iOS SDK expects + # Create Directory.Build.Override.props to skip Xcode version check (not needed on Windows) cp Directory.Build.Override.props.in Directory.Build.Override.props - # Insert ValidateXcodeVersion before closing tag - # GNU sed (Linux) uses -i without suffix; BSD sed (macOS) uses -i '' if [[ "$(uname)" == "Linux" ]]; then sed -i 's|| false\n|' Directory.Build.Override.props - else + elif [[ "$(uname)" == "Darwin" ]]; then sed -i '' 's|| false\n|' Directory.Build.Override.props + else + # Windows (Git Bash) — GNU sed, same as Linux + sed -i 's|| false\n|' Directory.Build.Override.props fi # Create artifacts directory for Copilot outputs @@ -606,7 +664,7 @@ stages: condition: and(succeededOrFailed(), ne(variables['LogDirectory'], '')) # Fail the pipeline if Copilot failed - - script: | + - bash: | if [ "$(CopilotFailed)" = "true" ]; then echo "##vso[task.logissue type=error]Copilot PR review failed. Check CopilotLogs artifact for details." exit 1