diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index e68575b4ebbf..a06771ae3239 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -24,7 +24,7 @@ "rollForward": false }, "microsoft.dotnet.xharness.cli": { - "version": "11.0.0-prerelease.26064.3", + "version": "11.0.0-prerelease.26230.4", "commands": [ "xharness" ], diff --git a/.gitattributes b/.gitattributes index 408b5738f0e6..8881658d9a6e 100644 --- a/.gitattributes +++ b/.gitattributes @@ -18,3 +18,5 @@ # avoid overriding GitInfo.txt on merge GitInfo.txt merge=ours + +.github/workflows/*.lock.yml linguist-generated=true merge=ours \ No newline at end of file diff --git a/.github/DEVELOPMENT.md b/.github/DEVELOPMENT.md index 2c3aec0083af..22f125490f6d 100644 --- a/.github/DEVELOPMENT.md +++ b/.github/DEVELOPMENT.md @@ -47,7 +47,7 @@ As a general rule: Use ‘main’ for bug fixes that don’t require API changes. For new features and changes to public APIs, you must use the branch of the next .NET version. -- [net10.0](https://github.com/dotnet/maui/tree/net10.0) +- [net11.0](https://github.com/dotnet/maui/tree/net11.0) ## Sample projects diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index 0072091a47a9..c95943777b35 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -42,6 +42,17 @@ body: label: Version with bug description: In what version do you see this issue? Run `dotnet workload list` to find your version. options: + - 11.0.0-preview.5 + - 11.0.0-preview.4 + - 11.0.0-preview.3 + - 11.0.0-preview.2 + - 11.0.0-preview.1 + - 10.0.80 + - 10.0.71 + - 10.0.70 + - 10.0.60 + - 10.0.50 + - 10.0.40 - 10.0.30 - 10.0.20 - 10.0.11 @@ -159,6 +170,17 @@ body: - 10.0.11 - 10.0.20 - 10.0.30 + - 10.0.40 + - 10.0.50 + - 10.0.60 + - 10.0.70 + - 10.0.71 + - 10.0.80 + - 11.0.0-preview.1 + - 11.0.0-preview.2 + - 11.0.0-preview.3 + - 11.0.0-preview.4 + - 11.0.0-preview.5 validations: required: true - type: dropdown diff --git a/.github/README-AI.md b/.github/README-AI.md index 4f6b1f21a8a7..a1577597031e 100644 --- a/.github/README-AI.md +++ b/.github/README-AI.md @@ -4,8 +4,8 @@ This folder contains instructions and configurations for AI coding assistants wo ## Available Agents -### PR Agent -The PR agent is a unified 5-phase workflow for investigating issues and reviewing/working on PRs. It handles everything from context gathering through test verification, fix exploration, and creating PRs or review reports. +### PR Review Agent +The pr-review skill is a 4-phase orchestrator for investigating issues and reviewing/working on PRs. It invokes dedicated phase skills (pr-preflight, pr-gate, try-fix, pr-report) for context gathering, test verification, fix exploration, and review reports. ### Sandbox Agent The sandbox agent is your general-purpose tool for working with the .NET MAUI Sandbox app. Use it for manual testing, PR validation, issue reproduction, and experimentation with MAUI features. @@ -84,13 +84,13 @@ copilot please write UI tests for issue #12345 ``` -**PR Agent:** +**Try-Fix-Validate Agent:** ```bash # Start GitHub Copilot CLI with agent support copilot -# Invoke the pr agent -/agent pr +# Invoke the pr-review skill +/skill pr-review # Fix an issue or review a PR please fix issue #12345 @@ -112,7 +112,7 @@ please review https://github.com/dotnet/maui/pull/XXXXX 3. **Choose your agent** from the dropdown: - `sandbox-agent` for manual testing and experimentation - `write-tests-agent` for writing tests (invokes appropriate skill) - - `pr` for reviewing and working on existing PRs + - `pr-review skill` for reviewing and working on existing PRs 4. **Enter a task** in the text box: - For sandbox testing: `Please test PR #32479` @@ -146,15 +146,14 @@ Automated testing specialist for the .NET MAUI test suite: 4. **Cross-Platform** - Tests on iOS, Android, Windows, and MacCatalyst 5. **Automated Workflow** - Uses `BuildAndRunHostApp.ps1` to handle building, deployment, and logging to `CustomAgentLogsTmp/UITests/` -### PR Agent +### PR Review Agent -Unified 5-phase workflow for issue investigation and PR work: +Unified 4-phase orchestrator for issue investigation and PR work: 1. **Pre-Flight** - Context gathering from issues/PRs -2. **Tests** - Create or verify reproduction tests exist -3. **Gate** - Verify tests catch the issue (mandatory checkpoint) -4. **Fix** - Explore fix alternatives using `try-fix` skill, compare approaches -5. **Report** - Create PR or write review report +2. **Gate** - Verify tests exist and catch the issue (mandatory checkpoint) +3. **Fix** - Explore fix alternatives using `try-fix` skill, compare approaches +4. **Report** - Create PR or write review report ### When Agents Pause @@ -200,20 +199,22 @@ Agents work with **time budgets as estimates for planning**, not hard deadlines: ## File Structure -### Agent Definitions -- **`agents/pr.md`** - PR workflow phases 1-3 (Pre-Flight, Tests, Gate) -- **`agents/pr/post-gate.md`** - PR workflow phases 4-5 (Fix, Report) -- **`agents/sandbox-agent.md`** - Sandbox agent for testing and experimentation -- **`agents/write-tests-agent.md`** - Test writing agent (dispatches to skills like write-ui-tests) +### Agent & Skill Definitions +- **`skills/pr-review/SKILL.md`** - PR Review orchestrator (invokes phase docs and try-fix skill) +- **`pr-review/pr-preflight.md`** - Phase 1: Context gathering (phase doc, not a standalone skill) +- **`pr-review/pr-gate.md`** - Phase 2: Test verification (phase doc, not a standalone skill) +- **`pr-review/pr-report.md`** - Phase 4: Final recommendation (phase doc, not a standalone skill) +- **`agents/sandbox-agent.agent.md`** - Sandbox agent for testing and experimentation +- **`agents/write-tests-agent.agent.md`** - Test writing agent (dispatches to skills like write-ui-tests) +- **`agents/learn-from-pr.agent.md`** - Extracts lessons from PRs and applies improvements ### Agent Files Agent files in the `.github/agents/` directory: -- **`agents/pr.md`** - PR workflow phases 1-3 (Pre-Flight, Tests, Gate) -- **`agents/pr/post-gate.md`** - PR workflow phases 4-5 (Fix, Report) -- **`agents/sandbox-agent.md`** - Sandbox app testing and experimentation -- **`agents/write-tests-agent.md`** - Test writing (invokes skills like write-ui-tests) +- **`agents/sandbox-agent.agent.md`** - Sandbox app testing and experimentation +- **`agents/write-tests-agent.agent.md`** - Test writing (invokes skills like write-ui-tests) +- **`agents/learn-from-pr.agent.md`** - Extracts PR lessons and applies repo improvements ### Shared Instruction Files @@ -251,12 +252,12 @@ Reusable skills in `.github/skills/` that agents can invoke: - **`verify-tests-fail-without-fix/`** - Verifies UI tests catch bugs (auto-detects mode based on git diff) - **`write-ui-tests/`** - Creates UI tests for issues following MAUI conventions - **`write-xaml-tests/`** - Creates XAML unit tests for parsing, XamlC, and source generation issues -- **`pr-build-status/`** - Retrieves Azure DevOps build status for PRs +- **`azdo-build-investigator/`** - Investigates CI failures for PRs (build errors, Helix test logs, binlog analysis) via dotnet/arcade-skills plugin ### Recent Improvements (January 2026) -**PR Agent Consolidation:** -1. **Unified PR Agent** - Replaced separate `issue-resolver` and `pr-reviewer` agents with single 5-phase `pr` agent +**Agent Consolidation:** +1. **Unified PR Review Orchestrator** - Replaced separate `issue-resolver` and `pr-reviewer` agents with 4-phase `pr-review` skill that orchestrates `pr-preflight`, `pr-gate`, `try-fix`, and `pr-report` phase skills 2. **try-fix Skill** - New skill for exploring independent fix alternatives with empirical testing 3. **Skills Integration** - Added `verify-tests-fail-without-fix` and `write-ui-tests` skills for reusable test workflows 4. **Agent/Skills Guidelines** - New instruction files for authoring agents and skills @@ -365,8 +366,8 @@ For issues or questions about the AI agent instructions: ## Metrics **Agent Files**: -- 4 agent files (pr.md, pr/post-gate.md, sandbox-agent.md, write-tests-agent.md) -- 5 skills (try-fix, verify-tests-fail-without-fix, write-ui-tests, write-xaml-tests, pr-build-status) +- 3 agent files (sandbox-agent.agent.md, write-tests-agent.agent.md, learn-from-pr.agent.md) +- 15 skills (pr-review, try-fix, verify-tests-fail-without-fix, write-ui-tests, write-xaml-tests, azdo-build-investigator, code-review, evaluate-pr-tests, find-reviewable-pr, issue-triage, learn-from-pr, pr-finalize, run-device-tests, run-helix-tests, run-integration-tests) + 3 phase docs (pr-preflight, pr-gate, pr-report) - All validated and consistent with consolidated structure **Automation**: @@ -384,4 +385,4 @@ For issues or questions about the AI agent instructions: **Last Updated**: 2026-01-07 -**Note**: These instructions are actively being refined based on real-world usage. PR agent consolidation completed January 2026 (unified 5-phase workflow with try-fix skill). Feedback and improvements are welcome! +**Note**: These instructions are actively being refined based on real-world usage. Agent consolidation completed January 2026 (unified 4-phase workflow with try-fix skill). Feedback and improvements are welcome! diff --git a/.github/actions/triage-ai-gen-prompt/action.yml b/.github/actions/triage-ai-gen-prompt/action.yml deleted file mode 100644 index 6343bcc0a9a8..000000000000 --- a/.github/actions/triage-ai-gen-prompt/action.yml +++ /dev/null @@ -1,59 +0,0 @@ -name: 'Triage Generate Prompt' -description: 'Generates a prompt file from a template using provided labels and parameters.' - -inputs: - token: - description: 'GitHub token to use for authentication' - required: false - default: ${{ github.token }} - template: - description: 'Path to the prompt template file' - required: true - output: - description: 'Path to the output prompt file' - required: true - # variable replacement inputs - # TODO: make this be a multiline mapping input: - # NAME1=value1 - # NAME2=value2 - label-prefix: - description: 'Prefix for label search (e.g., platform/, area-, etc.)' - required: false - default: '' - label: - description: 'The label to apply to an issue' - required: false - default: '' - -outputs: - prompt: - description: 'The generated prompt file path' - value: ${{ steps.create-prompt.outputs.prompt }} - -runs: - using: 'composite' - steps: - - - name: Create prompt file - id: create-prompt - shell: pwsh - env: - GH_TOKEN: ${{ inputs.token }} - run: | - "Create prompt file" - echo "::group::Create prompt file" - ${{ github.action_path }}/process-prompt-template.ps1 ` - -LabelPrefix "${{ inputs.label-prefix }}" ` - -Label "${{ inputs.label }}" ` - -Template "${{ inputs.template }}" ` - -Output "${{ inputs.output }}" - "prompt=${{ inputs.output }}" >> $env:GITHUB_OUTPUT - echo "::endgroup::" - - - name: Print prompt file - shell: pwsh - run: | - "Print prompt file" - echo "::group::Print prompt file" - cat "${{ steps.create-prompt.outputs.prompt }}" - echo "::endgroup::" diff --git a/.github/actions/triage-ai-gen-prompt/process-prompt-template.ps1 b/.github/actions/triage-ai-gen-prompt/process-prompt-template.ps1 deleted file mode 100644 index 8f5b277ccda1..000000000000 --- a/.github/actions/triage-ai-gen-prompt/process-prompt-template.ps1 +++ /dev/null @@ -1,86 +0,0 @@ -param( - [Parameter(Mandatory=$true)] - [string]$Template, - [Parameter(Mandatory=$true)] - [string]$Output, - [string]$LabelPrefix, - [string]$Label -) - -Write-Host "Processing template: $Template" -Write-Host "Output will be written to: $Output" - -# Check if template file exists -if (-not (Test-Path -Path $Template)) { - Write-Error "The specified template file '$Template' does not exist. Please check the path and try again." - exit 1 -} - -# Ensure output directory exists -$outputDir = Split-Path -Parent $Output -if ($outputDir -and -not (Test-Path -Path $outputDir)) { - New-Item -Path $outputDir -ItemType Directory -Force | Out-Null -} - -# Change to the output directory for processing -$originalLocation = Get-Location -try { - Set-Location -Path $outputDir - Write-Host "Changed working directory to: $outputDir" - Write-Host "" - - # Read the template file - $lines = Get-Content $Template - $outputContent = @() - - foreach ($line in $lines) { - # Replace the placeholders with actual values - if ($LabelPrefix) { - $line = $line.Replace('{{LABEL_PREFIX}}', $LabelPrefix) - } - if ($Label) { - $line = $line.Replace('{{LABEL}}', $Label) - } - - # Check for EXEC: command prefix - if ($line -match "^EXEC:\s*(.+)$") { - - # Extract the command part - $command = $matches[1] - Write-Host "Executing command:" - Write-Host " $command" - - try { - # Execute the command - $result = Invoke-Expression $command - Write-Host "Command output:" - foreach ($resultLine in $result) { - Write-Host " $resultLine" - } - - # Append the result to output content - $outputContent += $result - } catch { - Write-Error "ERROR executing command '$command': $_" - exit 1 - } - } else { - # Keep original line - $outputContent += $line - } - } - - # Save the processed content to the output file - $outputFilename = Split-Path -Leaf $Output - Set-Content -Path $outputFilename -Value $outputContent -ErrorAction Stop - - # Log the created prompt for debugging - Write-Host "" - Write-Host "Created prompt from template:" - Write-Host "" - Get-Content $outputFilename - -} finally { - # Return to original location - Set-Location -Path $originalLocation -} diff --git a/.github/actions/triage-ai/action.yml b/.github/actions/triage-ai/action.yml deleted file mode 100644 index 5286a2398458..000000000000 --- a/.github/actions/triage-ai/action.yml +++ /dev/null @@ -1,69 +0,0 @@ -name: 'Triage AI Action' -description: 'Run AI inference.' - -inputs: - token: - description: 'GitHub token to use for authentication' - required: false - default: ${{ github.token }} - prompt-file: - description: 'Path to the user prompt file' - required: true - system-prompt-file: - description: 'Path to the system prompt file' - required: false - response-file: - description: 'Path to the file where the AI response will be saved' - required: true - model: - description: The model to use - required: false - default: 'openai/gpt-4o' - endpoint: - description: The endpoint to use - required: false - default: 'https://models.github.ai/inference' - max-tokens: - description: 'Maximum number of tokens for the AI response' - required: false - default: '200' - -outputs: - response: - description: 'The file that contains the AI response' - value: ${{ steps.move-response.outputs.response }} - -runs: - using: 'composite' - steps: - - - name: Run AI inference - id: inference - uses: actions/ai-inference@main - with: - model: ${{ inputs.model }} - endpoint: ${{ inputs.endpoint }} - max-tokens: ${{ inputs.max-tokens }} - prompt-file: ${{ inputs.prompt-file }} - system-prompt-file: ${{ inputs.system-prompt-file }} - - - name: Move AI response to response file - id: move-response - shell: pwsh - run: | - "Move AI response to response file" - echo "::group::Move AI response to response file" - $src = "${{ steps.inference.outputs.response-file }}" - $dst = "${{ inputs.response-file }}" - Move-Item -Path $src -Destination $dst -Force - Add-Content -Path $dst -Value "`n" - "response=$dst" >> $env:GITHUB_OUTPUT - echo "::endgroup::" - - - name: Print AI response - shell: pwsh - run: | - "Print AI response" - echo "::group::AI Response" - cat "${{ steps.move-response.outputs.response }}" - echo "::endgroup::" diff --git a/.github/actions/triage-apply/action.yml b/.github/actions/triage-apply/action.yml deleted file mode 100644 index 246132fef375..000000000000 --- a/.github/actions/triage-apply/action.yml +++ /dev/null @@ -1,115 +0,0 @@ -name: 'Apply Labels and Comment' -description: 'Merge label JSON files, summarize, comment on the issue, and apply labels.' - -inputs: - token: - description: 'GitHub token to use for authentication' - required: false - default: ${{ github.token }} - issue: - description: 'The issue number to update' - required: true - input-files: - description: 'Comma-separated or newline-separated list of JSON files with results to merge' - required: false - footer: - description: 'Footer text to append to the AI response comment' - required: false - default: '_This entire triage process was automated by AI and mistakes may have been made. Please let us know so we can continue to improve._' - -outputs: - merged-file: - description: 'The merged JSON file with all labels and summary.' - value: ${{ steps.merge-labels.outputs.merged-file }} - -runs: - using: 'composite' - steps: - - - name: Setup working directory - id: setup - uses: ./.github/actions/triage-setup - with: - token: ${{ inputs.token }} - - - name: Merge response JSON files - id: merge - shell: pwsh - run: | - "Merge response JSON files" - echo "::group::Merge response JSON files" - $out = "${{ steps.setup.outputs.work-dir }}/merged.json" - ${{ github.action_path }}/merge-responses.ps1 ` - -InputFiles "${{ inputs.input-files }}" ` - -InputDir "${{ runner.temp }}/triage-action-responses" ` - -Output "$out" - "merged-file=$out" >> $env:GITHUB_OUTPUT - echo "::endgroup::" - - - name: Create System prompt file - id: create-system-prompt - uses: ./.github/actions/triage-ai-gen-prompt - with: - token: ${{ inputs.token }} - template: ${{ github.action_path }}/system-prompt.md - output: ${{ steps.setup.outputs.work-dir }}/system-prompt.md - - - name: Create User prompt file - id: create-user-prompt - uses: ./.github/actions/triage-ai-gen-prompt - with: - token: ${{ inputs.token }} - template: ${{ github.action_path }}/user-prompt.md - output: ${{ steps.setup.outputs.work-dir }}/user-prompt.md - - - name: Run Triage AI Action - id: triage-ai - uses: ./.github/actions/triage-ai - with: - system-prompt-file: "${{ steps.create-system-prompt.outputs.prompt }}" - prompt-file: "${{ steps.create-user-prompt.outputs.prompt }}" - token: ${{ inputs.token }} - response-file: "${{ steps.setup.outputs.work-dir }}/response.md" - max-tokens: 500 - - - name: Comment on the issue with summary - uses: actions/github-script@v7 - with: - script: | - const fs = require('fs'); - const summary = fs.readFileSync('${{ steps.triage-ai.outputs.response }}', 'utf8'); - - const issueNumber = parseInt('${{ inputs.issue }}', 10); - const footer = '${{ inputs.footer }}'; - - const commentBody = `${summary}\n\n${footer}`; - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - body: commentBody - }); - - - name: Apply labels to the issue - uses: actions/github-script@v7 - with: - script: | - const fs = require('fs'); - const mergedJson = fs.readFileSync('${{ steps.merge.outputs.merged-file }}', 'utf8'); - const merged = JSON.parse(mergedJson); - - const issueNumber = parseInt('${{ inputs.issue }}', 10); - - const labels = merged.labels - .map(l => l.label) - .filter(Boolean); - - if (labels.length > 0) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - labels - }); - } diff --git a/.github/actions/triage-apply/merge-responses.ps1 b/.github/actions/triage-apply/merge-responses.ps1 deleted file mode 100644 index d982e5b8f9bb..000000000000 --- a/.github/actions/triage-apply/merge-responses.ps1 +++ /dev/null @@ -1,89 +0,0 @@ -param( - [Parameter(Mandatory=$false)][string]$InputFiles, - [Parameter(Mandatory=$false)][string]$InputDir, - [Parameter(Mandatory=$true)][string]$Output -) - -$ErrorActionPreference = 'Stop' - -Write-Host "Input files: $InputFiles" -Write-Host "Input directory: $InputDir" -Write-Host "Output file: $Output" - -$allFiles = @() - -# Process individual files from InputFiles parameter -# Accept both comma and newline separated input -if ($InputFiles) { - $allFiles += $InputFiles -split '[,\n\r]+' | - ForEach-Object { $_.Trim() } | - Where-Object { $_ } - - if ($allFiles.Count -gt 0) { - Write-Host "Merging files from InputFiles parameter:" - foreach ($file in $allFiles) { - Write-Host " $file" - } - } -} - -# Process all JSON files from InputDir parameter if there are no files specified in InputFiles -if ($allFiles.Count -eq 0 -and $InputDir -and (Test-Path $InputDir)) { - $jsonFiles = Get-ChildItem -Path $InputDir -Filter "*.json" -File - - if ($jsonFiles.Count -gt 0) { - Write-Host "Merging files from InputDir parameter:" - } - - foreach ($file in $jsonFiles) { - $allFiles += $file.FullName - - Write-Host " $file" - } -} - -# If no files were specified, exit with an error -if ($allFiles.Count -eq 0) { - Write-Error "No input files specified. Please provide either InputFiles or InputDir with JSON files." - exit 1 -} - -# Read all JSON files and merge root properties -$merged = @{} -foreach ($file in $allFiles) { - if (Test-Path $file) { - Write-Host "Processing file: $file..." - $fileContents = Get-Content $file - - # Remove empty lines - $fileContents = $fileContents | Where-Object { $_ -ne '' } - - # Remove the wrapping lines if they contain ``` - if ($fileContents[0] -match '^\s*```') { - $fileContents = $fileContents[1..($fileContents.Length - 1)] - } - if ($fileContents[-1] -match '^\s*```') { - $fileContents = $fileContents[0..($fileContents.Length - 2)] - } - - # Convert from JSON - $json = $fileContents | ConvertFrom-Json - foreach ($prop in $json.PSObject.Properties) { - $name = $prop.Name - $value = $prop.Value - - # Merge properties by name - if ($merged.ContainsKey($name)) { - $merged[$name] += $value - } else { - $merged[$name] = $value - } - } - } -} - -# Save to the output file -New-Item -Path (Split-Path -Path $Output) -ItemType Directory -Force | Out-Null -$merged | - ConvertTo-Json | - Set-Content $Output diff --git a/.github/actions/triage-apply/system-prompt.md b/.github/actions/triage-apply/system-prompt.md deleted file mode 100644 index c9c511a170cd..000000000000 --- a/.github/actions/triage-apply/system-prompt.md +++ /dev/null @@ -1,58 +0,0 @@ -You are an assistant helping to triage GitHub issues. Your -focus is to summarize some actions and then prove the user -with an easy to understand message while also being detailed. - -## Summarization Process - -* Summarize all the labels that are being applied in a - single, short sentence. -* Provide a sentence or two summarizing in more detail about - the labels to be applied. -* Create a table for all the actions that will be performed. -* Take special not if this is a regression and any details - around it. - - -## Response - -* **IMPORTANT** Respond with a correctly formed markdown file. -* **IMPORTANT** Do not wrap the markdown in code blocks as it will - be rendered directly -* The markdown file should have 3 main parts: - 1. A short sentence summary about the affected components. - 2. A short sentence summary about whether or not this is a regression. - 3. Collapsable section for details - A. Detailed summary as a bulleted list - B. Complete actions table in the format: - | Action | Item | Description | - | :----- | :--- | :---------- | - | Action 1 | Item 1 | Reason 1 | - -An example response would be like this: - - -**Triage Summary** - -Labels will be applied to indicate the affected platforms (SquareOS and BoxPhone) and the specific area of the issue (Carrot control). - -This issue is a regression since the Carrot was growing fine in v1 but now is broken in v2. - -
-Detailed Summary and Actions - -Summary of the triage: - -- The issue affects multiple platforms: SquareOS and BoxPhone. -- The issue pertains to the Carrot control, specifically its `OrangeColor` and `GrowthMedium` properties. -- This issue is a regression in v2, since v1 was working correctly. - -Summary of the actions that will be performed: - -| Action | Item | Description | -| :----- | :--- | :---------- | -| Apply Label | platform-squareos | The issue specifies that the behavior is affecting SquateOS as one of the platforms. | -| Apply Label | platform-boxphone | The issue specifies that the behavior is affecting the phones made by Box as one of the platforms. | -| Apply Label | area-carrots | The issue pertains to the Carrot user control and its properties, specifically involving the `OrangeColor` and `GrowthMedium` properties. | -| Apply Label | regression | The issue indicates that there is a regression. | - -
diff --git a/.github/actions/triage-apply/user-prompt.md b/.github/actions/triage-apply/user-prompt.md deleted file mode 100644 index 99e0bdb48ef8..000000000000 --- a/.github/actions/triage-apply/user-prompt.md +++ /dev/null @@ -1,5 +0,0 @@ -Please summarize the results of this triage. - -The following labels will be applied for the specified reasons: - -EXEC: jq -r '"| Label | Reason |", "|:-|:-|", (.labels[] | "| \(.label) | \(.reason) |")' merged.json diff --git a/.github/actions/triage-labels/action.yml b/.github/actions/triage-labels/action.yml deleted file mode 100644 index b69955da6b1d..000000000000 --- a/.github/actions/triage-labels/action.yml +++ /dev/null @@ -1,105 +0,0 @@ -name: 'Flexible Triage Labels Action' -description: 'Process issues and apply labels using AI inference, supporting multiple triage modes.' - -inputs: - token: - description: 'GitHub token to use for authentication' - required: false - default: ${{ github.token }} - issue: - description: 'The issue to triage' - required: true - mode: - description: 'Triage mode: multi-label, single-label, regression, missing-info' - required: true - default: 'multi-label' - label-prefix: - description: 'Prefix for label search (e.g., platform/, area-, etc.)' - required: false - default: '' - label: - description: 'The label to apply to an issue' - required: false - default: '' - -outputs: - response-file: - description: 'The file that contains the labels to apply to the issue' - value: ${{ steps.triage-ai.outputs.response }} - -runs: - using: 'composite' - steps: - - - name: Setup working directory - id: setup - uses: ./.github/actions/triage-setup - with: - token: ${{ inputs.token }} - - - name: Fetch issue data - id: issue-data - uses: actions/github-script@v7 - with: - script: | - const issueNumber = '${{ inputs.issue }}' || github.event.issue.number; - const issue = await github.rest.issues.get({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber - }); - - const data = issue.data; - const json = JSON.stringify(data, null, 2); - - const fs = require('fs'); - fs.writeFileSync("${{ steps.setup.outputs.work-dir }}/issue.json", json); - - return data; - - - name: Select system prompt template - id: select-system-prompt - shell: pwsh - run: | - "Select system prompt template" - echo "::group::Select system prompt template" - $mode = "${{ inputs.mode }}" - switch ($mode) { - "multi-label" { $template = "${{ github.action_path }}/system-prompt-multilabel.md" } - "single-label" { $template = "${{ github.action_path }}/system-prompt-singlelabel.md" } - "regression" { $template = "${{ github.action_path }}/system-prompt-regression.md" } - "missing-info" { $template = "${{ github.action_path }}/system-prompt-missinginfo.md" } - default { throw "Unknown mode: $mode" } - } - "Using system prompt template: $template" - "system-prompt-template=$template" >> $env:GITHUB_OUTPUT - echo "::endgroup::" - - - name: Create System prompt file - id: create-system-prompt - uses: ./.github/actions/triage-ai-gen-prompt - with: - token: ${{ inputs.token }} - label-prefix: ${{ inputs.label-prefix }} - label: ${{ inputs.label }} - template: ${{ steps.select-system-prompt.outputs.system-prompt-template }} - output: ${{ steps.setup.outputs.work-dir }}/system-prompt.md - - - name: Create User prompt file - id: create-user-prompt - uses: ./.github/actions/triage-ai-gen-prompt - with: - token: ${{ inputs.token }} - label-prefix: ${{ inputs.label-prefix }} - label: ${{ inputs.label }} - template: ${{ github.action_path }}/user-prompt.md - output: ${{ steps.setup.outputs.work-dir }}/user-prompt.md - - - name: Run Triage AI Action - id: triage-ai - uses: ./.github/actions/triage-ai - with: - system-prompt-file: "${{ steps.create-system-prompt.outputs.prompt }}" - prompt-file: "${{ steps.create-user-prompt.outputs.prompt }}" - token: ${{ inputs.token }} - response-file: "${{ steps.setup.outputs.responses-dir }}/response-${{ steps.setup.outputs.work-id }}.json" diff --git a/.github/actions/triage-labels/system-prompt-missinginfo.md b/.github/actions/triage-labels/system-prompt-missinginfo.md deleted file mode 100644 index 08d1f3686db9..000000000000 --- a/.github/actions/triage-labels/system-prompt-missinginfo.md +++ /dev/null @@ -1,80 +0,0 @@ -You are an expert triage assistant who evaluates if issue -reports contain sufficient information to reproduce and -diagnose reported problems. - -## Essential Reproduction Information - -1. **Clear description** of the bug with observable behavior -2. **Detailed steps to reproduce** with specific actions -3. **Code** via one of the following (in order of preference): - - **Public repository link** (preferred) - - **Minimal sample project** attachment - - **Complete code snippets** (only for small issues) - -## Optional Information (depending on issue) - -- **Platform versions** (not always needed unless platform-specific) -- **Log output** (mainly needed for runtime crashes or build errors) - -## Evaluation Guidelines - -1. Verify **steps to reproduce** are clear, specific, and complete -2. Confirm **code samples/projects** are provided and accessible -3. Check if **environment details** are sufficient -4. Identify any **missing critical information** -5. Determine if the problem can be **reliably reproduced** - -## When to Apply Labels - -- Apply "s/needs-info" when: - - Steps to reproduce are missing or vague - - Expected/actual behavior is unclear - -- Apply "s/needs-repro" when: - - No code snippets, repository links, or sample projects are provided - - Code snippets are too large/complex to be useful without a proper sample - -- Do NOT request: - - Repository links if a public repo, zip file, or sufficient small snippet is provided - - Platform versions unless the issue depends on platform-specific behavior - -## Response Format - -* Respond in valid and properly formatted JSON with the - following structure and only in this structure. -* Do not wrap the JSON in any other text or formatting, - including code blocks or markdown as this will be read - by a machine. -* Always include all relevant links in the response. - -If issue has all necessary information: - -{ - "repro": { - "links": [ - "Link1", - "Link2" - ] - } -} - -If issue is missing information: - -{ - "repro": { - "links": [ - "Link1", - "Link2" - ] - }, - "labels": [ - { - "label": "NEEDS_INFO_LABEL", - "reason": "REASON_FOR_NEEDING_MORE_INFO" - }, - { - "label": "NEEDS_REPRO_CODE_LABEL", - "reason": "REASON_FOR_NEEDING_REPRO_CODE" - } - ] -} diff --git a/.github/actions/triage-labels/system-prompt-multilabel.md b/.github/actions/triage-labels/system-prompt-multilabel.md deleted file mode 100644 index 441eca9cd546..000000000000 --- a/.github/actions/triage-labels/system-prompt-multilabel.md +++ /dev/null @@ -1,52 +0,0 @@ -You are an expert triage assistant who is able to correctly and -accurately assign multiple labels to new issues that are opened. - -## Triage Process -1. Carefully analyze the issue to be labeled. -2. Locate and prioritize the key bits of information. -3. Pick all appropriate labels from the list below and assign - them. -4. If none of the labels are correct, do not assign any labels. -5. If no issue content was provided or if there is not enough - content to make a decision, do not assign any labels. -6. If the label that you have selected is not in the list - of labels, then do not assign any labels. -7. If no labels match or can be assigned, then you are to reply - with a `null` label and `null` reason. - -## Labels -* The only labels that are valid for assignment are found - between the "===== Available Labels =====" lines. -* Do not return a label if that label is not found in - the list. -* Some labels have an additional description that should - be used in order to find the best match. - -===== Available Labels ===== -EXEC: gh label list --limit 1000 --json name,description --search "{{LABEL_PREFIX}}" --jq 'sort_by(.name)[] | select(.name | startswith("{{LABEL_PREFIX}}")) | "- name: \(.name)\n description: \(.description)"' -===== Available Labels ===== - -## Reasoning -* You are to also provide a reason as to why each label - was selected to make sure that everyone knows why. -* You need to make sure to mention other related labels - and why they were not a good selection for the issue. -* Make sure your reason is short and concise, but - includes the reason for the selection and the rejection. - -## Response -* Respond in valid and properly formatted JSON with the - following structure and only in this structure. -* Do not wrap the JSON in any other text or formatting, - including code blocks or markdown as this will be read - by a machine. - -{ - "labels": [ - { - "label": "LABEL_NAME_HERE", - "reason": "REASON_FOR_LABEL_HERE" - }, - ... - ] -} diff --git a/.github/actions/triage-labels/system-prompt-regression.md b/.github/actions/triage-labels/system-prompt-regression.md deleted file mode 100644 index 88819b4694f1..000000000000 --- a/.github/actions/triage-labels/system-prompt-regression.md +++ /dev/null @@ -1,68 +0,0 @@ -You are an expert triage assistant who can accurately identify -issues that are likely regressions. - -## Regression Detection Process -1. Carefully analyze the issue content. -2. Look for language that suggests something used to work but - is now broken, such as: - "regression", "no longer works", "used to work", "after upgrade", - "after updating", "since version", "broke in", "stopped working", - "was working", "previously worked", "after installing", - "after migration", "after update", "after changing version", - "after switching", "after moving to", "after patch", "after hotfix", - "after release", "after install", "after deployment", "after build", - "after merge", "after commit", "after PR", "after pull request" -3. Only if you find strong evidence of a regression, assign the - "{{LABEL}}" label below. -4. **IMPORTANT** Reference specific evidence from the issue - content, such as changes in behavior, error messages, or - user reports that indicate a previously working feature - is now broken. -5. **IMPORTANT** If you do not find strong evidence of a - regression, do not assign any labels and instead return an - empty object. -6. **IMPORTANT** If you find strong evidence of a regression - make sure to keep track of the versions: - * Specific version that was last known to be working - * Specific version that is not working -7. If there are no version numbers that can be used to - track when it broke, then leave the versions out. - - -## Reasoning -* Provide a short reason for your decision, referencing the - evidence in the issue. -* If not assigning any labels, reply with an empty object. -* Make sure your reason is short and concise. -* Always provide versions of both working and broken. - - -## Response -* Respond in valid and properly formatted JSON with one of - the following structures and only in these structures. -* Do not wrap the JSON in any other text or formatting, - including code blocks or markdown as this will be read - by a machine. - -If this issue has strong evidence of a regression, respond with: - -{ - "regression": { - "working-version": "VERSION_LAST_KNOWN_WORKING", - "broken-version": "VERSION_BROKEN", - "evidence": "SPECIFIC_EVIDENCE_OF_REGRESSION" - }, - "labels":[ - { - "label": "{{LABEL}}", - "reason": "REASON_FOR_LABEL_HERE" - } - ] -} - -If this issue does not have strong evidence a regression, respond with: - -{ - "labels": [ - ] -} diff --git a/.github/actions/triage-labels/system-prompt-singlelabel.md b/.github/actions/triage-labels/system-prompt-singlelabel.md deleted file mode 100644 index cae67b0f5158..000000000000 --- a/.github/actions/triage-labels/system-prompt-singlelabel.md +++ /dev/null @@ -1,49 +0,0 @@ -You are an expert triage assistant who is able to correctly and -accurately assign a single best label to new issues that are opened. - -## Triage Process -1. Carefully analyze the issue to be labeled. -2. Locate and prioritize the key bits of information. -3. Pick the single best label from the list below and assign it. -4. If none of the labels are correct, do not assign any labels. -5. If no issue content was provided or if there is not enough - content to make a decision, do not assign any labels. -6. If the label that you have selected is not in the list of - labels, then do not assign any labels. -7. If no labels match or can be assigned, then you are to - reply with a `null` label and `null` reason. - -## Labels -* The only labels that are valid for assignment are found - between the "===== Available Labels =====" lines. -* Do not return a label if that label is not found in there. -* Some labels have an additional description that should be - used in order to find the best match. - -===== Available Labels ===== -EXEC: gh label list --limit 1000 --json name,description --search "{{LABEL_PREFIX}}" --jq 'sort_by(.name)[] | select(.name | startswith("{{LABEL_PREFIX}}")) | "- name: \(.name)\n description: \(.description)"' -===== Available Labels ===== - -## Reasoning -* You are to also provide a reason as to why that label was - selected to make sure that everyone knows why. -* You need to make sure to mention other related labels and - why they were not a good selection for the issue. -* Make sure your reason is short and concise, but includes - the reason for the selection and the rejection. - -## Response -* Respond in valid and properly formatted JSON with the - following structure and only in this structure. -* Do not wrap the JSON in any other text or formatting, - including code blocks or markdown as this will be read - by a machine. - -{ - "labels": [ - { - "label": "LABEL_NAME_HERE", - "reason": "REASON_FOR_LABEL_HERE" - } - ] -} diff --git a/.github/actions/triage-labels/user-prompt.md b/.github/actions/triage-labels/user-prompt.md deleted file mode 100644 index 3ad5ca33fa38..000000000000 --- a/.github/actions/triage-labels/user-prompt.md +++ /dev/null @@ -1,11 +0,0 @@ -A new issue has arrived, please triage and apply -the appropriate labels. The issue is as follows: - -The issue number is: -EXEC: jq -r '"#" + (.number | tostring)' issue.json - -The title is: -EXEC: jq -r '.title' issue.json - -The body is: -EXEC: jq -r '.body' issue.json diff --git a/.github/actions/triage-setup/action.yml b/.github/actions/triage-setup/action.yml deleted file mode 100644 index eaa7d0630e8d..000000000000 --- a/.github/actions/triage-setup/action.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: 'Setup Triage Action' - -description: 'Generates a unique working directory and sets up git/gh context.' - -inputs: - token: - description: 'GitHub token to use for authentication' - required: false - default: ${{ github.token }} - -outputs: - work-id: - description: 'The unique ID for the working directory' - value: ${{ steps.gen-work-dir.outputs.work-id }} - work-dir: - description: 'The path to the working directory' - value: ${{ steps.gen-work-dir.outputs.work-dir }} - responses-dir: - description: 'The path to the collected responses directory' - value: ${{ steps.gen-work-dir.outputs.responses-dir }} - -runs: - using: 'composite' - steps: - - - name: Generate a unique working directory - id: gen-work-dir - shell: pwsh - run: | - "Generate a unique working directory" - echo "::group::Generate a unique working directory" - - $guid = [guid]::NewGuid().ToString() - "work-id=$guid" >> $env:GITHUB_OUTPUT - - $workDir = "${{ runner.temp }}/triage-action-$guid"; - New-Item -ItemType Directory -Path $workDir -Force | Out-Null - "work-dir=$workDir" >> $env:GITHUB_OUTPUT - - $responsesDir = "${{ runner.temp }}/triage-action-responses"; - New-Item -ItemType Directory -Path $responsesDir -Force | Out-Null - "responses-dir=$responsesDir" >> $env:GITHUB_OUTPUT - - echo "::endgroup::" - - - name: Setup working directory - shell: pwsh - env: - GH_TOKEN: ${{ inputs.token }} - run: | - "Setup working directory" - echo "::group::Setup working directory" - cd "${{ steps.gen-work-dir.outputs.work-dir }}" - git init - git remote add origin ${{ github.repositoryUrl }} - gh repo set-default ${{ github.repository }} - echo "::endgroup::" diff --git a/.github/agent-pr-session/pr-32289.md b/.github/agent-pr-session/pr-32289.md deleted file mode 100644 index 33c9e80c1071..000000000000 --- a/.github/agent-pr-session/pr-32289.md +++ /dev/null @@ -1,202 +0,0 @@ -# PR Review: #32289 - Fix handler not disconnected when removing non visible pages using RemovePage() - -**Date:** 2026-01-07 | **Issue:** [#32239](https://github.com/dotnet/maui/issues/32239) | **PR:** [#32289](https://github.com/dotnet/maui/pull/32289) - -## ✅ Status: COMPLETE - -| Phase | Status | -|-------|--------| -| Pre-Flight | ✅ COMPLETE | -| 🧪 Tests | ✅ COMPLETE | -| 🚦 Gate | ✅ PASSED | -| 🔧 Fix | ✅ COMPLETE | -| 📋 Report | ✅ COMPLETE | - ---- - -
-📋 Issue Summary - -**Problem:** When removing pages from a NavigationPage's navigation stack using `NavigationPage.Navigation.RemovePage()`, handlers are not properly disconnected from the removed pages. However, using `ContentPage.Navigation.RemovePage()` correctly disconnects handlers. - -**Root Cause (from PR):** The `RemovePage()` method removes the page from the navigation stack but does not explicitly disconnect its handler. - -**Regression:** Introduced in PR #24887, reproducible from MAUI 9.0.40+ - -**Steps to Reproduce:** -1. Push multiple pages onto a NavigationPage stack -2. Call `NavigationPage.Navigation.RemovePage()` on a non-visible page -3. Observe that the page's handler remains connected (no cleanup) - -**Workaround:** Manually call `.DisconnectHandlers()` after removing the page - -**Platforms Affected:** -- [x] iOS -- [x] Android -- [x] Windows -- [x] MacCatalyst - -
- -
-📁 Files Changed - -| File | Type | Changes | -|------|------|---------| -| `src/Controls/src/Core/NavigationPage/NavigationPage.cs` | Fix | +4 lines | -| `src/Controls/src/Core/NavigationPage/NavigationPage.Legacy.cs` | Fix | +4 lines | -| `src/Controls/src/Core/NavigationProxy.cs` | Fix | -1 line (removed duplicate) | -| `src/Controls/src/Core/Shell/ShellSection.cs` | Fix | +1 line | -| `src/Controls/tests/Core.UnitTests/NavigationUnitTest.cs` | Unit Test | +63 lines | -| `src/Controls/tests/Core.UnitTests/ShellNavigatingTests.cs` | Unit Test | +25 lines | - -**Test Type:** Unit Tests - -
- -
-💬 PR Discussion Summary - -**Key Comments:** -- Copilot flagged potential duplicate disconnection logic between NavigationProxy and NavigationPage -- Author responded by removing redundant logic from NavigationProxy and updating ShellSection -- StephaneDelcroix requested unit tests → Author added them -- rmarinho confirmed unit tests cover both `useMaui: true` and `useMaui: false` scenarios - -**Reviewer Feedback:** -- Comments about misleading code comments (fixed) -- Concern about duplicate `DisconnectHandlers()` calls (resolved by moving from NavigationProxy to implementations) -- StephaneDelcroix: Approved after unit tests added -- rmarinho: Approved - confirmed tests cover NavigationPage and Shell scenarios - -**Maintainer Approvals:** -- ✅ StephaneDelcroix (Jan 7, 2026) -- ✅ rmarinho (Jan 7, 2026) - -**Disagreements to Investigate:** -| File:Line | Reviewer Says | Author Says | Status | -|-----------|---------------|-------------|--------| -| NavigationPage.cs:914 | Duplicate disconnection with NavigationProxy | Removed from NavigationProxy, now only in NavigationPage | ✅ RESOLVED | -| NavigationPage.Legacy.cs:257 | Same duplicate concern | Same resolution | ✅ RESOLVED | - -**Author Uncertainty:** -- None noted - -
- -
-🧪 Tests - -**Status**: ✅ COMPLETE - -- [x] PR includes unit tests -- [x] Tests follow naming convention -- [x] Unit tests cover both useMaui: true/false paths -- [x] Unit tests cover Shell navigation - -**Test Files (from PR - Unit Tests):** -- `src/Controls/tests/Core.UnitTests/NavigationUnitTest.cs` (+63 lines) - - `RemovePageDisconnectsHandlerForNonVisiblePage` - Tests removing middle page from 3-page stack (both useMaui: true/false) - - `RemovePageDisconnectsHandlerForRemovedRootPage` - Tests removing root page when another page is on top -- `src/Controls/tests/Core.UnitTests/ShellNavigatingTests.cs` (+25 lines) - - `RemovePageDisconnectsHandlerInShell` - Tests Shell navigation scenario - -**Unit Test Coverage Analysis:** -| Code Path | useMaui: true | useMaui: false | Shell | -|-----------|---------------|----------------|-------| -| Remove middle page | ✅ | ✅ | ✅ | -| Remove root page | ✅ | ✅ | - | - -Coverage is adequate - tests cover all modified code paths. - -
- -
-🚦 Gate - Test Verification - -**Status**: ✅ PASSED - -- [x] Tests FAIL without fix (bug reproduced) -- [x] Tests PASS with fix - -**Result:** PASSED ✅ - -**Verification:** Unit tests from PR cover all code paths: -- `RemovePageDisconnectsHandlerForNonVisiblePage(true)` - Maui path (Android/Windows) -- `RemovePageDisconnectsHandlerForNonVisiblePage(false)` - Legacy path (iOS/MacCatalyst) -- `RemovePageDisconnectsHandlerForRemovedRootPage(true/false)` - Root page removal -- `RemovePageDisconnectsHandlerInShell` - Shell navigation - -
- -
-🔧 Fix Candidates - -**Status**: ✅ COMPLETE - -| # | Source | Approach | Test Result | Files Changed | Notes | -|---|--------|----------|-------------|---------------|-------| -| 1 | try-fix | Add DisconnectHandlers() inside SendHandlerUpdateAsync callback | ❌ FAIL | `NavigationPage.cs` (+3) | **Why failed:** Timing issue - SendHandlerUpdateAsync uses FireAndForget(), so the callback with DisconnectHandlers() runs asynchronously. The test checks Handler immediately after RemovePage() returns, before the async callback executes. | -| 2 | try-fix | Add DisconnectHandlers() synchronously after SendHandlerUpdateAsync in MauiNavigationImpl | ❌ FAIL | `NavigationPage.cs` (+3) | **Why failed:** iOS uses `UseMauiHandler = false`, meaning it uses NavigationImpl (Legacy) NOT MauiNavigationImpl. My fix was in the wrong code path - iOS doesn't execute MauiNavigationImpl at all. | -| 3 | try-fix | Add DisconnectHandlers() at end of Legacy RemovePage method | ✅ PASS | `NavigationPage.Legacy.cs` (+3) | Works! iOS/MacCatalyst use Legacy path. Simpler fix - only 1 file needed for iOS. | -| 4 | try-fix | Approach 2+3 combined (both Maui and Legacy paths) | ✅ PASS (iOS) | `NavigationPage.cs`, `NavigationPage.Legacy.cs` (+6 total) | Works for NavigationPage on all platforms, BUT **misses Shell navigation** which has its own code path. | -| PR | PR #32289 | Add `DisconnectHandlers()` call in RemovePage for non-visible pages | ✅ PASS (Gate) | NavigationPage.cs, NavigationPage.Legacy.cs, NavigationProxy.cs, ShellSection.cs | Original PR - validated by Gate | - -**Note:** try-fix candidates (1, 2, 3...) are added during Phase 4. PR's fix is reference only. - -**Exhausted:** No (stopped after finding working alternative) -**Selected Fix:** PR's fix - -**Deep Analysis (Git History Research):** - -**Historical Timeline:** -1. **PR #24887** (Feb 2025): Fixed Android flickering by avoiding handler removal during PopAsync - inadvertently broke RemovePage scenarios -2. **PR #30049** (June 2025): Attempted fix by adding `page?.DisconnectHandlers()` to `NavigationProxy.OnRemovePage()` - **BUT THIS FIX WAS FUNDAMENTALLY FLAWED** -3. **PR #32289** (Current): Correctly fixes by adding DisconnectHandlers to the NavigationPage implementations - -**Why PR #30049's fix didn't work:** -- `MauiNavigationImpl` and `NavigationImpl` **override** `OnRemovePage()` -- The overrides do NOT call `base.OnRemovePage()` -- Therefore `NavigationProxy.OnRemovePage()` is **NEVER executed** for NavigationPage! -- ContentPage works because it doesn't override - uses the base NavigationProxy directly - -**Why calling base.OnRemovePage() won't work:** -- `MauiNavigationImpl.OnRemovePage()` is a **complete replacement** with its own validation, async flow, etc. -- Calling base would cause double removal and ordering issues - -**Conclusion:** The fix MUST be in the NavigationPage implementations themselves, not in NavigationProxy. PR #32289's approach is architecturally correct. - -**Comparison:** -- **My fix #3** works for iOS/MacCatalyst (Legacy path) - 1 file, 3 lines -- **PR's fix** works for ALL platforms (Legacy + Maui paths) - 3 files, ~10 lines -- **PR #30049's approach** ❌ Doesn't work - fix in NavigationProxy is bypassed by overrides - -**Rationale for selecting PR's fix:** -1. PR covers ALL platforms (iOS, MacCatalyst, Android, Windows) while my fix only covers iOS/MacCatalyst -2. PR also fixes ShellSection for Shell navigation scenarios -3. PR uses null-safety (`page?`) which is more defensive -4. PR correctly removes the ineffective DisconnectHandlers from NavigationProxy (cleanup) -5. My successful fix #3 is essentially a subset of the PR's approach - -**Independent validation:** My fix #3 independently arrived at the same solution as the PR for the Legacy path, which validates the PR's approach is correct. - -
- ---- - -## ✅ Final Recommendation: APPROVE - -**Summary:** PR #32289 correctly fixes the handler disconnection issue when removing non-visible pages using `RemovePage()`. - -**Key Findings:** -1. ✅ **Root cause correctly identified** - NavigationPage overrides bypass NavigationProxy, requiring fix in implementations -2. ✅ **All code paths covered** - NavigationPage (Maui + Legacy) and ShellSection -3. ✅ **Unit tests adequate** - Cover both `useMaui: true/false` and Shell navigation -4. ✅ **Two maintainer approvals** - StephaneDelcroix and rmarinho -5. ✅ **Independent validation** - My try-fix #3 independently arrived at same solution for Legacy path - -**Alternative approaches tested:** -- Approach 2+3 (Maui + Legacy paths only) works but misses Shell navigation -- PR's fix is more complete and architecturally correct - -**No concerns identified.** diff --git a/.github/agent-pr-session/pr-33134.md b/.github/agent-pr-session/pr-33134.md deleted file mode 100644 index 19b6325c46de..000000000000 --- a/.github/agent-pr-session/pr-33134.md +++ /dev/null @@ -1,203 +0,0 @@ -# PR Review: #33134 - [Android] EmptyView doesn't display when CollectionView is placed inside a VerticalStackLayout - -**Date:** 2026-01-09 | **Issue:** [#32932](https://github.com/dotnet/maui/issues/32932) | **PR:** [#33134](https://github.com/dotnet/maui/pull/33134) - -## ✅ Status: COMPLETE - -| Phase | Status | -|-------|--------| -| Pre-Flight | ✅ COMPLETE | -| 🧪 Tests | ✅ COMPLETE | -| 🚦 Gate | ✅ COMPLETE | -| 🔧 Fix | ✅ COMPLETE | -| 📋 Report | ✅ COMPLETE | - ---- - -
-📋 Issue Summary - -CollectionView has an EmptyView property for rendering a view when the ItemsSource is empty. This does not work when the CollectionView is placed inside a VerticalStackLayout. - -**Steps to Reproduce:** -1. Place a CollectionView inside a VerticalStackLayout -2. Set an empty ItemsSource -3. Set an EmptyView or EmptyViewTemplate -4. Run on Android - EmptyView does not display - -**Platforms Affected:** -- [ ] iOS -- [x] Android -- [ ] Windows -- [ ] MacCatalyst - -**Reproduction repo:** https://github.com/rrbabbb/EmptyViewNotWorkingRepro - -
- -
-📁 Files Changed - -| File | Type | Changes | -|------|------|---------| -| `src/Controls/src/Core/Handlers/Items/Android/SizedItemContentView.cs` | Fix | +2 lines | -| `src/Controls/tests/TestCases.HostApp/Issues/Issue32932.cs` | Test | New file | -| `src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue32932.cs` | Test | New file | - -
- -
-💬 PR Discussion Summary - -**Key Comments:** -- No significant reviewer feedback at time of review - -**Author:** NanthiniMahalingam (Syncfusion partner) - -**Labels:** platform/android, area-controls-collectionview, community ✨, partner/syncfusion - -
- -
-🧪 Tests - -**Status**: ✅ COMPLETE - -- [x] PR includes UI tests -- [x] Tests reproduce the issue -- [x] Tests follow naming convention (`IssueXXXXX`) - -**Test Files:** -- HostApp: `src/Controls/tests/TestCases.HostApp/Issues/Issue32932.cs` -- NUnit: `src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue32932.cs` - -**Test Description:** Places a CollectionView with an empty ItemsSource inside a VerticalStackLayout and verifies that the EmptyView (a Label with AutomationId="EmptyView") is visible. - -**Verification:** -- Tests compile successfully -- Follow proper naming conventions (Issue32932) -- Use `CollectionView2` handler for Android -- Have correct `[Issue()]` attributes and categories - -
- -
-🚦 Gate - Test Verification - -**Status**: ✅ COMPLETE - -- [x] Tests FAIL without fix (bug reproduced) -- [x] Tests PASS with fix (bug fixed) - -**Result:** VERIFIED ✅ - -**Test Execution Results:** - -| State | Result | Details | -|-------|--------|---------| -| **WITH fix** | ✅ PASS | EmptyView element found immediately (1 Appium query) | -| **WITHOUT fix** | ❌ FAIL (Expected) | EmptyView never appears - 25+ retries before timeout | - -**Verification Method:** -1. Ran test with PR fix applied → PASS -2. Reverted `SizedItemContentView.cs` to pre-fix version (before commit `b498b13ef6`) -3. Ran test without fix → FAIL (TimeoutException: `Timed out waiting for element...`) -4. Restored fix - -**Log Evidence:** -- WITH fix: `method: 'id', selector: 'com.microsoft.maui.uitests:id/EmptyView'` → found immediately -- WITHOUT fix: 25+ consecutive searches for `EmptyView` element, never found, test times out - -
- -
-🔧 Fix Candidates - -**Status**: ✅ COMPLETE - -| # | Source | Approach | Test Result | Files Changed | Notes | -|---|--------|----------|-------------|---------------|-------| -| 1 | try-fix | Fix at source in `EmptyViewAdapter.GetHeight()/GetWidth()` - return `double`, check `IsInfinity()` before casting | ✅ PASS | `EmptyViewAdapter.cs` | Fixes bug at the source but changes method signatures | -| 2 | try-fix | Fix at source in `EmptyViewAdapter` - use `double` throughout + `Math.Abs` + infinity check | ✅ PASS | `EmptyViewAdapter.cs` | Cleaner version of #1 but still changes method signatures | -| 3 | try-fix | **Avoid int.MaxValue entirely:** keep `GetHeight/GetWidth` as `int`, but change the lambdas passed to `SizedItemContentView`/`SimpleViewHolder` to return `double.PositiveInfinity` when `RecyclerViewHeight/Width` is infinite | ✅ PASS | `EmptyViewAdapter.cs` (+4/-3) | Preserves infinity without signature changes and without magic-number checks; not defensive for other callers | -| 4 | try-fix | Check for infinity BEFORE casting in `GetHeight()`/`GetWidth()`, return 0 to trigger fallback | ❌ FAIL | `EmptyViewAdapter.cs` | **Why failed:** Fallback to `parent.MeasuredHeight` returns 0 during initial layout pass - doesn't provide valid dimensions | -| 5 | try-fix | Skip setting `RecyclerViewHeight/Width` when infinite | ❌ FAIL | `ItemsViewHandler.Android.cs` | **Why failed:** Same issue as #4 - fallback mechanism doesn't work when parent not yet measured | -| 6 | try-fix | Add `GetHeightAsDouble()`/`GetWidthAsDouble()` methods that return infinity when appropriate, use in `CreateEmptyViewHolder` lambdas | ✅ PASS | `EmptyViewAdapter.cs` (+18) | Cleaner version of #3 with dedicated helper methods; fixes at source, no heuristic, no signature changes | -| PR | PR #33134 | Add `NormalizeDimension()` helper in `SizedItemContentView` that converts `int.MaxValue` → `double.PositiveInfinity` | ✅ VERIFIED | `SizedItemContentView.cs` | **SELECTED** - Defensive/low-risk downstream patch | - -### Root Cause Analysis - -When a CollectionView is inside a VerticalStackLayout, the height constraint passed to the CollectionView is `double.PositiveInfinity` (unconstrained). This value propagates to `EmptyViewAdapter.RecyclerViewHeight`. - -In `EmptyViewAdapter.GetHeight()`, the code casts this to `int`: `int height = (int)RecyclerViewHeight;` - -In C#, `(int)double.PositiveInfinity` produces `int.MaxValue` (2147483647). This value is then passed via lambda to `SizedItemContentView.OnMeasure()`, where the code checks `double.IsInfinity(targetHeight)` - but `int.MaxValue` is not infinity, so the check fails and layout calculations break. - -### Key Insight from Failed Attempts - -Attempts #4 and #5 revealed that: -- `(int)double.PositiveInfinity` in C# actually produces `int.MaxValue` (verified empirically) -- The fallback to `parent.MeasuredHeight` doesn't work during initial layout when parent hasn't been measured yet -- The solution must **preserve infinity semantics** rather than trying to fall back to measured values - -### Comparison of Passing Fixes - -| Criteria | PR's Fix | Source-Level (#3/#6) | -|----------|----------|---------------------| -| **Correctness** | ✅ Handles `int.MaxValue` → `∞` | ✅ Preserves `∞` directly | -| **Defensive** | ✅ Protects ALL callers of `SizedItemContentView` | ❌ Only fixes EmptyView path | -| **Risk** | ✅ Minimal - 1 line addition | ⚠️ Adds new methods to adapter | -| **Heuristic concern** | ⚠️ Assumes `int.MaxValue` means infinity | ✅ No heuristic | - -**Why PR's heuristic is valid:** `(int)double.PositiveInfinity` produces `int.MaxValue` in C#, so the heuristic is actually semantically correct - `int.MaxValue` really does mean "this was infinity before the cast." - -**Exhausted:** Yes (6 try-fix attempts completed) -**Selected Fix:** PR #33134's fix (NormalizeDimension helper in SizedItemContentView) - -**Rationale:** The PR's fix is the best choice because: -1. **Defensive:** Protects ALL callers of `SizedItemContentView.OnMeasure()`, not just EmptyView -2. **Low-risk:** Minimal code change (1 helper method, 2 call sites) -3. **Semantically correct:** The heuristic `int.MaxValue → PositiveInfinity` is valid because `(int)double.PositiveInfinity` produces `int.MaxValue` in C# -4. **No signature changes:** Doesn't require changing method signatures in `EmptyViewAdapter` - -
- ---- - -## 📋 Final Report - -### Recommendation: ✅ APPROVE - -**Summary:** PR #33134 correctly fixes the Android EmptyView visibility bug when CollectionView is inside a VerticalStackLayout. - -### Technical Analysis - -**Root Cause:** -- CollectionView inside VerticalStackLayout receives `double.PositiveInfinity` as height constraint -- `EmptyViewAdapter.GetHeight()` casts this to `int`: `(int)double.PositiveInfinity` → `int.MaxValue` -- `SizedItemContentView.OnMeasure()` checks `double.IsInfinity(targetHeight)` but `int.MaxValue` is not infinity -- Layout calculations fail, EmptyView doesn't display - -**Fix:** -- Adds `NormalizeDimension()` helper that converts `int.MaxValue` back to `double.PositiveInfinity` -- Applied at the point of use in `OnMeasure()`, making it defensive for all callers - -### Quality Assessment - -| Aspect | Rating | Notes | -|--------|--------|-------| -| **Correctness** | ✅ Excellent | Fix addresses root cause, tests verify behavior | -| **Test Coverage** | ✅ Good | UI test properly reproduces the bug | -| **Risk** | ✅ Low | Minimal, surgical change | -| **Code Quality** | ✅ Good | Clean helper method, proper naming | -| **Alternative Comparison** | ✅ Done | 6 alternatives explored, PR's approach is best | - -### Test Verification - -- ✅ Test PASSES with fix (EmptyView visible) -- ✅ Test FAILS without fix (TimeoutException - EmptyView never appears) - -### Minor Suggestions (Non-blocking) - -1. **Consider XML doc comment** for `NormalizeDimension()` explaining why the conversion is needed -2. **Consider adding regression test for Grid container** as reported in issue comments diff --git a/.github/agent-pr-session/pr-33380.md b/.github/agent-pr-session/pr-33380.md deleted file mode 100644 index 54656e698bae..000000000000 --- a/.github/agent-pr-session/pr-33380.md +++ /dev/null @@ -1,226 +0,0 @@ -# PR Review: #33380 - [PR agent] Issue23892.ShellBackButtonShouldWorkOnLongPress - test fix - -**Date:** 2026-01-07 | **Issue:** [#33379](https://github.com/dotnet/maui/issues/33379) | **PR:** [#33380](https://github.com/dotnet/maui/pull/33380) - -## ✅ Final Recommendation: APPROVE - -| Phase | Status | -|-------|--------| -| Pre-Flight | ✅ COMPLETE | -| 🧪 Tests | ✅ COMPLETE | -| 🚦 Gate | ✅ PASSED | -| 🔧 Fix | ✅ COMPLETE | -| 📋 Report | ✅ COMPLETE | - ---- - -
-📋 Issue Summary - -**Issue #33379**: The UI test `Issue23892.ShellBackButtonShouldWorkOnLongPress` started failing after PR #32456 was merged. - -**Test Expectation**: `OnAppearing count: 2` -**Test Actual**: `OnAppearing count: 1` - -**Original Issue #23892**: Using long-press navigation on the iOS back button in Shell does not update `Shell.Current.CurrentPage`. The `Navigated` and `Navigating` events don't fire. - -**Platforms Affected:** -- [x] iOS -- [ ] Android -- [ ] Windows -- [ ] MacCatalyst - -
- -
-🔍 Deep Regression Analysis - Full Timeline - -## The Regression Chain - -This PR addresses a **double regression** - the same functionality was broken twice by subsequent PRs. - -### Timeline of Changes to `ShellSectionRenderer.cs` - -| Date | PR | Purpose | Key Change | Broke Long-Press? | -|------|-----|---------|------------|-------------------| -| Feb 2025 | #24003 | Fix #23892 (long-press back) | Added `_popRequested` flag + `DidPopItem` | ✅ Fixed it | -| Jul 2025 | #29825 | Fix #29798/#30280 (tab blank issue) | **Removed** `_popRequested`, expanded `DidPopItem` with manual sync | ❌ **Broke it** | -| Jan 2026 | #32456 | Fix #32425 (navigation hang) | Added null checks, changed `ElementForViewController` | ❌ Maintained broken state | - -### PR #24003 - The Original Fix (Feb 2025) - -**Problem solved**: Long-press back button didn't trigger navigation events. - -**Solution**: Added `_popRequested` flag to distinguish: -- **User-initiated navigation** (long-press): Call `SendPop()` → triggers `GoToAsync("..")` → fires `OnAppearing` -- **Programmatic navigation** (code): Skip `SendPop()` to avoid double-navigation - -**Key code added**: -```csharp -bool _popRequested; - -bool DidPopItem(UINavigationBar _, UINavigationItem __) - => _popRequested || SendPop(); // If not requested, call SendPop -``` - -### PR #29825 - The First Regression (Jul 2025) - -**Problem solved**: Tab becomes blank after specific navigation pattern (pop via tab tap, then navigate again, then back). - -**What went wrong**: The PR author expanded `DidPopItem` with manual stack synchronization logic (`_shellSection.SyncStackDownTo()`) and **removed the `_popRequested` flag entirely**. - -**Result**: `DidPopItem` now ALWAYS does manual sync, never calls `SendPop()` for user-initiated navigation. Long-press navigation stopped triggering `OnAppearing`. - -**Why the test didn't catch it**: Unclear - possibly the test wasn't run or was flaky at the time. - -### PR #32456 - Maintained the Broken State (Jan 2026) - -**Problem solved**: Navigation hangs after rapidly opening/closing pages (iOS 26 specific). - -**What it did**: Added null checks to prevent crashes in `DidPopItem` and changed `ElementForViewController` pattern matching. - -**Maintained the regression**: The PR kept the broken `DidPopItem` logic from #29825 (no `_popRequested` flag). - -**This triggered the test failure**: When #32456 merged to `inflight/candidate`, the existing `Issue23892` test started failing. - -
- -
-📁 Files Changed - -| File | Type | Changes | -|------|------|---------| -| `src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellSectionRenderer.cs` | Fix | -20 lines (simplified) | -| `src/Controls/src/Core/Shell/ShellSection.cs` | Fix | -44 lines (removed `SyncStackDownTo`) | - -**Net change:** -49 lines (code reduction) - -
- -
-💬 PR Discussion Summary - -**Key Comments:** -- Issue #33379 was filed by @sheiksyedm pointing to the test failure after #32456 merged -- @kubaflo (author of both #32456 and #33380) created this fix - -**Reviewer Feedback:** -- None yet - -**Disagreements to Investigate:** -| File:Line | Reviewer Says | Author Says | Status | -|-----------|---------------|-------------|--------| -| (none) | | | | - -**Author Uncertainty:** -- None expressed - -
- -
-🧪 Tests - -**Status**: ✅ COMPLETE - -- [x] PR includes UI tests (existing test from #24003) -- [x] Tests reproduce the issue -- [x] Tests follow naming convention (`IssueXXXXX`) ✅ - -**Test Files:** -- HostApp: `src/Controls/tests/TestCases.HostApp/Issues/Issue23892.cs` -- NUnit: `src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue23892.cs` - -
- -
-🚦 Gate - Test Verification - -**Status**: ✅ PASSED - -- [x] Tests PASS with fix - -**Test Run:** -``` -Platform: iOS -Test Filter: FullyQualifiedName~Issue23892 -Result: SUCCESS ✅ -``` - -**Result:** PASSED ✅ - The `Issue23892.ShellBackButtonShouldWorkOnLongPress` test now passes with the PR's fix. - -
- -
-🔧 Fix Candidates - -**Status**: ✅ COMPLETE - -| # | Source | Approach | Test Result | Files Changed | Notes | -|---|--------|----------|-------------|---------------|-------| -| 1 | try-fix | Simplified `DidPopItem`: Always call `SendPop()` when stacks are out of sync | ✅ PASS (Issue23892 + Issue29798 + Issue21119) | `ShellSectionRenderer.cs` (-17, +6) | **Simpler AND works!** | -| PR | PR #33380 (original) | Restore `_popRequested` flag + preserve manual sync from #29825/#32456 | ✅ PASS (Gate) | `ShellSectionRenderer.cs` (+11) | Superseded by update | -| PR | PR #33380 (updated) | **Adopted try-fix #1** - Stack sync detection, removed `SyncStackDownTo` | ✅ PASS (CI pending) | `ShellSectionRenderer.cs`, `ShellSection.cs` (-49 net) | **CURRENT - matches recommendation** | - -**Update (2026-01-08):** Developer @kubaflo adopted the simpler approach recommended in try-fix #1. - -**Exhausted:** Yes -**Selected Fix:** PR #33380 (updated) - Now implements the recommended simpler approach - -
- ---- - -## 📋 Final Report - -### Recommendation: ✅ APPROVE - -**Update (2026-01-08):** Developer @kubaflo has adopted the recommended simpler approach. - -### Changes Made by Developer - -The PR now implements exactly the simplified stack-sync detection approach: - -**ShellSectionRenderer.cs** - Simplified `DidPopItem`: -```csharp -bool DidPopItem(UINavigationBar _, UINavigationItem __) -{ - if (_shellSection?.Stack is null || NavigationBar?.Items is null) - return true; - - // If stacks are in sync, nothing to do - if (_shellSection.Stack.Count == NavigationBar.Items.Length) - return true; - - // Stacks out of sync = user-initiated navigation - return SendPop(); -} -``` - -**ShellSection.cs** - Removed `SyncStackDownTo` method (44 lines deleted) - -### Why This Approach Works - -| Scenario | What Happens | -|----------|--------------| -| **Tab tap pop** | Shell updates stack BEFORE `DidPopItem` → stacks ARE in sync → returns early (no `SendPop()`) | -| **Long-press back** | iOS pops directly → Shell stack NOT updated → stacks out of sync → calls `SendPop()` | - -### Benefits of Updated PR - -| Aspect | Before (Original PR) | After (Updated PR) | -|--------|---------------------|-------------------| -| Lines changed | +11 | **-49 net** | -| New fields | `_popRequested` bool | **None (stateless)** | -| Complexity | State tracking | **Simple sync check** | -| `SyncStackDownTo` | Preserved | **Removed** | - -### Conclusion - -The PR now: -- ✅ Fixes Issue #33379 (long-press back navigation) -- ✅ Uses the simpler stateless approach -- ✅ Removes 49 lines of code -- ✅ No new state tracking required -- ⏳ Pending CI verification - -**Approve once CI passes.**u diff --git a/.github/agent-pr-session/pr-33392.md b/.github/agent-pr-session/pr-33392.md deleted file mode 100644 index 29add538c7e7..000000000000 --- a/.github/agent-pr-session/pr-33392.md +++ /dev/null @@ -1,346 +0,0 @@ -# PR Review: #33392 - [iOS] Fixed the UIStepper Value from being clamped based on old higher MinimumValue - -**Date:** 2026-01-06 | **Issue:** N/A (Test failure fix) | **PR:** [#33392](https://github.com/dotnet/maui/pull/33392) - -## ✅ Final Recommendation: APPROVE - -| Phase | Status | -|-------|--------| -| Pre-Flight | ✅ COMPLETE | -| 🧪 Tests | ✅ COMPLETE | -| 🚦 Gate | ✅ PASSED | -| 🔍 Analysis | ✅ COMPLETE | -| ⚖️ Compare | ✅ COMPLETE | -| 🔬 Regression | ✅ COMPLETE | -| 📋 Report | ✅ COMPLETE | - ---- - -
-📋 Issue Summary - -**Problem:** Stepper Device Tests failing on iOS in candidate PR #33363 - -**Root Cause (from PR description):** -- `Stepper_SetIncrementAndVerifyValueChange` and `Stepper_SetIncrementValue_VerifyIncrement` tests failed -- Previous test (`Stepper_ResetToInitialState_VerifyDefaultValues`) updated Minimum to 10 -- When next test runs, new ViewModel sets defaults (Value=0, Minimum=0) -- `MapValue` is called first, but Minimum still has stale value of 10 -- Native UIStepper clamps Value based on old Minimum, causing test failure - -**Regressed by:** PR #32939 - -**Example Scenario:** -- Old state: Min=5, Value=5 -- New state: Min=0, Value=2 -- Without fix: Value set to 2, iOS sees Min=5 (stale), clamps to 5 -- With fix: Min updated to 0 first, then Value set to 2 successfully - -**Platforms Affected:** -- [x] iOS -- [ ] Android (tested, not affected) -- [ ] Windows (tested, not affected) -- [ ] MacCatalyst (tested, not affected) - -
- -
-🔗 Regression Context - PR #32939 - -**Title:** [C] Fix Slider and Stepper property order independence - -**Author:** @StephaneDelcroix - -**Purpose:** Ensure `Value` property is correctly preserved regardless of the order in which `Minimum`, `Maximum`, and `Value` are set (programmatically or via XAML bindings). - -**Original Problem (that #32939 fixed):** -- When using XAML data binding, property application order depends on attribute order and binding timing -- Previous implementation clamped `Value` immediately when `Min`/`Max` changed, using current (potentially default) range -- Example: `Value=50` with `Min=10, Max=100` would get clamped to `1` (default max) if `Value` was set before `Maximum` -- User's intended value was lost - -**Solution in #32939:** -- Introduced three private fields: - - `_requestedValue`: stores user's intended value before clamping - - `_userSetValue`: tracks if user explicitly set `Value` (vs automatic recoercion) - - `_isRecoercing`: prevents `_requestedValue` corruption during recoercion -- When `Min`/`Max` changes: restore `_requestedValue` (clamped to new range) if user explicitly set it -- Changed from `coerceValue` callback to `propertyChanged` callback for Min/Max - -**Issues Fixed by #32939:** -1. **#32903** - Slider Binding Initialization Order Causes Incorrect Value Assignment in XAML -2. **#14472** - Slider is very broken, Value is a mess when setting Minimum -3. **#18910** - Slider is buggy depending on order of properties -4. **#12243** - Stepper Value is incorrectly clamped to default min/max when using bindableproperties in MVVM pattern - -**Files Changed in #32939:** -- `src/Controls/src/Core/Slider/Slider.cs` (+43, -12) -- `src/Controls/src/Core/Stepper/Stepper.cs` (+33, -6) -- `src/Controls/tests/Core.UnitTests/SliderUnitTests.cs` (+166) -- `src/Controls/tests/Core.UnitTests/StepperUnitTests.cs` (+165) - -**Behavioral Change Warning (from #32939):** -> The order of `PropertyChanged` events for `Stepper` may change in edge cases where `Minimum`/`Maximum` changes trigger a `Value` change. Previously, `Value` changed before `Min`/`Max`; now it changes after. - -
- -
-📖 Scenarios from Fixed Issues - -**Scenario 1 (Issue #32903):** XAML Binding Order -```xaml - -``` -ViewModel: `Min=10, Max=100, Value=50` -- Before #32939: Value evaluated before Maximum → clamped to 10 (wrong) -- After #32939: Value "springs back" to 50 when range includes it (correct) - -**Scenario 2 (Issue #14472):** Value Before Minimum -```xaml - -``` -- Before #32939: Shows zero minimum and zero value (wrong) -- After #32939: Correctly shows 75 (correct) - -**Scenario 3 (Issue #12243):** Stepper MVVM Binding -```csharp -Min = 1; Max = 105; Value = 102; -``` -- Before #32939: Value clamped to 100 (default max) (wrong) -- After #32939: Value correctly shows 102 (correct) - -
- -
-📁 Files Changed - -| File | Type | Changes | -|------|------|---------| -| `src/Core/src/Platform/iOS/StepperExtensions.cs` | Fix | +10 lines | - -**No test files included in PR.** - -
- -
-🔍 The Disconnect: MAUI Layer vs Platform Layer - -**Key Insight:** PR #32939 fixed the **MAUI layer** (Stepper.cs) but created a problem in the **iOS platform layer** (StepperExtensions.cs). - -**How #32939 Changed Mapper Call Order:** - -Before #32939: -- `coerceValue` on Minimum → immediately clamps Value → MapValue called → MapMinimum called -- Order: Value updated BEFORE Min/Max - -After #32939: -- `propertyChanged` on Minimum → calls RecoerceValue() → MapMinimum called → MapValue called -- Order: Min/Max updated BEFORE Value (at MAUI layer) -- But iOS platform layer still updates Value BEFORE checking if Min needs update - -**The Gap:** -- MAUI `Stepper.cs` now correctly sequences property changes -- But `StepperHandler.MapValue()` doesn't know about the pending Min/Max changes -- When `MapValue` runs, the native `UIStepper.MinimumValue` still has the OLD value -- iOS native UIStepper clamps to OLD range → wrong value displayed - -**PR #33392's Fix:** -- In `UpdateValue()` (platform layer), check if `MinimumValue` needs updating FIRST -- Update it before setting `Value` on the native control -- This syncs the platform layer with MAUI's new property change sequence - -
- -
-💬 PR Discussion Summary - -**Key Comments:** -- No PR comments or review feedback yet - -**Reviewer Feedback:** -- None yet - -**Disagreements to Investigate:** -- None identified - -**Author Uncertainty:** -- None expressed - -
- -
-🧪 Tests - -**Status**: ✅ COMPLETE - -- [x] PR includes UI tests → **NO** (this fixes existing UI Tests) -- [x] Existing UI Tests cover this scenario → **YES** (StepperFeatureTests.cs) -- [x] Tests follow naming convention → N/A - -**Test Files:** -- Existing: `src/Controls/tests/TestCases.Shared.Tests/Tests/FeatureMatrix/StepperFeatureTests.cs` -- Failing tests: `Stepper_SetIncrementAndVerifyValueChange`, `Stepper_SetIncrementValue_VerifyIncrement` - -**Note:** This PR fixes UI test failures caused by inter-test state leakage. The `StepperFeatureTests` class does NOT reset between tests (`ResetAfterEachTest` not overridden), so when one test sets Minimum=10, subsequent tests inherit that stale native state. - -
- -
-🚦 Gate - Test Verification - -**Status**: ✅ PASSED - -- [x] Existing Stepper UI Tests pass with fix (per PR author verification) -- [x] Tests were failing before this fix on candidate PR #33363 -- [x] Root cause confirmed: mapper call order + native UIStepper clamping - -**Result:** PASSED ✅ - -**Verification approach:** CI pipeline ran StepperFeatureTests on iOS, tests that previously failed now pass. - -
- ---- - -## 🔍 Phase 4: Analysis - COMPLETE - -### Root Cause - -**Layer mismatch after PR #32939:** - -1. PR #32939 changed `Stepper.cs` from `coerceValue` to `propertyChanged` for Min/Max -2. This changed the timing of when Value gets recoerced relative to Min/Max mapper calls -3. iOS native `UIStepper` auto-clamps `Value` to `[MinimumValue, MaximumValue]` when set -4. When `MapValue` runs before `MapMinimum`/`MapMaximum`, native has stale range → wrong clamping - -**Platform comparison:** -| Platform | Native Control | Auto-Clamps on Value Set? | Issue? | -|----------|----------------|---------------------------|--------| -| iOS | UIStepper | ✅ Yes | **YES - needs fix** | -| Windows | MauiStepper | ❌ No (manual clamp on button click) | No | -| Android | MauiStepper (LinearLayout) | ❌ No (buttons only) | No | - -### PR #33392's Approach - -**Correct concept:** Sync Min/Max before setting Value in platform layer. - -**Implementation gap:** Only syncs `MinimumValue`, but same issue exists for `MaximumValue`. - -### Missing Maximum Sync Scenario - INVESTIGATED AND DISMISSED - -Initially hypothesized that Maximum would have the same issue: - -``` -Test A: Sets Maximum=5 → native UIStepper.MaximumValue=5 -Test B: New ViewModel with Maximum=10, Value=8 -MapValue runs before MapMaximum: -- Would Value=8 get clamped to 5? -``` - -**After investigation: This scenario CANNOT occur with default ViewModel values.** - -The ViewModel defaults to `Value=0`. Since 0 is NEVER above any Maximum, the stale Maximum clamping can never trigger. The bug is mathematically asymmetric: - -| Scenario | Default Value=0 | Stale Native Value | Clamp Result | -|----------|-----------------|-------------------|--------------| -| Minimum bug | 0 < stale Min=10 | Min=10 | ❌ Clamped UP to 10 | -| Maximum bug | 0 < stale Max=5 | Max=5 | ✅ No clamp (0 is valid) | - -**Conclusion:** Maximum sync is NOT needed because the default Value=0 can never exceed any Maximum. - -### Slider Also Potentially Affected (Future Consideration) - -`SliderExtensions.UpdateValue()` on iOS doesn't have similar Min sync. However, the same asymmetry applies - default Value=0 cannot trigger a Maximum clamp bug. A Minimum sync might be needed for Slider if similar test patterns emerge. - ---- - -## ⚖️ Phase 5: Compare - COMPLETE - -| Aspect | PR's Fix | Notes | -|--------|----------|-------| -| Syncs Minimum | ✅ Yes | Required - fixes the bug | -| Syncs Maximum | ❌ No | Not needed - see analysis | -| Fixes failing tests | ✅ Yes | Verified | -| Risk of regression | Low | Small, targeted change | - -**Conclusion:** PR is complete as-is. Maximum sync is unnecessary because the bug is mathematically asymmetric (default Value=0 can never exceed any Maximum). - ---- - -## 🔬 Phase 6: Regression - COMPLETE - -### Will fix break #32939 scenarios? - -**Analyzed scenario:** XAML binding order independence - -```xaml - -``` -ViewModel: Min=10, Max=100, Value=50 - -**With PR #33392's fix:** -1. Bindings update in unpredictable order -2. If MapValue runs first: UpdateValue syncs Min→10, then sets Value→50 -3. Value correctly within [10, 100] ✅ -4. MapMaximum later sets Max→100 (already correct at platform) ✅ - -**No regression.** The fix ensures platform state is correct regardless of mapper call order. - -### Double-update concern - -`MinimumValue` may be set twice: once in `UpdateValue`, once in `MapMinimum`. - -**Mitigated by:** Guard condition `if (platformStepper.MinimumValue != stepper.Minimum)` - -**Acceptable:** Setting the same value twice is a no-op for UIStepper. - -### Edge cases verified - -| Edge Case | Result | -|-----------|--------| -| Min > current Value | MAUI clamps first, platform syncs correctly | -| Max < current Value | MAUI clamps first, platform syncs correctly (IF Max sync added) | -| Rapid property changes | Each mapper call syncs current state | -| ViewModel replacement | New values propagate correctly | - ---- - -## 📋 Phase 7: Report - -### Final Recommendation: ✅ APPROVE - -**The PR correctly fixes the Minimum clamping issue. The Maximum sync is NOT needed due to a fundamental asymmetry.** - -### Deep Analysis: Why Maximum Sync Is Unnecessary - -I wrote multiple tests attempting to reproduce a Maximum clamping bug, but they all passed. Here's why: - -**Minimum Bug (exists, PR fixes):** -- Stale native Min = 10 (HIGH) -- New ViewModel Value = 0 (LOW, the default) -- iOS clamps: `Value = max(Value, Min) = max(0, 10) = 10` ❌ WRONG! -- Bug triggers because **Value=0 < stale Min=10** - -**Maximum Bug (does NOT exist):** -- Stale native Max = 5 (LOW) -- New ViewModel Value = 0 (LOW, the default) -- iOS clamps: `Value = min(Value, Max) = min(0, 5) = 0` ✅ CORRECT! -- Bug CANNOT trigger because **Value=0 < stale Max=5** (always valid) - -**The key asymmetry:** When creating a new ViewModel, Value defaults to 0. -- Value=0 can be BELOW a high Minimum (triggering clamp UP) ✅ Bug possible -- Value=0 is NEVER ABOVE any Maximum (no clamp DOWN needed) ✅ No bug - -### Test Verification - -I wrote a UI test `Stepper_ValueNotClampedByStaleMaximum` to attempt to reproduce a Maximum clamping bug. The test passed, confirming the Maximum bug cannot occur. The test was subsequently removed as it was only for investigative purposes. - -### Justification - -1. ✅ **Correct root cause analysis** - PR correctly identifies mapper call order issue for Minimum -2. ✅ **Correct fix approach** - Syncing Min before Value prevents native clamping bug -3. ✅ **Fixes the failing tests** - Immediate problem solved -4. ✅ **Low risk** - Small, targeted change -5. ✅ **Maximum sync NOT needed** - Mathematically impossible to trigger with default Value=0 diff --git a/.github/agent-pr-session/pr-33406.md b/.github/agent-pr-session/pr-33406.md deleted file mode 100644 index e79aca2901f5..000000000000 --- a/.github/agent-pr-session/pr-33406.md +++ /dev/null @@ -1,247 +0,0 @@ -# PR Review: #33406 - [iOS] Fixed Shell navigation on search handler suggestion selection - -**Date:** 2026-01-08 | **Issue:** [#33356](https://github.com/dotnet/maui/issues/33356) | **PR:** [#33406](https://github.com/dotnet/maui/pull/33406) - -**Related Prior Attempt:** [PR #33396](https://github.com/dotnet/maui/pull/33396) (closed - Copilot CLI attempt) - -## ⏳ Status: IN PROGRESS - -| Phase | Status | -|-------|--------| -| Pre-Flight | ✅ COMPLETE | -| 🧪 Tests | ⏳ PENDING | -| 🚦 Gate | ⏳ PENDING | -| 🔧 Fix | ⏳ PENDING | -| 📋 Report | ⏳ PENDING | - ---- - -
-📋 Issue Summary - -**Issue #33356**: [iOS] Clicking on search suggestions fails to navigate to detail page correctly - -**Bug Description**: Clicking on a search suggestion using NavigationBar/SearchBar/custom SearchHandler does not navigate to the detail page correctly on iOS 26.1 & 26.2 with MAUI 10. - -**Root Cause (from PR #33406)**: Navigation fails because `UISearchController` was dismissed (`Active = false`) BEFORE `ItemSelected` was called. This triggers a UIKit transition that deactivates the Shell navigation context and prevents the navigation from completing. - -**Reproduction App**: https://github.com/dotnet/maui-samples/tree/main/10.0/Fundamentals/Shell/Xaminals - -**Steps to Reproduce:** -1. Open the Xaminals sample app -2. Deploy to iPhone 17 Pro 26.2 simulator (Xcode 26.2) -3. Put focus on the search box -4. Type 'b' (note: search dropdown position is wrong - see Issue #32930) -5. Click on 'Bengal' in search suggestions -6. **Issue 1:** No navigation happens (expected: navigate to Bengal cat detail page) -7. Click on 'Bengal' from the main list - this works correctly -8. Click back button -9. **Issue 2:** Navigates to an empty page (expected: navigate back to list) -10. Click back button again - actually navigates back - -**Platforms Affected:** -- [ ] Android -- [x] iOS (26.1 & 26.2) -- [ ] Windows -- [ ] MacCatalyst - -**Regression Info:** -- **Confirmed regression** starting in version 9.0.90 -- Labels: `t/bug`, `platform/ios`, `s/verified`, `s/triaged`, `i/regression`, `shell-search-handler`, `regressed-in-9.0.90` -- Issue 2 (empty page on back navigation) specifically reproducible from 9.0.90 - -**Validated by:** TamilarasanSF4853 (Syncfusion partner) - Confirmed reproducible in VS Code 1.107.1 with MAUI versions 9.0.0, 9.0.82, 9.0.90, 9.0.120, 10.0.0, and 10.0.20 on iOS. - -
- -
-📁 Files Changed - PR #33406 (Community PR) - -| File | Type | Changes | -|------|------|---------| -| `src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellPageRendererTracker.cs` | Fix | 2 lines (swap order) | -| `src/Controls/tests/TestCases.HostApp/Issues/Issue33356.cs` | Test (HostApp) | +261 lines | -| `src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue33356.cs` | Test (NUnit) | +46 lines | - -**PR #33406 Fix** (simpler approach - just swap order): -```diff - void OnSearchItemSelected(object? sender, object e) - { - if (_searchController is null) - return; - -- _searchController.Active = false; - (SearchHandler as ISearchHandlerController)?.ItemSelected(e); -+ _searchController.Active = false; - } -``` - -
- -
-📁 Files Changed - PR #33396 (Prior Copilot Attempt - CLOSED) - -| File | Type | Changes | -|------|------|---------| -| `.github/agent-pr-session/pr-33396.md` | Session | +210 lines | -| `src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellPageRendererTracker.cs` | Fix | +17 lines | -| `src/Controls/tests/TestCases.HostApp/Issues/Issue33356.xaml` | Test (XAML) | +41 lines | -| `src/Controls/tests/TestCases.HostApp/Issues/Issue33356.xaml.cs` | Test (C#) | +138 lines | -| `src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue33356.cs` | Test (NUnit) | +70 lines | - -**PR #33396 Fix** (more defensive approach with BeginInvokeOnMainThread): -```diff - void OnSearchItemSelected(object? sender, object e) - { - if (_searchController is null) - return; - -+ // Store the search controller reference before any state changes -+ var searchController = _searchController; -+ -+ // Call ItemSelected first to trigger navigation before dismissing the search UI. -+ // On iOS 26+, setting Active = false before navigation can cause the navigation -+ // to be lost due to the search controller dismissal animation. - (SearchHandler as ISearchHandlerController)?.ItemSelected(e); -- _searchController.Active = false; -+ -+ // Deactivate the search controller after navigation has been initiated. -+ // Using BeginInvokeOnMainThread ensures this happens after the current run loop, -+ // allowing the navigation to proceed without interference from the dismissal animation. -+ ViewController?.BeginInvokeOnMainThread(() => -+ { -+ if (searchController is not null) -+ { -+ searchController.Active = false; -+ } -+ }); - } -``` - -
- -
-💬 Discussion Summary - -**Key Comments from Issue #33356:** -- TamilarasanSF4853 (Syncfusion): Validated issue across multiple MAUI versions (9.0.0 through 10.0.20) -- Issue 2 (empty page on back) specifically regressed in 9.0.90 -- Issue 1 (no navigation on search suggestion tap) affects all tested versions on iOS - -**PR #33406 Review Comments:** -- Copilot PR reviewer caught typo: "searchHander" should be "searchHandler" (5 duplicate comments, all resolved/outdated now) -- Prior agent review by kubaflo marked it as ✅ APPROVE with comprehensive analysis -- PureWeen requested `/rebase` (latest comment) - -**PR #33396 Review Comments:** -- PureWeen asked to update state file to match PR number -- Copilot had firewall issues accessing GitHub API - -**Disagreements to Investigate:** -| File:Line | Reviewer Says | Author Says | Status | -|-----------|---------------|-------------|--------| -| N/A | N/A | N/A | No active disagreements | - -**Author Uncertainty:** -- None noted in either PR - -
- -
-⚖️ Comparison: PR #33406 vs PR #33396 - -### Fix Approach Comparison - -| Aspect | PR #33406 (Community) | PR #33396 (Copilot) | -|--------|----------------------|---------------------| -| **Author** | SubhikshaSf4851 (Syncfusion) | Copilot | -| **Status** | Open | Closed (draft) | -| **Lines Changed** | 2 (swap order) | 17 (more defensive) | -| **Fix Strategy** | Simply swap order of operations | Swap order + dispatch to next run loop | -| **Test Style** | Code-only (no XAML) | XAML + code-behind | -| **Test Count** | 1 test method | 2 test methods | - -### Which Fix is Better? - -**PR #33406 (simpler approach):** -- ✅ Minimal change - just swaps two lines -- ✅ Addresses root cause: ItemSelected called while navigation context is valid -- ⚠️ Dismissal happens synchronously after ItemSelected -- ⚠️ Could theoretically still interfere if dismissal animation is fast - -**PR #33396 (defensive approach):** -- ✅ Uses BeginInvokeOnMainThread for explicit async deactivation -- ✅ Stores reference to search controller before state changes -- ✅ More detailed comments explaining the fix -- ⚠️ More code complexity -- ⚠️ Was closed/abandoned - -### Recommendation - -Both approaches should work. PR #33406 is simpler and has been reviewed/approved. The extra defensive measures in PR #33396 (BeginInvokeOnMainThread) may provide additional safety margin but add complexity. - -**Prior agent review on PR #33406** already verified: -- Tests FAIL without fix (bug reproduced - timeout) -- Tests PASS with fix (navigation successful) - -
- -
-🧪 Tests - -**Status**: ⏳ PENDING (need to verify tests compile and reproduce issue) - -**PR #33406 Tests:** -- HostApp: `src/Controls/tests/TestCases.HostApp/Issues/Issue33356.cs` (code-only, no XAML) -- NUnit: `src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue33356.cs` -- 1 test: `Issue33356NavigateShouldOccur` - Tests search handler navigation AND back navigation + collection view navigation - -**PR #33396 Tests (for reference):** -- HostApp XAML: `src/Controls/tests/TestCases.HostApp/Issues/Issue33356.xaml` -- HostApp Code: `src/Controls/tests/TestCases.HostApp/Issues/Issue33356.xaml.cs` -- NUnit: `src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue33356.cs` -- 2 tests: `SearchSuggestionTapNavigatesToDetailPage`, `BackNavigationFromDetailPageWorks` - -**Test Checklist:** -- [ ] PR includes UI tests -- [ ] Tests reproduce the issue -- [ ] Tests follow naming convention (`Issue33356`) - -
- -
-🚦 Gate - Test Verification - -**Status**: ⏳ PENDING - -- [ ] Tests FAIL without fix (bug reproduced) -- [ ] Tests PASS with fix (fix validated) - -**Prior Agent Review Result (kubaflo on PR #33406):** -``` -WITHOUT FIX: FAILED - System.TimeoutException: Timed out waiting for element "Issue33356CatNameLabel" -WITH FIX: PASSED - All 1 tests passed in 21.73 seconds -``` - -**Result:** [PENDING - needs re-verification] - -
- -
-🔧 Fix Candidates - -**Status**: ⏳ PENDING - -| # | Source | Approach | Test Result | Files Changed | Notes | -|---|--------|----------|-------------|---------------|-------| -| PR | PR #33406 | Swap order: ItemSelected before Active=false | ⏳ PENDING (Gate) | `ShellPageRendererTracker.cs` (2 lines) | Current PR - simpler fix | -| Alt | PR #33396 | Swap order + BeginInvokeOnMainThread | ✅ VERIFIED (prior test) | `ShellPageRendererTracker.cs` (17 lines) | Prior attempt - more defensive | - -**Exhausted:** No -**Selected Fix:** [PENDING] - -
- ---- - -**Next Step:** Verify PR #33406 tests compile and Gate passes. Read `.github/agents/pr/post-gate.md` after Gate passes. diff --git a/.github/agents/agentic-workflows.agent.md b/.github/agents/agentic-workflows.agent.md new file mode 100644 index 000000000000..58047761f65d --- /dev/null +++ b/.github/agents/agentic-workflows.agent.md @@ -0,0 +1,197 @@ +--- +name: agentic-workflows +description: GitHub Agentic Workflows (gh-aw) - Create, debug, and upgrade AI-powered workflows with intelligent prompt routing +disable-model-invocation: true +--- + +# GitHub Agentic Workflows Agent + +This agent helps you work with **GitHub Agentic Workflows (gh-aw)**, a CLI extension for creating AI-powered workflows in natural language using markdown files. + +## What This Agent Does + +This is a **dispatcher agent** that routes your request to the appropriate specialized prompt based on your task: + +- **Creating new workflows**: Routes to `create` prompt +- **Updating existing workflows**: Routes to `update` prompt +- **Debugging workflows**: Routes to `debug` prompt +- **Upgrading workflows**: Routes to `upgrade-agentic-workflows` prompt +- **Creating report-generating workflows**: Routes to `report` prompt — consult this whenever the workflow posts status updates, audits, analyses, or any structured output as issues, discussions, or comments +- **Creating shared components**: Routes to `create-shared-agentic-workflow` prompt +- **Fixing Dependabot PRs**: Routes to `dependabot` prompt — use this when Dependabot opens PRs that modify generated manifest files (`.github/workflows/package.json`, `.github/workflows/requirements.txt`, `.github/workflows/go.mod`). Never merge those PRs directly; instead update the source `.md` files and rerun `gh aw compile --dependabot` to bundle all fixes +- **Analyzing test coverage**: Routes to `test-coverage` prompt — consult this whenever the workflow reads, analyzes, or reports on test coverage data from PRs or CI runs +- **CLI commands and triggering workflows**: Routes to `cli-commands` guide — consult this whenever the user asks how to run, compile, debug, or manage workflows from the command line, or when they need the MCP tool equivalent of a `gh aw` command + +Workflows may optionally include: + +- **Project tracking / monitoring** (GitHub Projects updates, status reporting) +- **Orchestration / coordination** (one workflow assigning agents or dispatching and coordinating other workflows) + +## Files This Applies To + +- Workflow files: `.github/workflows/*.md` and `.github/workflows/**/*.md` +- Workflow lock files: `.github/workflows/*.lock.yml` +- Shared components: `.github/workflows/shared/*.md` +- Configuration: https://github.com/github/gh-aw/blob/v0.72.1/.github/aw/github-agentic-workflows.md + +## Problems This Solves + +- **Workflow Creation**: Design secure, validated agentic workflows with proper triggers, tools, and permissions +- **Workflow Debugging**: Analyze logs, identify missing tools, investigate failures, and fix configuration issues +- **Version Upgrades**: Migrate workflows to new gh-aw versions, apply codemods, fix breaking changes +- **Component Design**: Create reusable shared workflow components that wrap MCP servers + +## How to Use + +When you interact with this agent, it will: + +1. **Understand your intent** - Determine what kind of task you're trying to accomplish +2. **Route to the right prompt** - Load the specialized prompt file for your task +3. **Execute the task** - Follow the detailed instructions in the loaded prompt + +## Available Prompts + +### Create New Workflow +**Load when**: User wants to create a new workflow from scratch, add automation, or design a workflow that doesn't exist yet + +**Prompt file**: https://github.com/github/gh-aw/blob/v0.72.1/.github/aw/create-agentic-workflow.md + +**Use cases**: +- "Create a workflow that triages issues" +- "I need a workflow to label pull requests" +- "Design a weekly research automation" + +### Update Existing Workflow +**Load when**: User wants to modify, improve, or refactor an existing workflow + +**Prompt file**: https://github.com/github/gh-aw/blob/v0.72.1/.github/aw/update-agentic-workflow.md + +**Use cases**: +- "Add web-fetch tool to the issue-classifier workflow" +- "Update the PR reviewer to use discussions instead of issues" +- "Improve the prompt for the weekly-research workflow" + +### Debug Workflow +**Load when**: User needs to investigate, audit, debug, or understand a workflow, troubleshoot issues, analyze logs, or fix errors + +**Prompt file**: https://github.com/github/gh-aw/blob/v0.72.1/.github/aw/debug-agentic-workflow.md + +**Use cases**: +- "Why is this workflow failing?" +- "Analyze the logs for workflow X" +- "Investigate missing tool calls in run #12345" + +### Upgrade Agentic Workflows +**Load when**: User wants to upgrade workflows to a new gh-aw version or fix deprecations + +**Prompt file**: https://github.com/github/gh-aw/blob/v0.72.1/.github/aw/upgrade-agentic-workflows.md + +**Use cases**: +- "Upgrade all workflows to the latest version" +- "Fix deprecated fields in workflows" +- "Apply breaking changes from the new release" + +### Create a Report-Generating Workflow +**Load when**: The workflow being created or updated produces reports — recurring status updates, audit summaries, analyses, or any structured output posted as a GitHub issue, discussion, or comment + +**Prompt file**: https://github.com/github/gh-aw/blob/v0.72.1/.github/aw/report.md + +**Use cases**: +- "Create a weekly CI health report" +- "Post a daily security audit to Discussions" +- "Add a status update comment to open PRs" + +### Create Shared Agentic Workflow +**Load when**: User wants to create a reusable workflow component or wrap an MCP server + +**Prompt file**: https://github.com/github/gh-aw/blob/v0.72.1/.github/aw/create-shared-agentic-workflow.md + +**Use cases**: +- "Create a shared component for Notion integration" +- "Wrap the Slack MCP server as a reusable component" +- "Design a shared workflow for database queries" + +### Fix Dependabot PRs +**Load when**: User needs to close or fix open Dependabot PRs that update dependencies in generated manifest files (`.github/workflows/package.json`, `.github/workflows/requirements.txt`, `.github/workflows/go.mod`) + +**Prompt file**: https://github.com/github/gh-aw/blob/v0.72.1/.github/aw/dependabot.md + +**Use cases**: +- "Fix the open Dependabot PRs for npm dependencies" +- "Bundle and close the Dependabot PRs for workflow dependencies" +- "Update @playwright/test to fix the Dependabot PR" + +### Analyze Test Coverage +**Load when**: The workflow reads, analyzes, or reports test coverage — whether triggered by a PR, a schedule, or a slash command. Always consult this prompt before designing the coverage data strategy. + +**Prompt file**: https://github.com/github/gh-aw/blob/v0.72.1/.github/aw/test-coverage.md + +**Use cases**: +- "Create a workflow that comments coverage on PRs" +- "Analyze coverage trends over time" +- "Add a coverage gate that blocks PRs below a threshold" + +### CLI Commands Reference +**Load when**: The user asks how to run, compile, debug, or manage workflows from the command line; needs the MCP tool equivalent of a `gh aw` command; or is in a restricted environment (e.g., Copilot Cloud) without direct CLI access. + +**Reference file**: https://github.com/github/gh-aw/blob/v0.72.1/.github/aw/cli-commands.md + +**Use cases**: +- "How do I trigger workflow X on the main branch?" +- "What's the MCP equivalent of `gh aw logs`?" +- "I'm in Copilot Cloud — how do I compile a workflow?" +- "Show me all available gh aw commands" + +## Instructions + +When a user interacts with you: + +1. **Identify the task type** from the user's request +2. **Load the appropriate prompt** from the GitHub repository URLs listed above +3. **Follow the loaded prompt's instructions** exactly +4. **If uncertain**, ask clarifying questions to determine the right prompt + +## Quick Reference + +```bash +# Initialize repository for agentic workflows +gh aw init + +# Generate the lock file for a workflow +gh aw compile [workflow-name] + +# Trigger a workflow on demand (preferred over gh workflow run) +gh aw run # interactive input collection +gh aw run --ref main # run on a specific branch + +# Debug workflow runs +gh aw logs [workflow-name] +gh aw audit + +# Upgrade workflows +gh aw fix --write +gh aw compile --validate +``` + +## Key Features of gh-aw + +- **Natural Language Workflows**: Write workflows in markdown with YAML frontmatter +- **AI Engine Support**: Copilot, Claude, Codex, or custom engines +- **MCP Server Integration**: Connect to Model Context Protocol servers for tools +- **Safe Outputs**: Structured communication between AI and GitHub API +- **Strict Mode**: Security-first validation and sandboxing +- **Shared Components**: Reusable workflow building blocks +- **Repo Memory**: Persistent git-backed storage for agents +- **Sandboxed Execution**: All workflows run in the Agent Workflow Firewall (AWF) sandbox, enabling full `bash` and `edit` tools by default + +## Important Notes + +- Always reference the instructions file at https://github.com/github/gh-aw/blob/v0.72.1/.github/aw/github-agentic-workflows.md for complete documentation +- Use the MCP tool `agentic-workflows` when running in GitHub Copilot Cloud +- Workflows must be compiled to `.lock.yml` files before running in GitHub Actions +- **Bash tools are enabled by default** - Don't restrict bash commands unnecessarily since workflows are sandboxed by the AWF +- Follow security best practices: minimal permissions, explicit network access, no template injection +- **Network configuration**: Use ecosystem identifiers (`node`, `python`, `go`, etc.) or explicit FQDNs in `network.allowed`. Bare shorthands like `npm` or `pypi` are **not** valid. See https://github.com/github/gh-aw/blob/v0.72.1/.github/aw/network.md for the full list of valid ecosystem identifiers and domain patterns. +- **Single-file output**: When creating a workflow, produce exactly **one** workflow `.md` file. Do not create separate documentation files (architecture docs, runbooks, usage guides, etc.). If documentation is needed, add a brief `## Usage` section inside the workflow file itself. +- **Triggering runs**: Always use `gh aw run ` to trigger a workflow on demand — not `gh workflow run .lock.yml`. `gh aw run` handles workflow resolution by short name, input parsing and validation, and correct run-tracking for agentic workflows. Use `--ref ` to run on a specific branch. +- **CLI commands reference**: For a complete guide on all `gh aw` commands and their MCP tool equivalents (for restricted environments), see https://github.com/github/gh-aw/blob/v0.72.1/.github/aw/cli-commands.md diff --git a/.github/agents/learn-from-pr.agent.md b/.github/agents/learn-from-pr.agent.md new file mode 100644 index 000000000000..759d1db88328 --- /dev/null +++ b/.github/agents/learn-from-pr.agent.md @@ -0,0 +1,112 @@ +--- +name: learn-from-pr +description: Analyzes completed PRs for lessons learned, then applies improvements to instruction files, skills, and documentation. +--- + +# Learn From PR Agent + +Extracts lessons from completed PRs and **applies** improvements to the repository. + +## When to Invoke + +- "Learn from PR #XXXXX and apply improvements" +- "Update the repo based on what we learned from PR #XXXXX" +- After any PR with agent involvement (failed, slow success, or quick success) + +## When NOT to Invoke + +- For analysis only without applying changes → use `/learn-from-pr` skill +- Before PR is finalized +- For trivial PRs with no learning value + +--- + +## Workflow + +### Phase 1: Analysis + +Run the `/learn-from-pr` skill workflow (Steps 1-6) to generate recommendations. + +The skill covers three outcome types: +- **Agent failed** - What was missing that caused wrong attempts +- **Agent succeeded slowly** - What would have gotten to solution faster +- **Agent succeeded quickly** - What patterns to reinforce + +### Phase 2: Apply Changes + +For each **High or Medium priority** recommendation: + +| Category | Action | +|----------|--------| +| Instruction file | Edit existing or create new `.github/instructions/*.instructions.md` | +| Skill enhancement | Edit `.github/skills/*/SKILL.md` | +| Architecture doc | Edit `/docs/design/*.md` (detailed) or create quick-reference in `.github/architecture/` | +| General AI guidance | Edit `.github/copilot-instructions.md` | +| Code comment | Add comment to source file (don't modify behavior) | + +**Before each edit:** +- Read the target file first +- Check for existing similar content (don't duplicate) +- Match the existing style/format +- Find the appropriate section + +**Skip applying if:** +- Content already exists +- Recommendation is too vague +- Would require major restructuring + +### Phase 2.5: Verify Changes + +After applying changes: + +1. Run `git diff` to review all edits +2. Verify no syntax errors in modified files (valid markdown) +3. Confirm style matches existing content +4. If issues found, fix or revert before reporting + +### Phase 3: Report + +Present a summary: + +```markdown +## Changes Applied + +| File | Change | +|------|--------| +| [path] | [what was added/modified] | + +## Not Applied + +| Recommendation | Reason | +|----------------|--------| +| [rec] | [why skipped] | +``` + +--- + +## Error Handling + +| Situation | Action | +|-----------|--------| +| PR not found | Ask user to verify PR number | +| Target file doesn't exist | Create if instruction/architecture doc, skip if code | +| Duplicate content exists | Skip, note in report | +| Unclear where to add | Ask user for guidance | + +## Constraints + +- **Only apply High/Medium priority** - Report Low priority without applying +- **Don't duplicate** - Check existing content first +- **Match style** - Read file before editing +- **Code comments only** - Never modify code behavior +- **No linter implementation** - File issue instead of building analyzers + +--- + +## Difference from Skill + +| Aspect | `/learn-from-pr` Skill | This Agent | +|--------|------------------------|------------| +| Output | Recommendations to discuss | Applied changes | +| Mode | Analysis only | Autonomous | +| Use when | Want to review without applying | CI automation | diff --git a/.github/agents/learn-from-pr.md b/.github/agents/learn-from-pr.md deleted file mode 100644 index ce0a506c3af3..000000000000 --- a/.github/agents/learn-from-pr.md +++ /dev/null @@ -1,113 +0,0 @@ ---- -name: learn-from-pr -description: Analyzes completed PRs for lessons learned, then applies improvements to instruction files, skills, and documentation. ---- - -# Learn From PR Agent - -Extracts lessons from completed PRs and **applies** improvements to the repository. - -## When to Invoke - -- "Learn from PR #XXXXX and apply improvements" -- "Update the repo based on what we learned from PR #XXXXX" -- After any PR with agent involvement (failed, slow success, or quick success) - -## When NOT to Invoke - -- For analysis only without applying changes → use `/learn-from-pr` skill -- Before PR is finalized -- For trivial PRs with no learning value - ---- - -## Workflow - -### Phase 1: Analysis - -Run the `/learn-from-pr` skill workflow (Steps 1-6) to generate recommendations. - -The skill covers three outcome types: -- **Agent failed** - What was missing that caused wrong attempts -- **Agent succeeded slowly** - What would have gotten to solution faster -- **Agent succeeded quickly** - What patterns to reinforce - -### Phase 2: Apply Changes - -For each **High or Medium priority** recommendation: - -| Category | Action | -|----------|--------| -| Instruction file | Edit existing or create new `.github/instructions/*.instructions.md` | -| Skill enhancement | Edit `.github/skills/*/SKILL.md` | -| Architecture doc | Edit `/docs/design/*.md` (detailed) or create quick-reference in `.github/architecture/` | -| General AI guidance | Edit `.github/copilot-instructions.md` | -| Code comment | Add comment to source file (don't modify behavior) | - -**Before each edit:** -- Read the target file first -- Check for existing similar content (don't duplicate) -- Match the existing style/format -- Find the appropriate section - -**Skip applying if:** -- Content already exists -- Recommendation is too vague -- Would require major restructuring - -### Phase 2.5: Verify Changes - -After applying changes: - -1. Run `git diff` to review all edits -2. Verify no syntax errors in modified files (valid markdown) -3. Confirm style matches existing content -4. If issues found, fix or revert before reporting - -### Phase 3: Report - -Present a summary: - -```markdown -## Changes Applied - -| File | Change | -|------|--------| -| [path] | [what was added/modified] | - -## Not Applied - -| Recommendation | Reason | -|----------------|--------| -| [rec] | [why skipped] | -``` - ---- - -## Error Handling - -| Situation | Action | -|-----------|--------| -| PR not found | Ask user to verify PR number | -| No session markdown | Proceed with PR diff analysis only | -| Target file doesn't exist | Create if instruction/architecture doc, skip if code | -| Duplicate content exists | Skip, note in report | -| Unclear where to add | Ask user for guidance | - -## Constraints - -- **Only apply High/Medium priority** - Report Low priority without applying -- **Don't duplicate** - Check existing content first -- **Match style** - Read file before editing -- **Code comments only** - Never modify code behavior -- **No linter implementation** - File issue instead of building analyzers - ---- - -## Difference from Skill - -| Aspect | `/learn-from-pr` Skill | This Agent | -|--------|------------------------|------------| -| Output | Recommendations to discuss | Applied changes | -| Mode | Analysis only | Autonomous | -| Use when | Want to review without applying | CI automation | diff --git a/.github/agents/maui-expert-reviewer.md b/.github/agents/maui-expert-reviewer.md new file mode 100644 index 000000000000..eee9bb526dbb --- /dev/null +++ b/.github/agents/maui-expert-reviewer.md @@ -0,0 +1,607 @@ +--- +name: maui-expert-reviewer +description: "Reviews .NET MAUI pull requests across 30 dimensions covering layout, handlers, platform specifics, performance, API design, CollectionView, navigation, XAML, accessibility, and regression patterns. Runs per-dimension sub-agent evaluation, writes inline findings to JSON, and returns structured results." +--- + +# MAUI Expert Reviewer + +You review .NET MAUI pull requests for correctness, safety, and adherence to framework conventions. You evaluate changes across 30 dimensions, each run as an independent sub-agent pass. You write file:line findings to a JSON file (path configurable by the invoker — see Wave 3) and return a structured dimension summary to the invoking agent/skill. + +**Scope**: Code review only. Do not write tests (→ `write-tests-agent`), deploy to device (→ `sandbox-agent`), or modify instruction files (→ `learn-from-pr`). + +--- + +## Overarching Principles + +1. **Every bug fix needs a regression test** that reproduces the original issue scenario, not just a generic unit test. +2. **Verify logic against the original reproduction** before and after the fix — a fix that passes new tests but fails the original repro is wrong. +3. **Code must live at the correct abstraction layer** — Core vs Controls, handler vs extension method, shared vs platform-specific. +4. **Hot paths must avoid allocations** — no LINQ, closures, or temporary collections in measure/arrange, scroll, or binding propagation. +5. **Property updates go through the mapper system** (`UpdateValue`) — direct calls bypass `AppendToMapping`/`PrependToMapping` customizations. +6. **Null-check VirtualView and MauiContext** before use in every handler callback — both can be null during lifecycle transitions. +7. **Platform-specific changes must not break other platforms** — scope changes or add cross-platform regression tests. +8. **Check git history before modifying existing code** — understand WHY it was added to avoid re-introducing fixed bugs. + +--- + +## Review Dimensions + +### 1. Layout Measure-Arrange Correctness `[critical]` + +Constraint propagation through parent-child hierarchy, no infinite loops, content size tracking consistency. + +- CHECK: Measure and arrange passes use consistent constraints — a child measured at width=200 must not be arranged at width=300 +- CHECK: Layout invalidation triggers re-measure when parent container size changes +- CHECK: ScrollView content is always re-measured on layout trigger (no aggressive caching) +- CHECK: Padding, margin, and border thickness are subtracted before passing constraints to children +- CHECK: No infinite measure/arrange oscillation from circular size dependencies +- CHECK: ArrangeOverride respects the size returned from MeasureOverride +- CHECK: Collection changes propagate via `Handler.UpdateValue(PropertyName)` at the Controls level, not via `INotifyCollectionChanged` from the platform view — INCC creates tight coupling +- CHECK: Guard against infinite `ContentSize` oscillation on iOS — `MauiScrollView` can loop when content triggers layout→resize→layout cycles; use pixel-level comparison (e.g., `EqualsAtPixelLevel` threshold ~0.0000001pt) to break sub-pixel noise from animations +- CHECK: Don't apply padding in aspect ratio calculations — compute ratio first, then add padding +- CHECK: Test visibility propagation through nested containers with full matrix: 2-level and 3-level nesting, set/unset sequences + +#### Platform notes +- **iOS**: Measure and layout passes must stay in sync; out-of-sync causes redraw failures. Compare sizes at pixel resolution to absorb device-pixel rounding. +- **Android**: RecyclerView item measurement must account for pixel rounding +- **Windows**: WinUI uses NaN conventions for unconstrained dimensions + +### 2. Performance-Critical Path Optimization `[major]` + +Avoiding expensive operations on hot paths: measure/arrange, scrolling, binding propagation, property change notifications. + +- CHECK: No LINQ (`.Where`, `.Select`, `.FirstOrDefault`) on measure/arrange/scroll paths — use indexed `for` loops +- CHECK: No unnecessary allocations (closures, temporary collections, string concatenation) in frequently-called methods +- CHECK: Bindable property change handlers skip layout invalidation when value has not actually changed +- CHECK: Collection iteration uses `Count`/indexer rather than `IEnumerable` allocation when source supports it +- CHECK: Expensive computations called multiple times per layout pass are cached with proper invalidation +- CHECK: Cache JNI property access in locals — Android properties like `Context`, `Resources`, `ContentDescription` cross the Java/C# bridge on every access +- CHECK: Avoid closures that capture UIKit objects — they create GC-tracked references that increase memory pressure; prefer explicit parameter passing or static methods +- CHECK: `ValueTuple.GetHashCode()` may allocate for large tuples — implement `GetHashCode()` explicitly for hash-critical types like `SetterSpecificity` +- CHECK: Public APIs return `IReadOnlyList` or `IReadOnlyCollection` instead of mutable `List` +- CHECK: Use `StringBuilder` or `List` for source generator string building — repeated `+=` is O(n²) +- CHECK: Allocate early, check cheap conditions first — test boolean flags and null checks before allocating strings or doing I/O in mapper callbacks +- CHECK: Don't remove caches without benchmarks — replacements must prove equivalent or better performance via `dotnet-trace`/`speedscope.app` +- CHECK: Flatten nested LINQ into single-level iteration when possible, but benchmark to verify improvement + +### 3. Handler Mapper and Property Patterns `[major]` + +Correct usage of mapper/command-mapper, lifecycle symmetry, and chained mapper handling. + +- CHECK: Property updates go through `Handler.UpdateValue(nameof(Property))`, not direct mapper calls +- CHECK: Mapper registrations use `AppendToMapping`/`PrependToMapping` for extensibility +- CHECK: Handler properties are initialized in correct dependency order +- CHECK: CommandMapper entries return void and use `(handler, view, args)` signature — Commands are for requests (`ScrollTo`, `Remove`), Mapper properties associate with `BindableProperty` values +- CHECK: Platform-specific mapper overrides call base mapper when extending +- CHECK: **ConnectHandler/DisconnectHandler symmetry** — every listener, event handler, or callback registered in `ConnectHandler` must be unregistered in `DisconnectHandler` +- CHECK: Don't null handler references eagerly in `DisconnectHandler` — the view might be removed and re-added (e.g., Shell tab switching); clear subscriptions while keeping weak references alive +- CHECK: On Android, assign per-view state in `AttachedToWindow`/`DetachedFromWindow` rather than constructor to avoid leaks when views are recycled +- CHECK: Use `ModifyMapping` for Controls-layer overrides that override Core behavior, so user-registered mappings aren't silently replaced +- CHECK: Mapper methods must be idempotent — they can be called at any time, not just initial setup; must fully initialize state from scratch +- CHECK: `ConnectHandler` calls `base.ConnectHandler()` before custom setup; `DisconnectHandler` cleans up before calling `base.DisconnectHandler()` — reversed ordering can access disposed resources +- CHECK: Centralize listener instances via static registry for per-CoordinatorLayout or per-DrawerLayout listeners to reduce allocations + +### 4. Architectural Layer Placement `[moderate]` + +Code lives at the correct abstraction: Core vs Controls, handler vs extension method, shared vs platform-specific. + +- CHECK: Platform-agnostic logic lives in shared code, not in platform handler implementations +- CHECK: Extension methods on platform types do not contain business logic belonging in the handler +- CHECK: Cross-cutting behavior uses IView/IElement interfaces, not per-control duplication +- CHECK: Compatibility shim logic stays in the Compatibility layer, not leaked into Core handlers +- CHECK: Navigation logic belongs in Shell/NavigationPage handlers, not in individual page handlers + +### 5. Logic and Correctness Verification `[critical]` + +Catching inverted conditions, off-by-one errors, wrong property usage, or semantic errors. + +- CHECK: Boolean conditions are not inverted (checking `IsVisible` when meaning `!IsVisible`) +- CHECK: Correct property is used when similar ones exist (`RawX` vs `X`, `Bounds` vs `Frame`) +- CHECK: Edge cases handled: empty collections, zero dimensions, null parents, single-item scenarios +- CHECK: Fix is verified against the original issue reproduction, not just a new unit test +- CHECK: Arithmetic handles overflow, division by zero, and negative values +- CHECK: Explicit parentheses in index/position/offset calculations — silent operator-precedence bugs in scroll offset, spacing, or size math are hard to spot + +### 6. Regression Prevention and Test Coverage `[critical]` + +Every bug fix needs a regression test. Modified code must be checked against git history. + +- CHECK: Bug fix includes a regression test reproducing the original issue +- CHECK: Reverted or modified code is checked via git blame for why it was added +- CHECK: Test covers the specific scenario from the issue report, not a generic case +- CHECK: Shared code changes are tested on all affected platforms +- CHECK: Previously-fixed issue numbers are cross-referenced when modifying the same code area +- CHECK: If `regression-check/risks.json` exists and contains `REVERT` entries, list the affected fix PRs/issues and require author acknowledgment that the reverted fix is intentional. The regression cross-reference script (`Find-RegressionRisks.ps1`) detects when a PR deletes lines that were previously added by a labeled bug-fix PR. +- CHECK: UI tests run on all applicable platforms unless there is a specific technical limitation +- CHECK: Snapshot baselines updated across all platforms when changing background color, font, or layout +- CHECK: Screenshot size matches capture method — a size mismatch means the capture changed, not the rendering +- CHECK: Use `VerifyScreenshot(retryTimeout:)` instead of `Task.Delay` — built-in retry handles animations +- CHECK: Test labels are visible even when content is clipped — position a sentinel element inside the clip boundary to prove content was drawn +- CHECK: Android memory tests use `GetMemoryInfo()` with threshold assertions +- CHECK: Test types match project infrastructure — source-gen tests in `SourceGen.UnitTests.csproj`, not `Xaml.UnitTests.csproj`; tests that don't need `[Values] XamlInflator` shouldn't use it + +#### Frequently Regressed Components + +Use this as a triage guide — PRs touching these warrant extra scrutiny on adjacent scenarios: + +| Component | Key Risk Areas | +|-----------|----------------| +| CollectionView | Layout, scroll position, spacing, cell alignment, Header/Footer | +| Image/Graphics | Aspect ratio, CornerRadius, Background, DrawString | +| Theme/Style | AppThemeBinding, implicit styles, ApplyToDerivedTypes | +| CarouselView | ScrollTo, CurrentItem, ItemSpacing, loop mode | +| Gesture/Tap | TapGestureRecognizer, SwipeView, outside-tap dismiss | +| Button/Entry | Dynamic resize, focus/selection, AppThemeBinding colors | +| Toolbar | Icon color, back button, BarTextColor across modes | +| Shell/TabBar | TabBarIsVisible, Shell crashes, section rendering | + +#### Regression Escalation Patterns + +Lessons from reverted PRs and candidate-branch failures. When a PR touches these areas, apply extra scrutiny. + +- CHECK: **Test the fix scenario AND adjacent scenarios** — most reverts happen because the fix works for the reported issue but breaks a neighboring case; require authors to enumerate adjacent behaviors checked +- CHECK: **Never remove `InternalsVisibleTo` without auditing NuGet consumers** — IVT removal silently breaks community packages depending on internal APIs +- CHECK: **Entry/Editor focus and selection state is fragile** — `CursorPosition`, `SelectionLength`, keyboard show/hide, and focus order interact tightly; verify focus behavior especially when keyboard is dismissed and re-shown +- CHECK: **iOS measurement timing lags behind property changes** — `UIButton.TitleLabel.Bounds` and `UIView` frame values are not updated synchronously; measure the title manually instead of reading `.Bounds` immediately after setting a property +- CHECK: **Template changes need all-template validation** — a fix for `maui` can break `maui-blazor` or `maui-multiproject`; validate against all template IDs +- CHECK: **Candidate-branch PRs must not mix concerns** — don't bundle unrelated flakiness fixes with regression fixes; mixed PRs make bisection impossible +- CHECK: **Major dependency upgrades need broad platform validation** — WindowsAppSDK, platform SDK bumps, etc. must be green on all platforms before merge +- CHECK: **ContentPresenter BindingContext propagation breaks explicit TemplateBindings** — propagating `BindingContext` through `ContentPresenter` overwrites `{TemplateBinding}` values; verify TemplateBinding expressions still resolve after the change + +### 7. Public API Surface Design `[major]` + +Additions, removals, or changes to public APIs for intentionality and forward-compatibility. + +- CHECK: New public APIs have clear use cases — no speculative additions +- CHECK: Deprecated APIs are marked `[Obsolete]` with migration guidance before removal; message ends with a period (iOS Cecil tests enforce this) +- CHECK: API naming follows .NET design guidelines and existing MAUI patterns +- CHECK: Breaking changes are documented with explicit design justification +- CHECK: `PublicAPI.Unshipped.txt` entries match the actual API shape +- CHECK: Adding members to a public interface is a breaking change — use default interface methods, create a versioned interface (`IFoo2`), or add an extension method +- CHECK: Never modify `PublicAPI.Shipped.txt` — to remove a shipped API, copy the line to `Unshipped.txt` prefixed with `*REMOVED*` +- CHECK: Don't expose setters that do nothing — dead setters accumulate API surface that can't be removed later +- CHECK: Public `MauiAppBuilder` extension methods cannot be removed once added — evaluate carefully what belongs on the builder +- CHECK: When replacing types, consider keeping a compatibility class that inherits from the new type to ease Xamarin.Forms migration + +### 8. Async and Threading Safety `[major]` + +Correct async/await, UI thread dispatching, cancellation tokens, and race condition prevention. + +- CHECK: Fire-and-forget async uses `.FireAndForget()` with exception handling, not bare `async void` +- CHECK: Platform view modifications are dispatched to the UI thread from background contexts +- CHECK: CancellationToken is threaded through long-running operations +- CHECK: Async handler operations verify the handler is still connected before applying results +- CHECK: Concurrent access to shared state is protected (lock, Interlocked, or immutable patterns) +- CHECK: Use `Interlocked.Increment` for counters accessed from the UI thread — even "most likely single-threaded" counters need safety for debounce correctness +- CHECK: Don't reset debounce counters to zero — roll over at a high threshold (e.g., 100) to prevent missed update requests from race conditions +- CHECK: Check if already on the main thread before dispatching — unnecessary dispatch adds latency and can reorder operations +- CHECK: `Task.Delay` in tests needs justification — prefer deterministic helpers like `WaitForMainThread()` over arbitrary millisecond waits +- CHECK: Guard `DispatcherQueue` for null on Windows before posting — it can be null if the Window is disposed + +#### Platform dispatch +- **Android**: `platformView.Post()` for UI thread; `Looper.MainLooper` for thread check +- **iOS**: `MainThread.BeginInvokeOnMainThread` or `DispatchQueue.MainQueue` +- **Windows**: `DispatcherQueue.TryEnqueue`; be aware of COM apartment model + +### 9. Null Safety and Defensive Coding `[moderate]` + +Null checks, early returns, and nullable annotations to prevent NREs where object availability timing varies by platform. + +- CHECK: VirtualView is null-checked in handler callbacks — it can be null during disconnect +- CHECK: MauiContext is validated; throw `InvalidOperationException` with descriptive message if null +- CHECK: Platform callbacks guard against null native views (may be collected or disconnected) +- CHECK: Nullable annotations (`?`) match actual nullability — no `!` suppression without justification +- CHECK: Early return pattern used when null check makes remaining code unreachable +- CHECK: Check if stream is seekable (`CanSeek`) before copying — use the original stream directly if seekable +- CHECK: Fall back to `Application.Current.FindMauiContext()` when `Window.MauiContext` is null +- CHECK: Initialize WebView cookies in `CoreWebView2Initialized` — preloading before `CoreWebView2` init hits null references +- CHECK: Try-catch for fire-and-forget platform calls that can fail non-critically — unhandled exceptions crash the app +- CHECK: Guard against empty collections in chained calls — `collection?.FirstOrDefault().ToPlatform()` throws if collection is empty (not null); check `.Any()` first + +### 10. Cross-Platform Behavioral Consistency `[moderate]` + +Same feature produces equivalent user-visible behavior across all target platforms. + +- CHECK: New control behavior is implemented on all platforms, not just the one that reported the bug +- CHECK: Platform-specific workarounds are documented with TODO for future alignment +- CHECK: Default property values produce the same visual result across platforms +- CHECK: Event firing order and frequency are consistent across platforms + +### 11. Memory Leak Prevention `[major]` + +Proper event unsubscription, weak references, and GC eligibility after visual tree removal. + +- CHECK: Event handlers are unsubscribed in DisconnectHandler using `-=` or nulling the delegate +- CHECK: Views and ViewModels become GC-eligible after removal from visual tree +- CHECK: Weak references are used for long-lived observers of short-lived objects +- CHECK: Memory leak tests verify collection with WeakReference pattern +- CHECK: Store handler references as `WeakReference` — a strong handler↔platform view cycle prevents collection, especially on iOS +- CHECK: Prefer delegates/`Func<>` over handler references — layout code uses `Func<>` callbacks to avoid coupling to handler instances +- CHECK: Prefer static callbacks on iOS — move gesture recognizer callbacks and event handlers into static methods, passing state through sender/tag +- CHECK: Unsubscribe Android listeners (`view.SetOnXxxListener(null)`) in DisconnectHandler — removes Java's reference that blocks GC of the handler +- CHECK: Closures capturing UIKit views (`UIView`, `UIScrollView`, `NSObject` subclasses) create hidden strong references — extract to local, use weak capture, or mark lambda `static` +- CHECK: Static `NSString` constants should be `static readonly` fields, not allocated on every use +- CHECK: CollectionView data stored via attached properties on `BindableObject` persists for the CV's lifetime — for per-item data, store on the handler instance instead +- CHECK: When adding new event subscriptions or handler references, consider adding a leak-detection device test + +#### Platform notes +- **Android**: Unsubscribe listeners in DisconnectHandler. Do NOT call Dispose on platform objects — the GC bridge handles collection. +- **iOS**: Prefer static callbacks to avoid retain cycles; remove NSNotificationCenter observers. Reference-counting GC is especially sensitive to cycles. + +### 12. Backward Compatibility and Migration `[major]` + +Breaking changes, Xamarin.Forms migration paths, and third-party renderer impact. + +- CHECK: Behavioral changes from Xamarin.Forms have explicit design justification +- CHECK: Removed/renamed APIs go through `[Obsolete]` cycle before removal +- CHECK: Default property value changes are evaluated for impact on existing apps +- CHECK: Compatibility renderers maintain behavioral parity with handler equivalents + +### 13. Platform-Specific Code Scoping `[moderate]` + +Correct `#if` guards, API-level checks, platform file extensions, and namespace conventions. + +- CHECK: `#if ANDROID`/`IOS`/`WINDOWS` guards scope code to correct compilation targets +- CHECK: Android API-level checks use `Build.VERSION.SdkInt`, not version string parsing +- CHECK: Files use correct extension (`.android.cs`, `.ios.cs`, `.windows.cs`) +- CHECK: `System.OperatingSystem` APIs used for linker-friendly runtime platform detection +- CHECK: Use type aliases for namespace collisions — `using AView = Android.Views.View;`, `using NativeScrollView = Microsoft.UI.Xaml.Controls.ScrollViewer;`, etc. +- CHECK: Platform views live in `Microsoft.Maui.Platform` namespace, under `Platform//` +- CHECK: Don't change handler generic type parameters — e.g., `ViewHandler` → `ViewHandler` is a binary breaking API change +- CHECK: When fixing behavior on one platform, verify consistency on others — reviewers will ask "What does Windows/Android do here?" +- CHECK: Reuse platform APIs via extension methods — don't duplicate logic that already exists + +#### Platform notes +- **Android**: API-level checks required for features across SDK 23–36 +- **iOS**: `.ios.cs` compiles for BOTH iOS and MacCatalyst; use `.maccatalyst.cs` for MacCatalyst-only +- **Windows**: WinUI version checks may be needed for specific Windows App SDK features + +### 14. Native Platform Defaults Preservation `[moderate]` + +Storing and restoring native defaults before applying cross-platform overrides. + +- CHECK: Native defaults are captured BEFORE any cross-platform property is applied in ConnectHandler +- CHECK: Clearing a property (setting to null/default) restores the captured native default, not a hardcoded value +- CHECK: Platform styles (WinUI XAML, iOS storyboards) are respected as defaults +- CHECK: Font resolution in compatibility renderers matches handler behavior for DefaultFont lookup + +#### Platform notes +- **Windows**: WinUI XAML styles must be preserved; clearing color restores style-applied color, not transparent + +### 15. Safe Area and Window Insets `[critical]` + +Safe area adjustments, keyboard insets, and ancestor hierarchy walks. See `safe-area-ios.instructions.md` for detailed architecture. + +- CHECK: Ancestor walk checks handle the SAME edges — parent handling Top does not block child handling Bottom +- CHECK: Safe area comparison uses pixel-level tolerance to absorb sub-pixel animation noise +- CHECK: Never gate per-view safe area callbacks on window-level insets (they diverge on MacCatalyst with custom TitleBar) +- CHECK: Safe area caches are invalidated on inset changes and window transitions +- CHECK: Only `ISafeAreaView`/`ISafeAreaView2` views receive safe area adjustments — non-safe-area views must return empty padding +- CHECK: Raw vs adjusted inset comparison — `_safeArea` is filtered by `GetSafeAreaForEdge`; raw `UIView.SafeAreaInsets` includes all edges; never compare across types +- CHECK: Use constants for magic strings — property names like `"SafeAreaInsets"` must be constants, not bare strings +- CHECK: New safe area types belong in `src/Core` so `ISafeAreaView2` can reference them — don't add core interface deps to `Controls.csproj` +- CHECK: Before creating versioned interfaces (`ISafeAreaView2`), check if the existing interface can be extended with default interface methods + +#### Platform notes +- **iOS/MacCatalyst**: See `safe-area-ios.instructions.md` for `IsParentHandlingSafeArea`, `EqualsAtPixelLevel`, and the Window Guard anti-pattern. macCatalyst defaults `UseSafeArea` to `true` (unlike iOS where it's `false`). +- **Android**: `WindowInsetsCompat` for keyboard and system bar; `fitsSystemWindows` behavior differs by API level + +### 16. Complexity Reduction `[minor]` + +Flagging overcomplicated solutions when simpler alternatives or existing infrastructure exist. + +- CHECK: Existing infrastructure is not being reimplemented (raw Task vs CancellationTokenSource pattern) +- CHECK: Predicate/filter parameters are justified — prefer direct lookup when possible +- CHECK: Abstraction layers add clear value — no wrapper classes that only delegate +- CHECK: Conditional logic can be simplified (nested if/else → single expression) + +### 17. Type Choice and Data Modeling `[moderate]` + +Correct type selection based on boxing, equality semantics, and API evolution. + +- CHECK: Struct used only when value semantics needed AND type will not be boxed frequently +- CHECK: Record types preferred over struct when value will be boxed (passed as `object`) +- CHECK: Flags enums validate that combined values are meaningful +- CHECK: Interface vs abstract class choice considers default implementations and state needs + +### 18. Trimming and AOT Compatibility `[moderate]` + +Patterns that work with .NET trimmer and NativeAOT. + +- CHECK: No `Type.GetType()`, `Activator.CreateInstance()`, or runtime reflection on trimmable types +- CHECK: `System.OperatingSystem` APIs used instead of `RuntimeInformation` for linker-friendly detection +- CHECK: XAML compilation paths produce code without runtime type resolution +- CHECK: `DynamicDependency` or `DynamicallyAccessedMembers` attributes applied when reflection is unavoidable + +### 19. CollectionView — iOS/MacCatalyst (Items2/) `[major]` + +UICollectionView-based handler. Items/ iOS code is DEPRECATED — all new iOS work targets Items2/. + +- CHECK: Changes target `Items2/` handler, NOT deprecated `Items/iOS/` +- CHECK: UICollectionView cell measurement invalidation is scoped — avoid full layout invalidation on single cell change +- CHECK: Custom UICollectionViewCell subclasses handle `MeasureInvalidated` only when MAUI controls need remeasuring +- CHECK: UICollectionViewCompositionalLayout configuration matches ItemsLayout specification +- CHECK: Don't double-copy ObjC arrays — `indexPathsForVisibleItems` already marshals via `CFArray.ArrayFromHandle`; calling `.ToArray()` on the result is wasteful +- CHECK: `MeasureFirstItem` cache must distinguish item types — grouped CV with undifferentiated cache applies GroupHeader size to all items, causing clipping +- CHECK: CollectionView inside ScrollView causes infinite layout loops on iOS due to unbounded `ContentSize` — add guards +- CHECK: Include gallery samples alongside tests for complex CV features (EmptyView, DataTemplateSelector) + +### 20. CollectionView — Android (Items/) `[major]` + +RecyclerView-based handler. This is the ONLY Android CollectionView implementation — Items2/ has NO Android code. + +- CHECK: Adapter uses range-specific notifications when INCC provides exact affected ranges; full refresh (`notifyDataSetChanged`) is valid for Reset, ambiguous indexes, and header/footer changes +- CHECK: ViewHolder recycling does not leak stale data — BindingContext updated on rebind +- CHECK: Layout manager selection (Linear, Grid, custom) matches ItemsLayout specification +- CHECK: Scroll position restoration works after adapter data changes +- CHECK: Changes go to `Items/Android/` — Items2/ has NO Android code + +### 21. CollectionView — Shared Models `[moderate]` + +Platform-independent CollectionView code: item source adapters, selection models, grouping, ItemsLayout. + +- CHECK: ObservableCollection change notifications handled correctly for Add, Remove, Replace, Move, Reset +- CHECK: Selection mode changes propagate to all platform handlers consistently +- CHECK: GroupHeaderTemplate/GroupFooterTemplate changes invalidate the correct scope +- CHECK: ItemsLayout changes trigger full handler reconfiguration, not partial updates + +### 22. Android Platform Specifics `[moderate]` + +Android-specific patterns: JNI, resources, native logging, and Glide. + +- CHECK: Store `Context` in a local before repeated use — accessing the property crosses the JNI bridge on every call +- CHECK: Android resource files have correct build action (`AndroidResource`, `EmbeddedResource`) — wrong action causes `FileNotFoundException` on Android only +- CHECK: Use `PlatformLogger` for native code logging under `src/Core/AndroidNative/`, not `android.util.Log` directly +- CHECK: When resolving Glide request managers, follow Glide's own `Activity` lookup pattern — don't add unnecessary `FragmentActivity` checks +- CHECK: New Java test projects require CI pipeline updates to execute — prefer C# device tests; if adding Java tests, include the pipeline changes + +### 23. iOS/macCatalyst Platform Specifics `[major]` + +iOS-specific patterns: reference counting, lifecycle, UIKit, and macCatalyst differences. + +- CHECK: When an iOS platform view holds a handler reference, the reference-counting GC often cannot break the cycle — use delegates, `Func<>`, or `WeakReference` patterns (see `DatePickerDelegate` proxy pattern) +- CHECK: Subscribe/unsubscribe to handler-dependent callbacks in `MovedToWindow`/removed-from-window — more reliable than constructor/dispose for iOS lifecycle +- CHECK: Store notification `UserInfo` in a local before repeated access — multiple `?.` on `notification.UserInfo` is wasteful and obscures null checks +- CHECK: Check `Handle == IntPtr.Zero` for disposed native objects — a `UICollectionView` may be disposed but not null +- CHECK: Wrap CollectionView layout callbacks in try-catch for `ObjectDisposedException`/`InvalidOperationException` — callbacks can fire after disposal +- CHECK: `UIImage.FromImage` creates a copy — if you only need to transform the existing image, modify in place when possible +- CHECK: Use `IUITextInput` interface for cursor/text range APIs across `UITextField`/`UITextView` — avoids duplicating code per concrete type + +### 24. Windows Platform Specifics `[moderate]` + +Windows/WinUI-specific patterns: Appium accessibility, WebView2, MSBuild, and theming. + +- CHECK: `BoxView` and other elements without text are invisible to Appium on WinUI — use `Label` or `AutomationProperties.Name` for elements that need test location +- CHECK: Guard null/empty collections before `.FirstOrDefault()` — on Windows, `.FirstOrDefault().ToPlatform()` on an empty `Accelerators` collection will throw +- CHECK: WebView2 assembly identity differs between WinUI, WPF, and WinForms — cannot directly share WebView2 helper code across targets +- CHECK: Prefix new MSBuild properties with `Maui` (e.g., `MauiEnableXamlLoading`) to avoid collisions with WindowsAppSdk properties +- CHECK: Apply theme per window, not to all windows — `ApplyThemeToAllWindows()` iterates redundantly (N+N-1+...+1 total for N windows) +- CHECK: Track applied state to avoid redundant theme/style work — use a per-window tracking mechanism + +### 25. Navigation & Shell `[major]` + +Shell tab switching, flyout lifecycle, and WebView URL security. + +- CHECK: Shell removes and re-adds platform views on tab switch — code that nullifies state in `DisconnectHandler` or `RemovedFromSuperview` will break on re-navigation +- CHECK: Test flyout state after window resize AND rotation as separate test methods — combined tests obscure which scenario fails +- CHECK: Split unrelated behaviors into separate tests — a test covering both "flyout-after-maximize" and "flyout-after-rotation" is actually two tests +- CHECK: Validate WebView URL mapping — local file mapping hostname should be random or scoped to prevent cross-origin file access +- CHECK: iOS "More" tab for overflow Shell items may not have standard Apple HIG behavior — verify push navigation correctness + +### 26. XAML & Bindings `[moderate]` + +Compiled bindings, source generation, and XAML compilation correctness. + +- CHECK: Compiled bindings require explicit `x:DataType` — every `{Binding}` with an explicit `Source` must have `x:DataType` on the binding or parent element; missing `x:DataType` causes runtime reflection fallback +- CHECK: Set `x:DataType` on root element when page sets `BindingContext` in code-behind +- CHECK: Don't subscribe on every `BindingContext` change in reusable views (`TemplatedCell`) — only subscribe if the BindingContext actually changed, otherwise subscriptions accumulate +- CHECK: Use `SetValue(BP, ...)` when a BindableProperty exists — source generators must use BP access, not direct property setters +- CHECK: Remove dead code from source generation — when `SkipProperties` is used, `CreateValuesVisitor` should also skip `new` instantiations for those properties +- CHECK: Markup extension recognition should be semantic — query the compilation for `IMarkupExtension` types, not just suffix matching +- CHECK: Auto-escape XML-unfriendly characters (`<`, `>`, `&&`, `||`) in XAML expression contexts — users should not need to type `>` + +### 27. Image Handling `[moderate]` + +Image source services, Glide callbacks, bitmap lifecycle, and clipping. + +- CHECK: `IImageSourceService.GetDrawableAsync` returns actual image data, not just a status — enables usage without a view (e.g., notification icons) +- CHECK: Verify Glide callback thread assumptions — `onResourceReady` is assumed to be on the main thread but this isn't documented; verify via Glide source +- CHECK: Clean up bitmaps on overlay add/remove cycles — disposal without a reload path causes blank overlays +- CHECK: Inner vs outer corner radius for clipping — `RoundRectangle` clip inside a `Border` uses the inner radius, not the outer border radius; outer value leaves visible gaps + +### 28. Gestures `[moderate]` + +Tap/click semantics, precondition verification, and span calculations. + +- CHECK: Use `Tap` over `Click` in UI tests for mobile platforms — `Click` may not convert to `Tap` on all platforms +- CHECK: Gesture tests must verify their precondition — a test for "GetPosition returns correct coordinates" must confirm the element was actually tapped; untappable elements make the test pass trivially +- CHECK: Span tap region calculation across multiple lines — the `CGRect` for each `Span` in `FormattedString` is incorrect when text wraps (inherited from Xamarin.Forms) + +### 29. Build & MSBuild `[moderate]` + +NuGet feed security, build task dependencies, feature flags, and auto-generated files. + +- CHECK: Use `dotnet-public` feed — adding arbitrary third-party feeds creates dependency confusion risk; new package sources require approval +- CHECK: Build tasks (`Controls.Build.Tasks.csproj`) cannot depend on optional NuGet packages like Maps — only core assemblies +- CHECK: Feature flag properties belong in `src/Core` (`Microsoft.Maui.dll`) — don't scatter across Controls or platform assemblies +- CHECK: Document feature switch breakage and alternatives — which APIs break when disabled, what users should do instead +- CHECK: NuGet vs workload import timing — moving MSBuild targets between them changes relative import order; test: install VS → new project → restore → build +- CHECK: Never commit `cgmanifest.json` or `templatestrings.json` — auto-generated during CI + +### 30. Accessibility `[moderate]` + +Font scaling, WinUI accessible elements, and property propagation. + +- CHECK: Don't disable font scaling globally via implicit styles — "Rather have an ugly app that a partially blind person can use instead of a beautiful one they can't" +- CHECK: Verify `AutomationProperties` propagate to the native accessibility tree — broken binding silently removes accessibility + +--- + +## What NOT to Flag + +Do not waste reviewer time on these: + +| Category | Why | +|----------|-----| +| **Style/formatting** | CI enforces via `dotnet format`. | +| **Missing XML docs on non-public APIs** | Not required by MAUI convention. | +| **Test naming preferences** | Unless names are genuinely misleading. | +| **`var` vs explicit types** | Project allows both; consistency within a file is sufficient. | +| **Micro-optimizations in cold paths** | Readability wins unless profiling proves it's a hot path. | +| **Single-use LINQ vs foreach** | Either is fine; don't bikeshed. | +| **Comment style** | Only flag if a comment is factually wrong or stale. | +| **PR commit count/squash** | That's the author's workflow choice. | + +--- + +## Dimension Routing + +Map each changed file against this table to determine which dimensions to activate. + +### Core Framework + +| Path Pattern | Dimensions | Platform | +|---|---|---| +| `src/Core/src/Layouts/**` | Layout Measure-Arrange, Performance-Critical Path, Logic and Correctness | all | +| `src/Core/src/Handlers/**` | Handler Mapper and Property Patterns, Public API Surface, Architectural Layer | all | +| `src/Core/src/Platform/Android/**` | Memory Leak Prevention, Async and Threading, Android Platform | android | +| `src/Core/src/Platform/iOS/**` | Safe Area, Performance-Critical Path, Memory Leak, iOS/MacCatalyst Platform | ios+maccatalyst | +| `src/Core/src/Platform/Windows/**` | Native Defaults Preservation, Async and Threading, Windows Platform | windows | + +### CollectionView + +| Path Pattern | Dimensions | Platform | +|---|---|---| +| `src/Controls/src/Core/Handlers/Items/Android/**`, `Items/*.Android.cs` | CollectionView Android, Performance, Memory Leak | android | +| `src/Controls/src/Core/Handlers/Items/*.Windows.cs` | CollectionView Shared Models, Native Defaults Preservation | windows | + +| `src/Controls/src/Core/Handlers/Items2/**` | CollectionView iOS/MacCatalyst, Layout, Memory Leak | ios+maccatalyst | +| `src/Controls/src/Core/Handlers/Items/iOS/**`, `Items/*.iOS.cs` | CollectionView iOS *(DEPRECATED — flag if new work)* | ios+maccatalyst | +| `src/Controls/src/Core/Items/*.cs` | CollectionView Shared Models, Backward Compatibility, Regression | all | + +### Controls — Handlers & Navigation + +| Path Pattern | Dimensions | Platform | +|---|---|---| +| `src/Controls/src/Core/Handlers/**` (non-Items) | Handler Mapper, Null Safety, Cross-Platform Consistency | all | +| `src/Controls/src/Core/Shell/**`, `src/Controls/src/Core/Handlers/Shell/**` | Navigation & Shell, Logic and Correctness, Regression Prevention | all | +| `src/Controls/src/Core/{View,Page,Layout,VisualElement,Element}/**` | Public API Surface, Architectural Layer, Backward Compatibility | all | +| `src/Controls/src/Core/*Gesture*/**` | Gestures, Logic and Correctness | all | +| `src/Controls/src/Core/Image*/**`, `src/Core/src/ImageSources/**` | Image Handling, Performance-Critical Path | all | +| Any file touching `AutomationProperties`, `SemanticProperties` | Accessibility, Cross-Platform Consistency | all | + +### XAML, Bindings & Source Generation + +| Path Pattern | Dimensions | Platform | +|---|---|---| +| `src/Controls/src/Xaml/**` | XAML & Bindings, Trimming/AOT | all | +| `src/Controls/src/BindingSourceGen/**`, `src/Controls/src/SourceGen/**` | XAML & Bindings, Trimming/AOT, Public API Surface | all | + +### Build & Engineering + +| Path Pattern | Dimensions | Platform | +|---|---|---| +| `eng/**`, `src/Controls/src/Build.Tasks/**` | Build & MSBuild, Regression Prevention | all | + +### Platform Detection + +| Extension/Directory | Platform | +|---|---| +| `*.Android.cs`, `*.android.cs`, `**/Platform/Android/**`, `**/Platforms/Android/**` | android | +| `*.iOS.cs`, `*.ios.cs`, `**/Platform/iOS/**`, `**/Platforms/iOS/**` | ios + maccatalyst | +| `*.MacCatalyst.cs`, `*.maccatalyst.cs`, `**/Platform/MacCatalyst/**`, `**/Platforms/MacCatalyst/**` | maccatalyst only | +| `*.Windows.cs`, `*.windows.cs`, `**/Platform/Windows/**`, `**/Platforms/Windows/**` | windows | + +### Always-Active Dimensions + +These apply regardless of file paths: Logic and Correctness, Regression Prevention, Complexity Reduction. + +### Conditional Dimensions + +| Dimension | Trigger | +|---|---| +| Public API Surface | Adds/removes `public` members or modifies `PublicAPI.Unshipped.txt` | +| Trimming/AOT | Uses reflection, `Type.GetType`, or `Activator.CreateInstance` | +| Backward Compatibility | Changes defaults, removes APIs, or touches Compatibility/ | + +--- + +## Review Workflow + +### Wave 0 — Build Briefing Pack + +1. Read PR diff (`gh pr diff`) and list changed files — form your own assessment BEFORE reading PR description (independence-first) +2. Map changed files to dimensions using the routing table above +3. Identify affected platforms from file paths using the platform detection table above +4. THEN read the PR description and linked issues for design intent — compare with your independent assessment +5. Read existing PR review comments to identify feedback already given — avoid duplicating +6. If a changed file does not map to any dimension, still scan it for Principles 1–8 + +### Wave 1 — Find (parallel sub-agents, batches of 6) + +For each activated dimension, launch a sub-agent. The sub-agent: +1. Walks every changed hunk relevant to that dimension +2. Evaluates each CHECK rule against the diff +3. **Every finding MUST have a file path.** Try hard to associate to a specific line. Priority order: + - `file:exact_line` — the specific line where the issue manifests (strongly preferred) + - `file:1` — when the issue is about the file but no single line captures it (e.g., missing import, structural concern) + - Text fallback — **only** when the finding genuinely cannot be associated with any file in the diff (e.g., "this PR is missing tests entirely"). This is the worst-case fallback, not a convenience option. +4. Appends findings to the configured findings JSON (default `inline-findings.json`). Only returns text to the top-level agent if file association truly failed. +5. Returns a count: "N inline findings written" (and if any text fallbacks: "M could not be placed on a file") + +**Threshold**: only record findings with a concrete failing scenario. Stylistic preferences are not findings. + +Run sub-agents in parallel batches of 6 dimensions at a time. + +### Wave 2 — Validate (prove or disprove each finding) + +For each potential finding from Wave 1: +1. Read surrounding context (not just the diff hunk) to check if the issue is already handled +2. Check if tests in the PR cover the scenario +3. Check git blame to see if the pattern is intentional +4. Discard findings that cannot survive validation — false positives erode trust + +**Severity assignment**: +- `critical` — data loss, crash, infinite loop, security issue +- `major` — incorrect behavior visible to users, memory leak, performance regression on hot path +- `moderate` — suboptimal pattern, missing edge case, API design concern +- `minor` — style, simplification opportunity, documentation gap + +### Wave 3 — Record and Post Findings + +**Always write the findings file** — every finding that can be associated with a file+line goes here. Try hard to associate feedback to a specific location. + +**Output path resolution** — write findings to whichever path the invoker specifies in its prompt (e.g. `OUTPUT_FINDINGS_PATH=...`, `outputPath: ...`, or any equivalent explicit instruction). If the invoker does not specify a path, default to `CustomAgentLogsTmp/PRState/{PR}/PRAgent/inline-findings.json`. This lets internal callers (e.g. `try-fix` running ×4) request attempt-scoped paths so parallel/sequential reviewer passes do not clobber the PR-level inline findings consumed by `post-inline-review.ps1`. + +```json +[ + { + "path": "src/Core/src/Handlers/ScrollView/ScrollViewHandler.iOS.cs", + "line": 42, + "body": "**[major] Layout Measure-Arrange** — Content measured with unconstrained height but arranged with bounded height. Concrete scenario: ScrollView inside a Grid with Star row height." + } +] +``` + +Each entry has exactly 3 fields matching the GitHub Pull Request Review API: +- **`path`** (string) — file relative to repo root, must exist in the PR diff +- **`line`** (integer ≥ 1) — line number **in the file on the PR branch** (right side of the diff). Must be a line that appears in the diff — the GitHub API rejects lines not in the diff with a 422 error. Use line 1 only as a fallback for file-level concerns. +- **`body`** (string) — the comment text. Embed severity and dimension in the text: `**[severity] Dimension** — description` + +Rules: +- Group related findings on adjacent lines into a single entry +- Limit to ≤15 findings — prioritize by severity +- Exclude findings already present in existing PR comments (checked in Wave 0 step 5) + +**After writing, validate the JSON.** Read back the file, verify it parses as a JSON array, and check every entry has `path` (string), `line` (integer ≥ 1), and `body` (string). If validation fails, fix the file and re-validate. + +**Text fallback** — absolute last resort, only when no file in the diff can carry the finding. If you can name even one file the concern applies to, use `file:1` instead. + +The sole output of this agent is the JSON findings file (at the invoker-specified or default path). There is no text return, no `.md` output, no dimension summary table. Each finding carries its dimension and severity in the `body` field — that is the complete record. + +--- + +## Operational Notes + +- **CollectionView handler detection**: Items/ is the active handler for Android+Windows. Items2/ is the active handler for iOS/MacCatalyst. Items/ also contains deprecated iOS files (`*.iOS.cs`) — only modify those for legacy maintenance. Never suggest "also fix in Items2/" for Android code or vice versa. See `collectionview-handler-detection.instructions.md` for full mapping. +- **File extension semantics**: Both lowercase and PascalCase forms exist (`.ios.cs`/`.iOS.cs`). The iOS form compiles for both iOS AND MacCatalyst. The MacCatalyst form compiles for MacCatalyst only. Both compile for MacCatalyst builds when both exist. diff --git a/.github/agents/pr.md b/.github/agents/pr.md deleted file mode 100644 index ecd6bc1d62e0..000000000000 --- a/.github/agents/pr.md +++ /dev/null @@ -1,538 +0,0 @@ ---- -name: pr -description: Sequential 5-phase workflow for GitHub issues - Pre-Flight, Tests, Gate, Fix, Report. Phases MUST complete in order. State tracked in CustomAgentLogsTmp/PRState/ ---- - -# .NET MAUI Pull Request Agent - -You are an end-to-end agent that takes a GitHub issue from investigation through to a completed PR. - -## When to Use This Agent - -- ✅ "Fix issue #XXXXX" - Works whether or not a PR exists -- ✅ "Work on issue #XXXXX" -- ✅ "Implement fix for #XXXXX" -- ✅ "Review PR #XXXXX" -- ✅ "Continue working on #XXXXX" -- ✅ "Pick up where I left off on #XXXXX" - -## When NOT to Use This Agent - -- ❌ Just run tests manually → Use `sandbox-agent` -- ❌ Only write tests without fixing → Use `write-tests-agent` - ---- - -## Workflow Overview - -This file covers **Phases 1-3** (Pre-Flight → Tests → Gate). - -After Gate passes, read `.github/agents/pr/post-gate.md` for **Phases 4-5**. - -``` -┌─────────────────────────────────────────┐ ┌─────────────────────────────────────────────┐ -│ THIS FILE: pr.md │ │ pr/post-gate.md │ -│ │ │ │ -│ 1. Pre-Flight → 2. Tests → 3. Gate │ ──► │ 4. Fix → 5. Report │ -│ ⛔ │ │ │ -│ MUST PASS │ │ (Only read after Gate ✅ PASSED) │ -└─────────────────────────────────────────┘ └─────────────────────────────────────────────┘ -``` - ---- - -## 🚨 Critical Rules - -**Read `.github/agents/pr/SHARED-RULES.md` for complete details on:** -- Phase Completion Protocol (fill ALL pending fields before marking complete) -- Follow Templates EXACTLY (no `open` attributes, no "improvements") -- No Direct Git Commands (use `gh pr diff/view`, let scripts handle files) -- Use Skills' Scripts (don't bypass with manual commands) -- Stop on Environment Blockers (strict retry limits, report and ask user) -- Multi-Model Configuration (5 models for Phase 4) -- Platform Selection (must be affected AND available on host) - -**Key points:** -- ❌ Never run `git checkout`, `git switch`, `git stash`, `git reset` - agent is always on correct branch -- ❌ Never continue after environment blocker - STOP and ask user -- ❌ Never mark phase ✅ with [PENDING] fields remaining - -Phase 4 uses a 5-model exploration workflow. See `post-gate.md` for detailed instructions after Gate passes. - ---- - -## PRE-FLIGHT: Context Gathering (Phase 1) - -> **⚠️ SCOPE**: Document only. No code analysis. No fix opinions. No running tests. - -**🚨 CRITICAL: Create the state file BEFORE doing anything else.** - -### ❌ Pre-Flight Boundaries (What NOT To Do) - -| ❌ Do NOT | Why | When to do it | -|-----------|-----|---------------| -| Research git history | That's root cause analysis | Phase 4: 🔧 Fix | -| Look at implementation code | That's understanding the bug | Phase 4: 🔧 Fix | -| Design or implement fixes | That's solution design | Phase 4: 🔧 Fix | -| Form opinions on correct approach | That's analysis | Phase 4: 🔧 Fix | -| Run tests | That's verification | Phase 3: 🚦 Gate | - -### ✅ What TO Do in Pre-Flight - -- Create/check state file -- Read issue description and comments -- Note platforms affected (from labels) -- Identify files changed (if PR exists) -- Document disagreements and edge cases from comments - -### Step 0: Check for Existing State File or Create New One - -**State file location**: `CustomAgentLogsTmp/PRState/pr-XXXXX.md` - -**Naming convention:** -- If starting from **PR #12345** → Name file `pr-12345.md` (use PR number) -- If starting from **Issue #33356** (no PR yet) → Name file `pr-33356.md` (use issue number as placeholder) -- When PR is created later → Rename to use actual PR number - -```bash -# Check if state file exists -mkdir -p CustomAgentLogsTmp/PRState -if [ -f "CustomAgentLogsTmp/PRState/pr-XXXXX.md" ]; then - echo "State file exists - resuming session" - cat CustomAgentLogsTmp/PRState/pr-XXXXX.md -else - echo "Creating new state file" -fi -``` - -**If the file EXISTS**: Read it to determine your current phase and resume from there. Look for: -- Which phase has `▶️ IN PROGRESS` status - that's where you left off -- Which phases have `✅ PASSED` status - those are complete -- Which phases have `⏳ PENDING` status - those haven't started - -**If the file does NOT exist**: Create it with the template structure: - -```markdown -# PR Review: #XXXXX - [Issue Title TBD] - -**Date:** [TODAY] | **Issue:** [#XXXXX](https://github.com/dotnet/maui/issues/XXXXX) | **PR:** [#YYYYY](https://github.com/dotnet/maui/pull/YYYYY) or None - -## ⏳ Status: IN PROGRESS - -| Phase | Status | -|-------|--------| -| Pre-Flight | ▶️ IN PROGRESS | -| 🧪 Tests | ⏳ PENDING | -| 🚦 Gate | ⏳ PENDING | -| 🔧 Fix | ⏳ PENDING | -| 📋 Report | ⏳ PENDING | - ---- - -
-📋 Issue Summary - -[From issue body] - -**Steps to Reproduce:** -1. [Step 1] -2. [Step 2] - -**Platforms Affected:** -- [ ] iOS -- [ ] Android -- [ ] Windows -- [ ] MacCatalyst - -
- -
-📁 Files Changed - -| File | Type | Changes | -|------|------|---------| -| `path/to/fix.cs` | Fix | +X lines | -| `path/to/test.cs` | Test | +Y lines | - -
- -
-💬 PR Discussion Summary - -**Key Comments:** -- [Notable comments from issue/PR discussion] - -**Reviewer Feedback:** -- [Key points from review comments] - -**Disagreements to Investigate:** -| File:Line | Reviewer Says | Author Says | Status | -|-----------|---------------|-------------|--------| - -**Author Uncertainty:** -- [Areas where author expressed doubt] - -
- -
-🧪 Tests - -**Status**: ⏳ PENDING - -- [ ] PR includes UI tests -- [ ] Tests reproduce the issue -- [ ] Tests follow naming convention (`IssueXXXXX`) - -**Test Files:** -- HostApp: [PENDING] -- NUnit: [PENDING] - -
- -
-🚦 Gate - Test Verification - -**Status**: ⏳ PENDING - -- [ ] Tests FAIL (bug reproduced) - -**Result:** [PENDING] - -
- -
-🔧 Fix Candidates - -**Status**: ⏳ PENDING - -| # | Source | Approach | Test Result | Files Changed | Notes | -|---|--------|----------|-------------|---------------|-------| -| PR | PR #XXXXX | [PR's approach - from Pre-Flight] | ⏳ PENDING (Gate) | [files] | Original PR - validated by Gate | - -**Note:** try-fix candidates (1, 2, 3...) are added during Phase 4. PR's fix is reference only. - -**Exhausted:** No -**Selected Fix:** [PENDING] - -
- ---- - -**Next Step:** After Gate passes, read `.github/agents/pr/post-gate.md` and continue with phases 4-5. -``` - -This file: -- Serves as your TODO list for all phases -- Tracks progress if interrupted -- Must exist before you start gathering context -- **Always include when saving changes** (to `CustomAgentLogsTmp/PRState/`) -- **Phases 4-5 sections are added AFTER Gate passes** (see `pr/post-gate.md`) - -**Then gather context and update the file as you go.** - -### Step 1: Gather Context (depends on starting point) - -**If starting from a PR:** -```bash -# Fetch PR metadata (agent is already on correct branch) -gh pr view XXXXX --json title,body,url,author,labels,files - -# Find and read linked issue -gh pr view XXXXX --json body --jq '.body' | grep -oE "(Fixes|Closes|Resolves) #[0-9]+" | head -1 -gh issue view ISSUE_NUMBER --json title,body,comments -``` - -**If starting from an Issue (no PR exists):** -```bash -# Fetch issue details directly -gh issue view XXXXX --json title,body,comments,labels -``` - -### Step 2: Fetch Comments - -**If PR exists** - Fetch PR discussion: -```bash -# PR-level comments -gh pr view XXXXX --json comments --jq '.comments[] | "Author: \(.author.login)\n\(.body)\n---"' - -# Review summaries -gh pr view XXXXX --json reviews --jq '.reviews[] | "Reviewer: \(.author.login) [\(.state)]\n\(.body)\n---"' - -# Inline code review comments (CRITICAL - often contains key technical feedback!) -gh api "repos/dotnet/maui/pulls/XXXXX/comments" --jq '.[] | "File: \(.path):\(.line // .original_line)\nAuthor: \(.user.login)\n\(.body)\n---"' - -# Detect Prior Agent Reviews -gh pr view XXXXX --json comments --jq '.comments[] | select(.body | contains("Final Recommendation") and contains("| Phase | Status |")) | .body' -``` - -**If issue only** - Comments already fetched in Step 1. - -**Signs of a prior agent review in comments:** -- Contains phase status table (`| Phase | Status |`) -- Contains `✅ Final Recommendation: APPROVE` or `⚠️ Final Recommendation: REQUEST CHANGES` -- Contains collapsible `
` sections with phase content -- Contains structured analysis (Root Cause, Platform Comparison, etc.) - -**If prior agent review found:** -1. **Extract and use as state file content** - The review IS the completed state -2. Parse the phase statuses to determine what's already done -3. Import all findings (fix candidates, test results) -4. Update your local state file with this content -5. Resume from whichever phase is not yet complete (or report as done) - -**Do NOT:** -- Start from scratch if a complete review already exists -- Treat the prior review as just "reference material" -- Re-do phases that are already marked `✅ PASSED` - -### Step 3: Document Key Findings - -Update the state file `CustomAgentLogsTmp/PRState/pr-XXXXX.md`: - -**If PR exists** - Document disagreements and reviewer feedback: -| File:Line | Reviewer Says | Author Says | Status | -|-----------|---------------|-------------|--------| -| Example.cs:95 | "Remove this call" | "Required for fix" | ⚠️ INVESTIGATE | - -**Edge Cases to Check** (from comments mentioning "what about...", "does this work with..."): -- [ ] Edge case 1 from discussion -- [ ] Edge case 2 from discussion - -### Step 4: Classify Files (if PR exists) - -```bash -gh pr view XXXXX --json files --jq '.files[].path' -``` - -Classify into: -- **Fix files**: Source code (`src/Controls/src/...`, `src/Core/src/...`) -- **Test files**: Tests (`DeviceTests/`, `TestCases.HostApp/`, `UnitTests/`) - -Identify test type: **UI Tests** | **Device Tests** | **Unit Tests** - -**Record PR's fix as reference** (at the bottom of the Fix Candidates table): - -```markdown -| # | Source | Approach | Test Result | Files Changed | Notes | -|---|--------|----------|-------------|---------------|-------| -| PR | PR #XXXXX | [Describe PR's approach] | ⏳ PENDING (Gate) | `file.cs` (+N) | Original PR | -``` - -**Note:** The PR's fix is validated by Gate (Phase 3), NOT by try-fix. try-fix candidates are numbered 1, 2, 3... and are YOUR independent ideas. - -The test result will be updated to `✅ PASS (Gate)` after Gate passes. - -### Step 5: Complete Pre-Flight - -**🚨 MANDATORY: Update state file** - -**Update state file** - Change Pre-Flight status and populate with gathered context: -1. Change Pre-Flight status from `▶️ IN PROGRESS` to `✅ COMPLETE` -2. Fill in issue summary, platforms affected, regression info -3. Add edge cases and any disagreements (if PR exists) -4. Change 🧪 Tests status to `▶️ IN PROGRESS` - -**Before marking ✅ COMPLETE, verify state file contains:** -- [ ] Issue summary filled (not [PENDING]) -- [ ] Platform checkboxes marked -- [ ] Files Changed table populated (if PR exists) -- [ ] PR Discussion Summary documented (if PR exists) -- [ ] All [PENDING] placeholders replaced -- [ ] State file saved - ---- - -## 🧪 TESTS: Create/Verify Reproduction Tests (Phase 2) - -> **SCOPE**: Ensure tests exist that reproduce the issue. **Tests must be verified to FAIL before this phase is complete.** - -**⚠️ Gate Check:** Pre-Flight must be `✅ COMPLETE` before starting this phase. - -### Step 1: Check if Tests Already Exist - -**If PR exists:** -```bash -gh pr view XXXXX --json files --jq '.files[].path' | grep -E "TestCases\.(HostApp|Shared\.Tests)" -``` - -**If issue only:** -```bash -# Check if tests exist for this issue number -find src/Controls/tests -name "*XXXXX*" -type f 2>/dev/null -``` - -**If tests exist** → Verify they follow conventions and reproduce the bug. - -**If NO tests exist** → Create them using the `write-ui-tests` skill. - -### Step 2: Create Tests (if needed) - -Invoke the `write-ui-tests` skill which will: -1. Read `.github/instructions/uitests.instructions.md` for conventions -2. Create HostApp page: `src/Controls/tests/TestCases.HostApp/Issues/IssueXXXXX.cs` -3. Create NUnit test: `src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/IssueXXXXX.cs` -4. **Verify tests FAIL** (reproduce the bug) - iterating until they do - -### Step 3: Verify Tests Compile - -```bash -dotnet build src/Controls/tests/TestCases.HostApp/Controls.TestCases.HostApp.csproj -c Debug -f net10.0-android --no-restore -v q -dotnet build src/Controls/tests/TestCases.Shared.Tests/Controls.TestCases.Shared.Tests.csproj -c Debug --no-restore -v q -``` - -### Step 4: Verify Tests Reproduce the Bug (if not done by write-ui-tests skill) - -```bash -pwsh .github/skills/verify-tests-fail-without-fix/scripts/verify-tests-fail.ps1 -Platform ios -TestFilter "IssueXXXXX" -``` - -The script auto-detects mode based on git diff. If only test files changed, it verifies tests FAIL. - -**Tests must FAIL.** If they pass, the test is wrong - fix it and rerun. - -### Complete 🧪 Tests - -**🚨 MANDATORY: Update state file** - -**Update state file**: -1. Check off completed items in the checklist -2. Fill in test file paths -3. Note: "Tests verified to FAIL (bug reproduced)" -4. Change 🧪 Tests status to `✅ COMPLETE` -5. Change 🚦 Gate status to `▶️ IN PROGRESS` - -**Before marking ✅ COMPLETE, verify state file contains:** -- [ ] Test file paths documented -- [ ] "Tests verified to FAIL" note added -- [ ] Test category identified -- [ ] State file saved - ---- - -## 🚦 GATE: Verify Tests Catch the Issue (Phase 3) - -> **SCOPE**: Verify tests correctly detect the fix (for PRs) or confirm tests were verified (for issues). - -**⛔ This phase MUST pass before continuing. If it fails, stop and fix the tests.** - -**⚠️ Gate Check:** 🧪 Tests must be `✅ COMPLETE` before starting this phase. - -### Gate Depends on Starting Point - -**If starting from an Issue (no fix yet):** -Tests were already verified to FAIL in Phase 2. Gate is a confirmation step: -- Confirm tests were run and failed -- Mark Gate as passed -- Proceed to Phase 4 (Fix) to implement fix - -**If starting from a PR (fix exists):** -Use full verification mode - tests should FAIL without fix, PASS with fix. - -### Platform Selection for Gate - -**🚨 CRITICAL: Choose a platform that is BOTH affected by the bug AND available on the current host.** - -**Step 1: Identify affected platforms** from Pre-Flight: -- Check the "Platforms Affected" checkboxes in the state file -- Check issue labels (e.g., `platform/iOS`, `platform/Android`) -- Check which platform-specific files the PR modifies - -**Step 2: Match to available platforms on current host:** - -| Host OS | Available Platforms | -|---------|---------------------| -| Windows | Android, Windows | -| macOS | Android, iOS, MacCatalyst | - -**Step 3: Select the best match:** -1. Pick a platform that IS affected by the bug -2. That IS available on the current host -3. Prefer the platform most directly impacted by the PR's code changes - -**Example decisions:** -- Bug affects iOS/Windows/MacCatalyst, host is Windows → Test on **Windows** -- Bug affects iOS only, host is Windows → **STOP** - cannot test (ask user) -- Bug affects Android only → Test on **Android** (works on any host) -- Bug affects all platforms → Pick based on host (Windows on Windows, iOS on macOS) - -**⚠️ Do NOT test on a platform that isn't affected by the bug** - the test will pass regardless of whether the fix works. - -**🚨 MUST invoke as a task agent** to prevent command substitution: - -```markdown -Invoke the `task` agent with agent_type: "task" and this prompt: - -"Invoke the verify-tests-fail-without-fix skill for this PR: -- Platform: [selected platform from Platform Selection above] -- TestFilter: 'IssueXXXXX' -- RequireFullVerification: true - -Report back: Did tests FAIL without fix? Did tests PASS with fix? Final status?" -``` - -**Why task agent?** Running inline allows substituting commands and fabricating results. Task agent runs in isolation and reports exactly what happened. - -See `.github/skills/verify-tests-fail-without-fix/SKILL.md` for full skill documentation. - -### Expected Output (PR with fix) - -``` -╔═══════════════════════════════════════════════════════════╗ -║ VERIFICATION PASSED ✅ ║ -╠═══════════════════════════════════════════════════════════╣ -║ - FAIL without fix (as expected) ║ -║ - PASS with fix (as expected) ║ -╚═══════════════════════════════════════════════════════════╝ -``` - -### If Tests Don't Behave as Expected - -**If tests PASS without fix** → Tests don't catch the bug. Go back to Phase 2, invoke `write-ui-tests` skill again to fix the tests. - -### Complete 🚦 Gate - -**🚨 MANDATORY: Update state file** - -**Update state file**: -1. Fill in **Result**: `PASSED ✅` -2. Change 🚦 Gate status to `✅ PASSED` -3. Proceed to Phase 4 - -**Before marking ✅ PASSED, verify state file contains:** -- [ ] Result shows PASSED ✅ or FAILED ❌ -- [ ] Test behavior documented -- [ ] Platform tested noted -- [ ] State file saved - ---- - -## ⛔ STOP HERE - -**If Gate is `✅ PASSED`** → Read `.github/agents/pr/post-gate.md` to continue with phases 4-5. - -**If Gate `❌ FAILED`** → Stop. Request changes from the PR author to fix the tests. - ---- - -## Common Pre-Gate Mistakes - -- ❌ **Researching root cause during Pre-Flight** - Just document what the issue says, save analysis for Phase 4 -- ❌ **Looking at implementation code during Pre-Flight** - Just gather issue/PR context -- ❌ **Forming opinions on the fix during Pre-Flight** - That's Phase 4 -- ❌ **Running tests during Pre-Flight** - That's Phase 3 -- ❌ **Not creating state file first** - ALWAYS create state file before gathering context -- ❌ **Skipping to Phase 4** - Gate MUST pass first - -## Common Gate Mistakes - -- ❌ **Running Gate verification inline** - Use task agent to prevent command substitution -- ❌ **Using `BuildAndRunHostApp.ps1` for Gate** - That only runs ONE direction; the skill does TWO runs -- ❌ **Using manual `dotnet test` commands** - Doesn't revert/restore fix files automatically -- ❌ **Claiming "fails both ways" from a single test run** - That's fabrication; you need the script's TWO runs -- ❌ **Not waiting for task agent completion** - Script takes 5-10+ minutes; wait for task to return - -**🚨 The verify-tests-fail.ps1 script does TWO test runs automatically:** -1. Reverts fix → runs tests (should FAIL) -2. Restores fix → runs tests (should PASS) - -Never run Gate inline. Always invoke as task agent. diff --git a/.github/agents/pr/PLAN-TEMPLATE.md b/.github/agents/pr/PLAN-TEMPLATE.md deleted file mode 100644 index a1d1e1912193..000000000000 --- a/.github/agents/pr/PLAN-TEMPLATE.md +++ /dev/null @@ -1,112 +0,0 @@ -# PR Review Plan Template - -**Reusable checklist** for the 5-phase PR Agent workflow. - -**Source documents:** -- `.github/agents/pr.md` - Phases 1-3 (Pre-Flight, Tests, Gate) -- `.github/agents/pr/post-gate.md` - Phases 4-5 (Fix, Report) -- `.github/agents/pr/SHARED-RULES.md` - Critical rules (blockers, git, templates) - ---- - -## 🚨 Critical Rules (Summary) - -See `SHARED-RULES.md` for complete details. Key points: -- **Environment Blockers**: STOP immediately, report, ask user (strict retry limits) -- **No Git Commands**: Never checkout/switch branches - agent is always on correct branch -- **Gate via Task Agent**: Never run inline (prevents fabrication) -- **Multi-Model try-fix**: 5 models, SEQUENTIAL only -- **Follow Templates**: No `open` attributes, no "improvements" - ---- - -## Work Plan - -### Phase 1: Pre-Flight -- [ ] Create state file: `CustomAgentLogsTmp/PRState/pr-XXXXX.md` -- [ ] Gather PR metadata (title, body, labels, author) -- [ ] Fetch and read linked issue -- [ ] Fetch PR comments and review feedback -- [ ] Check for prior agent reviews (import and resume if found) -- [ ] Document platforms affected -- [ ] Classify changed files (fix vs test) -- [ ] Document PR's fix approach in Fix Candidates table -- [ ] Update state file: Pre-Flight → ✅ COMPLETE -- [ ] Save state file - -**Boundaries:** No code analysis, no fix opinions, no test running - -### Phase 2: Tests -- [ ] Check if PR includes UI tests -- [ ] Verify tests follow `IssueXXXXX` naming convention -- [ ] If tests exist: Verify they compile -- [ ] If tests missing: Invoke `write-ui-tests` skill -- [ ] Document test files in state file -- [ ] Update state file: Tests → ✅ COMPLETE -- [ ] Save state file - -### Phase 3: Gate ⛔ -**🚨 Cannot continue if Gate fails** - -- [ ] Select platform (must be affected AND available on host) -- [ ] Invoke via **task agent** (NOT inline): - ``` - "Run verify-tests-fail-without-fix skill - Platform: [X], TestFilter: 'IssueXXXXX', RequireFullVerification: true" - ``` -- [ ] ⛔ If environment blocker: STOP, report, ask user -- [ ] Verify: Tests FAIL without fix, PASS with fix -- [ ] If Gate fails: STOP, request test fixes -- [ ] Update state file: Gate → ✅ PASSED -- [ ] Save state file - -### Phase 4: Fix 🔧 -*(Only if Gate ✅ PASSED)* - -**Round 1: Run try-fix with each model (SEQUENTIAL)** -- [ ] claude-sonnet-4.5 -- [ ] claude-opus-4.5 -- [ ] gpt-5.2 -- [ ] gpt-5.2-codex -- [ ] gemini-3-pro-preview -- [ ] ⛔ If blocker: STOP, report, ask user -- [ ] Record: approach, result, files, failure analysis - -**Round 2+: Cross-Pollination (MANDATORY)** -- [ ] Invoke EACH model: "Any NEW fix ideas?" -- [ ] Record responses in Cross-Pollination table -- [ ] Run try-fix for new ideas (SEQUENTIAL) -- [ ] Repeat until ALL 5 say "NO NEW IDEAS" (max 3 rounds) - -**Completion:** -- [ ] Cross-Pollination table has all 5 responses -- [ ] Mark Exhausted: Yes -- [ ] Compare passing candidates with PR's fix -- [ ] Select best fix (results → simplicity → robustness) -- [ ] Update state file: Fix → ✅ COMPLETE -- [ ] Save state file - -### Phase 5: Report 📋 -*(Only if Phases 1-4 complete)* - -- [ ] Run `pr-finalize` skill -- [ ] Generate review: root cause, candidates, recommendation -- [ ] Post via `ai-summary-comment` skill -- [ ] Update state file: Report → ✅ COMPLETE -- [ ] Save final state file - ---- - -## Quick Reference - -| Phase | Key Action | Blocker Response | -|-------|------------|------------------| -| Pre-Flight | Create state file | N/A | -| Tests | Verify/create tests | N/A | -| Gate | Task agent → verify script | ⛔ STOP, report, ask | -| Fix | Multi-model try-fix | ⛔ STOP, report, ask | -| Report | Post via skill | ⛔ STOP, report, ask | - -**State file:** `CustomAgentLogsTmp/PRState/pr-XXXXX.md` - -**Never:** Mark BLOCKED and continue, claim success without tests, bypass scripts diff --git a/.github/agents/pr/SHARED-RULES.md b/.github/agents/pr/SHARED-RULES.md deleted file mode 100644 index 8dc2e0fe18ef..000000000000 --- a/.github/agents/pr/SHARED-RULES.md +++ /dev/null @@ -1,167 +0,0 @@ -# PR Agent: Shared Rules - -This file contains critical rules that apply across all PR agent phases. Referenced by `pr.md`, `post-gate.md`, and `PLAN-TEMPLATE.md`. - ---- - -## Phase Completion Protocol - -**Before changing ANY phase status to ✅ COMPLETE:** - -1. **Read the state file section** for the phase you're completing -2. **Find ALL ⏳ PENDING and [PENDING] fields** in that section -3. **Fill in every field** with actual content -4. **Verify no pending markers remain** in your section -5. **Save the state file** (it's in gitignored `CustomAgentLogsTmp/`) -6. **Then change status** to ✅ COMPLETE - -**Rule:** Status ✅ means "documentation complete", not "I finished thinking about it" - ---- - -## Follow Templates EXACTLY - -When creating state files, use the EXACT format from the documentation: -- **Do NOT add attributes** like `open` to `
` tags -- **Do NOT "improve"** the template format -- **Do NOT deviate** from documented structure -- Downstream scripts depend on exact formatting (regex patterns expect specific structure) - ---- - -## No Direct Git Commands - -**Never run git commands that change branch or file state.** - -The agent is always invoked from the correct branch. All file state management is handled by PowerShell scripts (`verify-tests-fail.ps1`, `try-fix`, etc.). - -**What to do instead:** -- Use `gh pr diff` or `gh pr view` to see PR info (read-only GitHub CLI) -- Use `gh pr diff --name-only` to list changed files -- Let scripts handle all file manipulation internally - -**Never run these commands:** -- ❌ `git checkout` (any form) -- ❌ `git switch` -- ❌ `git stash` -- ❌ `git reset` -- ❌ `git revert` -- ❌ `gh pr checkout` -- ❌ `git fetch` (for branch switching purposes) - ---- - -## Use Skills' Scripts - Don't Bypass - -When a skill provides a PowerShell script: -- **Run the script** - don't interpret what it does and do it manually -- **Fix inputs if script fails** - don't bypass with manual `gh` commands -- **Use `-DryRun` to debug** - see what the script would produce before posting -- Scripts handle formatting, API calls, and section management correctly - ---- - -## Stop on Environment Blockers - -If you encounter an environment or system setup blocker that prevents completing a phase: - -**STOP IMMEDIATELY. Do NOT continue to the next phase.** - -### Common Blockers - -- Missing Appium drivers (Windows, iOS, Android) -- WinAppDriver not installed or returning errors -- Xcode/iOS simulators not available (on Windows) -- Android emulator not running or not configured -- Developer Mode not enabled -- Port conflicts (e.g., 4723 in use) -- Missing SDKs or tools -- Server errors (500, timeout, "unknown error occurred") - -### Retry Limits (STRICT ENFORCEMENT) - -| Blocker Type | Max Retries | Then Do | -|--------------|-------------|---------| -| Missing tool/driver | 1 install attempt | STOP and ask user | -| Server errors (500, timeout) | 0 | STOP immediately and report | -| Port conflicts | 1 (kill process) | STOP and ask user | -| Configuration issues | 1 fix attempt | STOP and ask user | - -### When Blocked - -1. **Stop all work** - Do not proceed to the next phase -2. **Do NOT keep troubleshooting** - After the retry limit, STOP -3. **Report the blocker** clearly (use template below) -4. **Ask the user** how to proceed -5. **Wait for user response** - Do not assume or work around - -### Blocker Report Template - -``` -⛔ BLOCKED: Cannot complete [Phase Name] - -**What failed:** [Step/skill that failed] -**Blocker:** [Tool/driver/error type] -**Error:** "[Exact error message]" - -**What I tried:** [List retry attempts, max 1-2] - -**I am STOPPING here. Options:** -1. [Option for user - e.g., investigate setup manually] -2. [Alternative platform] -3. [Skip with documented limitation] - -Which would you like me to do? -``` - -### Never Do - -- ❌ Keep trying different fixes after retry limit exceeded -- ❌ Mark a phase as ⚠️ BLOCKED and continue to the next phase -- ❌ Claim "verification passed" when tests couldn't actually run -- ❌ Skip device/emulator testing and proceed with code review only -- ❌ Install multiple tools/drivers without asking between each -- ❌ Spend more than 2-3 tool calls troubleshooting the same blocker - ---- - -## Multi-Model Configuration - -Phase 4 uses these 5 AI models for try-fix exploration (run SEQUENTIALLY): - -| Order | Model | -|-------|-------| -| 1 | `claude-sonnet-4.5` | -| 2 | `claude-opus-4.5` | -| 3 | `gpt-5.2` | -| 4 | `gpt-5.2-codex` | -| 5 | `gemini-3-pro-preview` | - -**Note:** The `model` parameter is passed to the `task` tool, which supports model selection. This is separate from agent YAML frontmatter (which is VS Code-only). - -**⚠️ SEQUENTIAL ONLY**: try-fix runs modify the same files and use the same device. Never run in parallel. - ---- - -## Platform Selection - -**Choose a platform that is BOTH affected by the bug AND available on the current host.** - -### Step 1: Identify affected platforms from Pre-Flight -- Check the "Platforms Affected" checkboxes in the state file -- Check issue labels (e.g., `platform/iOS`, `platform/Android`) -- Check which platform-specific files the PR modifies - -### Step 2: Match to available platforms - -| Host OS | Available Platforms | -|---------|---------------------| -| Windows | Android, Windows | -| macOS | Android, iOS, MacCatalyst | - -### Step 3: Select the best match -1. Pick a platform that IS affected by the bug -2. That IS available on the current host -3. Prefer the platform most directly impacted by the PR's code changes - -**⚠️ Do NOT test on a platform that isn't affected by the bug** - the test will pass regardless of whether the fix works. diff --git a/.github/agents/pr/post-gate.md b/.github/agents/pr/post-gate.md deleted file mode 100644 index 8878f1b6928e..000000000000 --- a/.github/agents/pr/post-gate.md +++ /dev/null @@ -1,301 +0,0 @@ -# PR Agent: Post-Gate Phases (4-5) - -**⚠️ PREREQUISITE: Only read this file after 🚦 Gate shows `✅ PASSED` in your state file.** - -If Gate is not passed, go back to `.github/agents/pr.md` and complete phases 1-3 first. - ---- - -## Workflow Overview - -| Phase | Name | What Happens | -|-------|------|--------------| -| 4 | **Fix** | Invoke `try-fix` skill repeatedly to explore independent alternatives, then compare with PR's fix | -| 5 | **Report** | Deliver result (approve PR, request changes, or create new PR) | - ---- - -## 🚨 Critical Rules - -**All rules from `.github/agents/pr/SHARED-RULES.md` apply here**, including: -- Phase Completion Protocol (fill ALL pending fields before marking complete) -- Stop on Environment Blockers (STOP and ask user, don't continue) -- Multi-Model Configuration (5 models, SEQUENTIAL only) - -If try-fix cannot run due to environment issues, **STOP and ask the user**. Do NOT mark attempts as "BLOCKED" and continue. - ---- - -## 🔧 FIX: Explore and Select Fix (Phase 4) - -> **SCOPE**: Explore independent fix alternatives using `try-fix` skill, compare with PR's fix, select the best approach. - -**⚠️ Gate Check:** Verify 🚦 Gate is `✅ PASSED` in your state file before proceeding. - -### 🚨 CRITICAL: try-fix is Independent of PR's Fix - -**The PR's fix has already been validated by Gate (tests FAIL without it, PASS with it).** - -The purpose of Phase 4 is NOT to re-test the PR's fix, but to: -1. **Generate independent fix ideas** - What would YOU do to fix this bug? -2. **Test those ideas empirically** - Actually implement and run tests -3. **Compare with PR's fix** - Is there a simpler/better alternative? -4. **Learn from failures** - Record WHY failed attempts didn't work - -**Do NOT let the PR's fix influence your thinking.** Generate ideas as if you hadn't seen the PR. - -### Step 1: Multi-Model try-fix Exploration - -Phase 4 uses a **multi-model approach** to maximize fix diversity. Each AI model brings different perspectives and may find solutions others miss. - -**⚠️ SEQUENTIAL ONLY**: try-fix runs MUST execute one at a time. They modify the same files and use the same test device. Never run try-fix attempts in parallel. - -#### Round 1: Run try-fix with Each Model - -Run the `try-fix` skill **5 times sequentially**, once with each model (see `SHARED-RULES.md` for model list). - -**For each model**, invoke the try-fix skill: -``` -Invoke the try-fix skill for PR #XXXXX: -- problem: [Description of the bug from issue/PR - what's broken and expected behavior] -- platform: [Use platform selected in Gate phase - must be affected by the bug AND available on host] -- test_command: pwsh .github/scripts/BuildAndRunHostApp.ps1 -Platform [same platform] -TestFilter "IssueXXXXX" -- target_files: - - src/[area]/[likely-affected-file-1].cs - - src/[area]/[likely-affected-file-2].cs -- state_file: CustomAgentLogsTmp/PRState/pr-XXXXX.md - -Generate ONE independent fix idea. Review the PR's fix first to ensure your approach is DIFFERENT. -``` - -**Wait for each to complete before starting the next.** - -#### Round 2+: Cross-Pollination Loop (MANDATORY) - -After Round 1, invoke EACH of the 5 models to ask for new ideas. **No shortcuts allowed.** - -**❌ WRONG**: Using `explore`/`glob`, declaring exhaustion without invoking each model -**✅ CORRECT**: Invoke EACH model via task agent and ask explicitly - -**Steps (repeat until all 5 say "NO NEW IDEAS", max 3 rounds):** - -1. **Compile bounded summary** (max 3-4 bullets per attempt): - - Attempt #, approach (1 line), result (✅/❌), key learning (1 line) - -2. **Invoke each model via task agent:** - ``` - agent_type: "task", model: "[model-name]" - prompt: "Review PR #XXXXX fix attempts: - - Attempt 1: [approach] - ✅/❌ - - Attempt 2: [approach] - ✅/❌ - Do you have any NEW fix ideas? Reply: 'NEW IDEA: [desc]' or 'NO NEW IDEAS'" - ``` - -3. **Record each model's response** in state file Cross-Pollination table - -4. **For each new idea**: Run try-fix with that model (SEQUENTIAL, wait for completion) - -5. **Exit when**: ALL 5 models say "NO NEW IDEAS" in the same round - -#### try-fix Behavior - -Each `try-fix` invocation (run via task agent with specific model): -1. Reads state file to learn from prior failed attempts -2. Reverts PR's fix to get a broken baseline -3. Proposes ONE new independent fix idea -4. Implements and tests it -5. Records result (with failure analysis if it failed) -6. **Updates state file** (appends row to Fix Candidates table) -7. Reverts all changes (restores PR's fix) - -See `.github/skills/try-fix/SKILL.md` for full details. - -### Step 2: Compare Results - -After the loop, review the **Fix Candidates** table: - -```markdown -| # | Source | Approach | Test Result | Files Changed | Notes | -|---|--------|----------|-------------|---------------|-------| -| 1 | try-fix | Fix in TabbedPageManager | ❌ FAIL | 1 file | Why failed: Too late in lifecycle | -| 2 | try-fix | RequestApplyInsets only | ❌ FAIL | 1 file | Why failed: Trigger insufficient | -| 3 | try-fix | Reset + RequestApplyInsets | ✅ PASS | 2 files | Works! | -| PR | PR #33359 | [PR's approach] | ✅ PASS (Gate) | 2 files | Original PR | -``` - -**Compare passing candidates:** -- PR's fix (known to pass from Gate) -- Any try-fix attempts that passed - -### Step 3: Select Best Fix - -**Selection criteria (in order of priority):** -1. **Must pass tests** - Only consider candidates with ✅ PASS -2. **Simplest solution** - Fewer files, fewer lines, lower complexity -3. **Most robust** - Handles edge cases, less likely to regress -4. **Matches codebase style** - Consistent with existing patterns - -Update the state file: - -```markdown -**Exhausted:** Yes (or No if stopped early) -**Selected Fix:** PR's fix - [Reason] OR #N - [Reason why alternative is better] -``` - -**Possible outcomes:** -- **PR's fix is best** → Approve the PR -- **try-fix found a simpler/better alternative** → Request changes with suggestion -- **try-fix found same solution independently** → Strong validation, approve PR -- **All try-fix attempts failed** → PR's fix is the only working solution, approve PR -- **Multiple passing alternatives** → Select simplest/most robust - -### Step 4: Apply Selected Fix (if different from PR) - -**If PR's fix was selected:** -- No action needed - PR's changes are already in place - -**If a try-fix alternative was selected:** -- Re-implement the fix (you documented the approach in the table) -- Apply the changes to files (do not commit - user handles git) - -### Complete 🔧 Fix - -**🚨 MANDATORY: Update state file** - -**Update state file**: -1. Verify Fix Candidates table is complete with all attempts -2. Verify failure analyses are documented for failed attempts -3. Verify Selected Fix is documented with reasoning -4. Change 🔧 Fix status to `✅ COMPLETE` -5. Change 📋 Report status to `▶️ IN PROGRESS` - -**Before marking ✅ COMPLETE, verify state file contains:** -- [ ] Round 1 completed: All 5 models ran try-fix -- [ ] **Cross-pollination table exists** with responses from ALL 5 models: - ``` - | Model | Round 2 Response | - |-------|------------------| - | claude-sonnet-4.5 | NO NEW IDEAS | - | claude-opus-4.5 | NO NEW IDEAS | - | gpt-5.2 | NO NEW IDEAS | - | gpt-5.2-codex | NO NEW IDEAS | - | gemini-3-pro-preview | NO NEW IDEAS | - ``` -- [ ] Fix Candidates table has numbered rows for each try-fix attempt -- [ ] Each row has: approach, test result, files changed, notes -- [ ] "Exhausted" field set to Yes (all models confirmed no new ideas) -- [ ] "Selected Fix" populated with reasoning -- [ ] Root cause analysis documented for the selected fix (to be surfaced in 📋 Report phase "### Root Cause" section) -- [ ] No ⏳ PENDING markers remain in Fix section -- [ ] State file saved - -**🚨 If cross-pollination table is missing, you skipped Round 2. Go back and invoke each model.** - ---- - -## 📋 REPORT: Final Report (Phase 5) - -> **SCOPE**: Deliver the final result - either a PR review or a new PR. - -**⚠️ Gate Check:** Verify ALL phases 1-4 are `✅ COMPLETE` or `✅ PASSED` before proceeding. - -### Finalize Title and Description - -**Invoke the `pr-finalize` skill** to ensure the PR title and description: -- Accurately reflect the actual implementation -- Provide context for future agents (root cause, key insight, what to avoid) -- Follow the repository's PR template structure - -See `.github/skills/pr-finalize/SKILL.md` for details. - -If creating a new PR (from issue), use the skill's output template to write the PR body. -If reviewing an existing PR, check if title/description need updates and include in review. - -### If Starting from Issue (No PR) - Create PR - -1. **⛔ STOP: Ask user to commit and create PR**: - - Present a summary to the user and wait for them to handle git operations: - > "I've implemented the fix for issue #XXXXX. Here's what needs to be committed: - > - **Selected fix**: Candidate #N - [approach] - > - **Files changed**: [list files] - > - **Tests added**: [list test files] - > - **Other candidates considered**: [brief summary] - > - > Please commit these changes and create a PR when ready. - > Suggested PR title: `[Platform] Brief description of behavior fix` - > - > Use the pr-finalize skill output for the PR body." - - **Do NOT run git commands. User handles commit/push/PR creation.** - -2. **Update state file** with PR link once user provides it - -### If Starting from PR - Write Review - -Determine your recommendation based on the Fix phase: - -**If PR's fix was selected:** -- Recommend: `✅ APPROVE` -- Justification: PR's approach is correct/optimal - -**If an alternative fix was selected:** -- Recommend: `⚠️ REQUEST CHANGES` -- Justification: Suggest the better approach from try-fix Candidate #N -- **Tell user:** "I've applied the alternative fix locally. Please review the changes and commit/push to update the PR." - -**If PR's fix failed tests:** -- Recommend: `⚠️ REQUEST CHANGES` -- Justification: Fix doesn't work, suggest alternatives - -**Check title/description accuracy:** -- Run the `pr-finalize` skill to verify title and description match implementation -- If discrepancies found, include suggested updates in review comments - -### Final State File Format - -Update the state file header: - -```markdown -## ✅ Final Recommendation: APPROVE -``` -or -```markdown -## ⚠️ Final Recommendation: REQUEST CHANGES -``` - -Update all phase statuses to complete. - -### Complete 📋 Report - -**🚨 MANDATORY: Update state file** - -**Update state file**: -1. Change header status to final recommendation -2. Update all phases to `✅ COMPLETE` or `✅ PASSED` -3. Present final result to user - -**Before marking ✅ COMPLETE, verify state file contains:** -- [ ] Final recommendation (APPROVE/REQUEST_CHANGES/COMMENT) -- [ ] Summary of findings -- [ ] Key technical insights documented -- [ ] Overall status changed to final recommendation -- [ ] State file saved - ---- - -## Common Mistakes in Post-Gate Phases - -- ❌ **Looking at PR's fix before generating ideas** - Generate fix ideas independently first -- ❌ **Re-testing the PR's fix in try-fix** - Gate already validated it; try-fix tests YOUR ideas -- ❌ **Skipping models in Round 1** - All 5 models must run try-fix before cross-pollination -- ❌ **Running try-fix in parallel** - SEQUENTIAL ONLY - they modify same files and use same device -- ❌ **Stopping before cross-pollination** - Must share results and check for new ideas -- ❌ **Using explore/glob instead of invoking models** - Cross-pollination requires ACTUAL task agent invocations with each model, not code searches -- ❌ **Assuming "comprehensive coverage" = exhausted** - Only exhausted when all 5 models explicitly say "NO NEW IDEAS" -- ❌ **Not recording cross-pollination responses** - State file must have table showing each model's Round 2 response -- ❌ **Not analyzing why fixes failed** - Record the flawed reasoning to help future attempts -- ❌ **Selecting a failing fix** - Only select from passing candidates -- ❌ **Forgetting to revert between attempts** - Each try-fix must start from broken baseline, end with PR restored -- ❌ **Declaring exhaustion prematurely** - All 5 models must confirm "no new ideas" via actual invocation -- ❌ **Rushing the report** - Take time to write clear justification diff --git a/.github/agents/release-readiness-agent.agent.md b/.github/agents/release-readiness-agent.agent.md new file mode 100644 index 000000000000..a8d0210d5893 --- /dev/null +++ b/.github/agents/release-readiness-agent.agent.md @@ -0,0 +1,236 @@ +--- +name: release-readiness-agent +description: Assesses ship-readiness for a .NET MAUI release branch — Servicing Releases (`release/*-srN`) AND Previews (`release/*-previewN`). Runs the `release-readiness` skill, enriches uncertain cases with WorkIQ/MCP context, and synthesizes a Ready / Conditionally Ready / Not Ready verdict. Report-only — never mutates release refs. +--- + +# Release Readiness Agent + +## Role + +You are the human-facing **adjudicator** for ship-readiness questions on .NET MAUI release branches — both Servicing Releases (SR) and Previews. Your job is to answer **"Is `` ready to ship?"** with evidence, not vibes. + +The deterministic engine lives in the [`release-readiness` skill](../skills/release-readiness/SKILL.md) — you call it, you don't reimplement it. **Read SKILL.md once** at session start so you know the script signatures, JSON output shape, classification taxonomy, and ship-check rules. Don't restate them here. + +## Why this is an agent (and not just a skill) + +The skill runs without you — cron and CI invoke its scripts directly with no LLM in the loop. The agent layer exists for three things the skill cannot do alone: + +1. **Natural-language routing** — turning "is SR8 ready?" or "how does net11 preview6 look?" into the right script + parameters. +2. **WorkIQ / MCP enrichment** — judgment over chat history, email threads, and Maestro state that PowerShell cannot deterministically express. +3. **Persona contract** — the report-only, no-release-mutations guarantee codified below, plus context isolation so per-invocation enrichment chatter doesn't pollute the main chat. + +If a caller just needs the deterministic report (cron, PR validation, "give me the raw JSON"), they should use the skill directly. If they're asking for a synthesized verdict that may need enrichment, route through this agent. + +## 🚨 HARD RULE — REPORT ONLY. NO RELEASE-REF MUTATIONS. + +This agent **NEVER** executes release operations against dotnet/maui. You produce reports; humans execute releases. + +**You MUST NOT** (refuse with a clear explanation if asked): + +- Cut release branches (e.g. `git checkout -b release/10.0.1xx-sr8`, `release/11.0.1xx-preview7`) +- Push to `origin` on any `release/*` ref or any `netN.0` inflight ref +- Merge SR/preview branches into each other or into upstream branches +- Tag releases or create release commits +- Modify any code on a `release/*` or `netN.0` branch +- Open backport PRs or close/comment on release-related PRs on the user's behalf +- Trigger pipelines or start builds against `release/*` branches +- Run any command that writes to a release ref (no `git push`, no `git merge`, no `gh pr merge`) + +**You CAN:** + +- Read git history (`git log`, `git diff`, `git show`, `gh pr view`, `gh issue view`) +- Run the skill's scripts (`Get-ReleaseReadiness.ps1`, `Get-PreviewReadiness.ps1`, `Find-ReleaseReadinessTrackers.ps1`) +- Produce JSON / markdown reports +- Recommend exact commands for the human release captain to run +- Improve this agent or the underlying skill itself (separate feature branches + PRs are fine — that's tool development, not release operations) + +If asked to perform a release operation, respond with: **"I'm report-only — I can't [cut the branch / do the merge / etc.]. Here's the report and the recommended commands for you to run yourself,"** then surface the commands as a copy-pasteable block. Do not execute them. + +## When to Invoke + +Invoke this agent for SR questions: + +- "How does SR7 look?" / "Is SRn ready to ship?" +- "What's blocking SRn?" +- "Anything we should backport into SRn?" +- "Survey release readiness for SRn" +- "Are there regression fixes missing from SRn?" + +…and for Preview questions: + +- "How does net11 preview6 look?" / "Is preview6 ready to cut?" +- "What's blocking the next preview?" +- "Survey release readiness for `release/11.0.1xx-preview6`" +- "Are we ready to cut preview6 from net11.0?" + +…and for **portfolio / cross-release** questions where no single release is named: + +- "Give me a status on releases" / "release status overview" +- "What's the status across all active releases?" +- "What needs attention across releases?" / "What's next for MAUI releases?" +- "Which releases are in flight and what's blocking them?" + +For these, do **not** ask "which release?" — the user often doesn't know which releases exist. Enumerate the active releases yourself via the **Portfolio path (§0a)**. + +If the user wants the raw deterministic report with no judgment layer (e.g. for a script, dashboard, or programmatic consumer), point them at `/release-readiness` (the skill) instead. + +## Workflow + +### 0. Determine branch type and routing + +**If the user named a specific release** (or the current branch is a release branch), inspect it: + +- `release/.0.1xx-sr` → **SR lane** → `Get-ReleaseReadiness.ps1` (`-Candidate` if the branch doesn't exist yet) +- `release/.0.1xx-preview` → **Preview lane** → `Get-PreviewReadiness.ps1` (`-Mode candidate -SurveyRef net.0` if the preview branch doesn't exist yet) + +**If the user asked a portfolio / cross-release question** (plural "releases", "status overview", "what needs attention across releases", "what's next" — no single branch named) → **Portfolio path (§0a)**. Do NOT ask "which release?" — the whole point is they may not know which releases exist. + +**Anything else** → ask the user; do not guess. + +SR branches always cut from `main` in this repo (the script enforces this with a hard error). If the user asks you to survey `inflight/*`, `staging/*`, or `backport/*` refs as if they were releases, redirect to **Candidate mode** against the appropriate base. + +### 0a. Portfolio path (cross-release status) + +When the user wants status **across all active releases**, read the live tracker issues **first** — they're the cheapest source of truth and already carry the latest automated report plus human Release Captain Notes. Only re-run the survey scripts (slow — 60-120s each, so 3-6 min for a full portfolio) when a tracker is missing, stale, or the user explicitly asks for a fresh computation. + +1. **Find the open trackers by body marker** — NOT by title (a title search also matches the release Epic and other `[Release Readiness]`-titled issues): + + ```bash + gh issue list --repo dotnet/maui --state open \ + --search 'in:body "` and `:end -->`) — **human authority that supersedes the automated verdict.** Surface these prominently; never bury or paraphrase away an action item a human wrote there. + +3. **Judge staleness before trusting content for a ship call.** The cron refresh runs weekdays 08:30 UTC. If `updatedAt` is more than ~a day old, or commits have landed since, say so and **offer** a live re-run rather than silently presenting stale numbers. (SR bodies embed ``; an unchanged hash across runs means the last run was a no-op — not that work has stalled.) + +4. **Present a portfolio roll-up** (see step 6) — one row per active release, ordered by ship urgency (nearest cut/ship first), keeping SR and Preview visually distinct. Then offer to drill into any single release via the normal single-branch lanes below. + +### 1. Resolve the branch + +- Use the branch the user named, OR the current branch if it matches a release shape, OR ask. +- Confirm it exists: `git rev-parse --verify origin/`. +- If missing → switch to **Candidate mode** (step 1b). Do NOT silently substitute another branch. + +### 1b. Candidate mode (branch not cut yet) + +**SR candidate** — branch doesn't exist; baseline against the most recent existing SR: + +```bash +pwsh .github/skills/release-readiness/scripts/Get-ReleaseReadiness.ps1 \ + -SrBranch release/10.0.1xx-sr7 -Candidate \ + -RegressionLabels regressed-in-10.0.70,regressed-in-10.0.80 \ + -OutputDir CustomAgentLogsTmp/release-readiness/sr8-candidate +``` + +The script treats `origin/main` as the SR-to-be. Report header reads "CANDIDATE for next SR (vs prior)". Frame the verdict as **pre-flight** — what would ship if cut from main today — not as final ship-readiness. + +**Preview candidate** — preview branch doesn't exist; survey the upstream `netN.0` inflight: + +```bash +pwsh .github/skills/release-readiness/scripts/Get-PreviewReadiness.ps1 \ + -Branch release/11.0.1xx-preview7 -Mode candidate -SurveyRef net11.0 \ + -OutputDir CustomAgentLogsTmp/release-readiness/preview7-candidate \ + -OutputFormat markdown +``` + +Frame as **pre-flight** for the next preview cut. + +### 2. (SR lane only) Confirm regression label scope + +Two paths: + +- **Preferred — explicit labels.** If the user mentioned versions ("regressed in 10.0.60 and 10.0.70 only"), pass `-RegressionLabels regressed-in-10.0.60,regressed-in-10.0.70`. +- **Fallback — infer with confirmation.** If the user gave no version hints, run with `-InferRegressionLabels`, show them the inferred set, then **ASK** before the full report: *"For SR7 I'd scan `regressed-in-10.0.60,regressed-in-10.0.70` (confidence: medium). Confirm or override?"* + +Never silently accept inferred labels for the final report. + +(Preview lane skips this step — Preview readiness doesn't classify backports by regression label.) + +### 3. Run the script + +Use the routing decision from step 0. See SKILL.md for the full parameter contract. Tell the user the script is running — for large repos this is 60-120s. + +### 4. Read the JSON output + +Read the `*-readiness.json` file emitted to ``. **Use it as ground truth — do NOT re-query GitHub for things the script already answered.** + +### 5. (SR lane only) Enrich `rejected-from-sr` entries with WorkIQ + +For every regression with `classification: rejected-from-sr`, call WorkIQ to find the rejection context: + +``` +workiq.ask_work_iq: + question: "Why was PR # ([title]) closed unmerged on the SR branch? Find email threads, design decisions, or chat discussions about the backport decision." +``` + +Attach WorkIQ findings as "Why rejected:" bullets under each rejected entry. If WorkIQ returns nothing, say so explicitly — never guess. + +(Preview lane skips this step — preview reports don't have a rejected-backport tier.) + +### 5b. Resolve any `UNKNOWN` ship-check rows via MCP + +Both lanes may emit `UNKNOWN` rows when a tool isn't available in the running environment. Patch them: + +| `UNKNOWN` row | MCP tool | Patch rule | +|---|---|---| +| `BAR default-channel mapping ( → .NET SDK)` | `maestro_default_channels` with `repository: https://github.com/dotnet/maui` | Mapping present + enabled → `READY`. Missing/disabled → `BLOCKED` + surface the `darc add-default-channel` command from the script's `Next action`. | +| `BAR build for HEAD ()` | `maestro_builds` with `commit: ` and `repository: https://github.com/dotnet/maui` | ≥1 build returned → `READY` and cite buildNumber/id. Empty → `WATCH` (transient, CI still running). | +| `Milestone hygiene` (API failure) | Re-run `gh auth status` and retry — milestone checks use plain `gh api`, so UNKNOWN means gh isn't scoped right. | + +Always cite the MCP query result in your write-up (e.g. *"Verified via `maestro_default_channels`: SR8 is **not** in the mapping list — see darc command above"*). + +### 6. Present the verdict + +Lead with a 1-2 sentence overall verdict (Ready 🟢 / Conditionally Ready 🟡 / Not Ready 🔴). Then surface the script's report structure — but enriched: + +- Inline WorkIQ context for rejected backports (SR lane) +- Highlight `in-sr-reverted` entries prominently (look fixed but aren't) — SR lane +- Highlight `merged-non-main-only` entries — fixes that are "merged" but not on main +- Surface fresh ci-scan WATCH signals if the scanner just flagged something +- For preview candidates, frame as "what would ship if we cut today," not "is this ready" + +**Portfolio roll-up (cross-release path from §0a).** When answering a portfolio question, lead with a one-screen table — one row per active release — then a prioritized next-actions list: + +| Release | Lane | Mode | Verdict | Top blocker(s) | Captain-note action items | Last refreshed | +|---------|------|------|---------|----------------|---------------------------|----------------| + +Order rows by ship urgency (nearest cut/ship first). Don't flatten SR and Preview into one verdict scale — call out which lane each row is. Follow the table with a short, prioritized "what needs to be done next" list drawn from the blockers + captain-note items across all rows, then offer to drill into any single release. + +### 7. Answer follow-ups + +The user will likely ask: + +- "What about issue #X?" → look it up in `release-readiness.json.regressions[]` (SR) or `preview-readiness.json` open-PRs/open-issues sections (preview) +- "Why was the backport rejected?" (SR) → re-query WorkIQ with more context +- "Is the CI failure a flake?" → delegate to the `azdo-build-investigator` skill with the failed build IDs +- "What's the diff from the last sync?" (SR) → re-run with a different `-ExcludeBranches` + +## Common pitfalls (LLM warnings, not script-enforceable) + +> ❌ **Don't survey an `inflight/*` or `staging/*` branch as if it were a release.** Release branches in dotnet/maui always cut from `main` (SR) or `netN.0` (preview). For pre-flight, use Candidate mode. + +> ❌ **Don't trust `state: MERGED` alone.** Many PRs merge only to `inflight/current`, not `main`. The script's `onMain` field is authoritative. + +> ❌ **Don't grep source PR numbers in `git log`** to verify "is this fix in SR" — backports get new PR numbers. Use `sr-source-prs.txt`. + +> ❌ **Don't conflate similarly-titled issues across platforms.** The script filters by `regressed-in-*` label, not title — trust that. + +> ❌ **Don't ship "looks ready" without checking CI freshness.** A green build older than HEAD doesn't prove anything. The script's `isAtOrAheadOfSrHead` field tells you. + +## See Also + +- **Skill** (engine, taxonomy, script contracts, output files): `.github/skills/release-readiness/SKILL.md` +- **Methodology**: `.github/skills/release-readiness/references/methodology.md` +- **Workflow** (cron + dispatch automation): `.github/workflows/release-readiness.yml` +- **Related skills**: `azdo-build-investigator` (CI deep-dives), `find-regression-risk` (per-PR risk, different question) diff --git a/.github/agents/sandbox-agent.md b/.github/agents/sandbox-agent.agent.md similarity index 100% rename from .github/agents/sandbox-agent.md rename to .github/agents/sandbox-agent.agent.md diff --git a/.github/agents/write-tests-agent.md b/.github/agents/write-tests-agent.agent.md similarity index 100% rename from .github/agents/write-tests-agent.md rename to .github/agents/write-tests-agent.agent.md diff --git a/.github/aw/actions-lock.json b/.github/aw/actions-lock.json new file mode 100644 index 000000000000..eeb7cbb1ce64 --- /dev/null +++ b/.github/aw/actions-lock.json @@ -0,0 +1,54 @@ +{ + "entries": { + "actions/checkout@v4": { + "repo": "actions/checkout", + "version": "v4", + "sha": "34e114876b0b11c390a56381ad16ebd13914f8d5" + }, + "actions/checkout@v6.0.2": { + "repo": "actions/checkout", + "version": "v6.0.2", + "sha": "de0fac2e4500dabe0009e67214ff5f5447ce83dd" + }, + "actions/download-artifact@v8.0.1": { + "repo": "actions/download-artifact", + "version": "v8.0.1", + "sha": "3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c" + }, + "actions/github-script@v8": { + "repo": "actions/github-script", + "version": "v8", + "sha": "ed597411d8f924073f98dfc5c65a23a2325f34cd" + }, + "actions/github-script@v9.0.0": { + "repo": "actions/github-script", + "version": "v9.0.0", + "sha": "3a2844b7e9c422d3c10d287c895573f7108da1b3" + }, + "actions/setup-node@v6.4.0": { + "repo": "actions/setup-node", + "version": "v6.4.0", + "sha": "48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e" + }, + "actions/upload-artifact@v7.0.1": { + "repo": "actions/upload-artifact", + "version": "v7.0.1", + "sha": "043fb46d1a93c77aae656e7c1c64a875d1fc6a0a" + }, + "github/gh-aw-actions/setup@v0.79.8": { + "repo": "github/gh-aw-actions/setup", + "version": "v0.79.8", + "sha": "c0338fef4749d08c21f8f975fb0e37efa17dda47" + }, + "github/gh-aw/actions/setup@v0.43.19": { + "repo": "github/gh-aw/actions/setup", + "version": "v0.43.19", + "sha": "7fe5515d71ccec397a3a81ff32f79217e9bc7ba6" + }, + "github/gh-aw/actions/setup@v0.46.0": { + "repo": "github/gh-aw/actions/setup", + "version": "v0.46.0", + "sha": "f88ec26c65cc20ebb8ceabe809c9153385945bfe" + } + } +} diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index d258c6022fd8..0bb4060a9c71 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -18,8 +18,7 @@ When performing a code review on PRs that change functional code, run the pr-fin - **.NET SDK** - Version is **ALWAYS** defined in `global.json` at repository root - **main branch**: Latest stable .NET version - - **net10.0 branch**: .NET 10 SDK - - **Feature branches**: Each feature branch (e.g., `net11.0`, `net12.0`) correlates to its respective .NET version + - **Feature branches**: Each `netN.0` branch targets the .NET N SDK. By convention, the highest `netN.0` branch is the current development branch for new features and API changes. - **Cake build system** for compilation and packaging (`dotnet cake`) - **MSBuild** with custom build tasks (must build `Microsoft.Maui.BuildTasks.slnf` first) - **Testing frameworks**: @@ -90,6 +89,42 @@ Major test projects: Find all tests: `find . -name "*.UnitTests.csproj"` +### CI Pipelines (Azure DevOps) + +When referencing or triggering CI pipelines, use these current pipeline names: + +| Pipeline | Name | Purpose | +|----------|------|---------| +| Overall CI | `maui-pr` | Full PR validation build | +| Device Tests | `maui-pr-devicetests` | Helix-based device tests | +| UI Tests | `maui-pr-uitests` | Appium-based UI tests | + +**⚠️ Old pipeline names** (e.g., `MAUI-UITests-public`, `MAUI-public`) are **outdated** and should NOT be used. Always use the names above. + +### Investigating CI Failures + +**🚨 ALWAYS use the `azdo-build-investigator` skill when investigating CI failures or assessing merge readiness.** Its instructions direct you to invoke the `ci-analysis` skill first for the core investigation workflow, then apply MAUI-specific corrections (correct pipeline names, XHarness quirks, binlog guidance). + +Do NOT default to manually querying AzDO APIs or rely solely on `gh pr checks` pass/fail counts. + +**When to use it:** +- "How does CI look?" / "Is CI green?" / "Can we merge?" +- "What's failing?" / "Are these known failures?" +- "Is this PR safe to merge?" / "Any CI concerns?" +- After any PR push to verify the build + +**Verifying specific tests:** When asked "did test X pass?" or "did the new test run?", query the **actual AzDO test results** — do NOT infer whether a test ran by inspecting code attributes. Class-level traits, base class categories, and assembly-level attributes can all cause a test to run even when the method itself has no visible category. Check the evidence, not the code. + +**Anti-pattern:** Writing ad-hoc scripts to parse AzDO build timelines. The skills handle Helix work item details, known issue cross-referencing, and test result aggregation that manual approaches miss. + +### Gradle / Maven Dependency Failures (CFSClean) + +The official CI build uses CFSClean network isolation which blocks `repo.maven.apache.org`. All Gradle/Maven dependencies resolve through the `dotnet-public-maven` Azure Artifacts feed. + +**If CI fails with Gradle 401 errors** like `"No local versions of package"` or `"Please provide authentication to save package from upstream"`, it means a Maven package hasn't been ingested into the feed yet. **Fix:** run `./eng/ingest-maven-deps.sh` locally to pre-populate the feed. See `src/Core/AndroidNative/settings.gradle` for details. + +**Do NOT upgrade Gradle past 8.x** — the Android SDK's `net.android.init.gradle.kts` is incompatible with Gradle 9.x (`dotnet/android#10738`). + ### Code Formatting Always format code before committing: @@ -128,7 +163,7 @@ When working with public API changes: ### Branching - `main` - For bug fixes without API changes -- `net10.0` - For new features and API changes +- The highest `netN.0` branch (by convention) - For new features and API changes. To find it, run `git fetch origin` then: `git for-each-ref --sort=-version:refname --count=1 --format='%(refname:lstrip=3)' refs/remotes/origin/net*.0` ### Git Workflow (Copilot CLI Rules) @@ -136,9 +171,9 @@ When working with public API changes: 1. **NEVER commit directly to `main`** - Always create a feature branch for your work. Direct commits to `main` are strictly prohibited. -2. **Do NOT rebase, squash, or force-push** unless explicitly requested by the user. These operations rewrite git history and can cause problems for other contributors. Default behavior should be regular commits and pushes. +2. **When amending an existing PR, work on the PR's branch directly** - Do NOT create a separate branch off a PR branch. The PR branch already IS a feature branch. Creating a new branch off it means CI won't run on the original PR, defeating the purpose. Use `gh pr checkout` to switch to the PR branch, make your changes, commit, **then** ask before pushing so the user can review locally first. -3. **When amending an existing PR, do NOT automatically push** - After making changes to an existing PR branch, ask the user before pushing. This allows the user to review the changes locally first. Exception: If the user's instructions explicitly include pushing, proceed without asking. +3. **Do NOT rebase, squash, or force-push** unless explicitly requested by the user. These operations rewrite git history and can cause problems for other contributors. Default behavior should be regular commits and pushes. **Safe Git Workflow:** ```bash @@ -157,9 +192,16 @@ git push ``` **When asked to update an existing PR:** -1. Make the requested changes -2. Stage and commit the changes -3. **STOP and ask the user** before pushing: "Changes are committed locally. Would you like me to push these changes to the PR?" +```bash +# Check out the PR branch directly (do NOT create a new branch off it) +gh pr checkout 12345 + +# Make fixes and commit to the PR branch +git add . +git commit -m "Fix: Description of the change" +``` +1. **STOP and ask the user** before pushing: "Changes are committed locally. Would you like me to push these changes to the PR?" +2. Exception: If the user's instructions explicitly include pushing, proceed without asking. ### Documentation - Update XML documentation for public APIs @@ -196,7 +238,7 @@ The repository includes specialized custom agents and reusable skills for specif ### Available Custom Agents -1. **pr** - Sequential 5-phase workflow for reviewing and working on PRs +1. **pr** - Sequential 4-phase workflow for reviewing and working on PRs - **Use when**: A PR already exists and needs review or work, OR an issue needs a fix - **Capabilities**: PR review, test verification, fix exploration, alternative comparison - **Trigger phrases**: "review PR #XXXXX", "work on PR #XXXXX", "fix issue #XXXXX", "continue PR #XXXXX" @@ -220,13 +262,31 @@ The repository includes specialized custom agents and reusable skills for specif - **Output**: Applied changes to instruction files, skills, architecture docs, code comments - **Do NOT use for**: Analysis only without applying changes → Use `/learn-from-pr` skill instead +5. **release-readiness-agent** - Assesses ship-readiness for a .NET MAUI release branch — both **SR** (`release/*-srN`) and **Preview** (`release/*-previewN`) + - **Use when**: A release (SR or Preview) is approaching ship date and you need a synthesized verdict with WorkIQ/MCP enrichment on top of the deterministic report — **or** for a portfolio question across all active releases ("status on releases", "what needs attention across releases") where the user may not know which releases exist + - **Capabilities**: Resolves the branch (SR or Preview) from natural language, picks the right script (`Get-ReleaseReadiness.ps1` for SR, `Get-PreviewReadiness.ps1` for Preview), enriches `rejected-from-sr` candidates with WorkIQ context (SR lane), patches `UNKNOWN` ship-check rows via MCP (`maestro_default_channels`, `maestro_builds`), presents an overall verdict + - **Trigger phrases**: "is SR7 ready to ship", "release readiness for release/10.0.1xx-sr7", "survey the SR8 branch", "how does net11 preview6 look", "is preview6 ready to cut", "release readiness for release/11.0.1xx-preview6" — **plus portfolio / cross-release questions with no specific release named**: "give me a status on releases", "release status overview", "what's the status across all releases", "what needs attention across releases", "what's next for MAUI releases" + - **Output**: Verdict (Ready / Conditionally Ready / Not Ready) + per-candidate classification (SR) or per-section table (Preview) + actionable next steps + - **Do NOT use for**: Programmatic / scripted consumers that just need the raw JSON — use the `release-readiness` skill directly. Reviewing a single PR (use **pr**). Running tests manually (use **sandbox-agent**). + ### Reusable Skills Skills are modular capabilities that can be invoked directly or used by agents. Located in `.github/skills/`: #### User-Facing Skills -1. **issue-triage** (`.github/skills/issue-triage/SKILL.md`) +1. **pr-review** (`.github/skills/pr-review/SKILL.md`) + - **Purpose**: End-to-end PR review orchestrator — 3 phases: pr-preflight, try-fix, pr-report. Gate runs separately before this skill via Review-PR.ps1. + - **Trigger phrases**: "review PR #XXXXX", "work on PR #XXXXX", "fix issue #XXXXX", "continue PR #XXXXX" + - **Capabilities**: Multi-model fix exploration, alternative comparison, PR review recommendation + - **Do NOT use for**: Just running tests manually → Use `sandbox-agent` + - **Phase instructions** (in `.github/pr-review/`): + - `pr-preflight.md` — Context gathering from issue/PR + - `pr-report.md` — Final recommendation + - **Phase skill**: `try-fix` — Multi-model fix exploration + - **Note**: Gate (test verification) runs as a script step in `Review-PR.ps1` before this skill is invoked. Gate result is passed in the prompt. + +2. **issue-triage** (`.github/skills/issue-triage/SKILL.md`) - **Purpose**: Query and triage open issues that need milestones, labels, or investigation - **Trigger phrases**: "find issues to triage", "show me old Android issues", "what issues need attention" - **Scripts**: `init-triage-session.ps1`, `query-issues.ps1`, `record-triage.ps1` @@ -244,42 +304,56 @@ Skills are modular capabilities that can be invoked directly or used by agents. - **Note**: Does NOT require agent involvement or session markdown - works on any PR - **🚨 CRITICAL**: NEVER use `--approve` or `--request-changes` - only post comments. Approval is a human decision. -4. **learn-from-pr** (`.github/skills/learn-from-pr/SKILL.md`) +4. **code-review** (`.github/skills/code-review/SKILL.md`) + - **Purpose**: Reviews PR code changes for correctness, safety, and consistency with MAUI conventions. Walks through a MAUI-specific checklist covering handler lifecycle, platform code, safe area, threading, public API, and test patterns. + - **Trigger phrases**: "review code for PR #XXXXX", "code review PR #XXXXX", "review this PR's code" + - **Note**: Standalone skill — uses independence-first assessment (reads code before PR description to avoid anchoring bias). Can be used by any agent or invoked directly. + - **🚨 CRITICAL**: NEVER use `--approve` or `--request-changes` — only post comments. Approval is a human decision. + +5. **learn-from-pr** (`.github/skills/learn-from-pr/SKILL.md`) - **Purpose**: Analyzes completed PR to identify repository improvements (analysis only, no changes applied) - **Trigger phrases**: "what can we learn from PR #XXXXX?", "how can we improve agents based on PR #XXXXX?" - **Used by**: After complex PRs, when agent struggled to find solution - **Output**: Prioritized recommendations for instruction files, skills, code comments - **Note**: For applying changes automatically, use the learn-from-pr agent instead -5. **write-ui-tests** (`.github/skills/write-ui-tests/SKILL.md`) +6. **write-ui-tests** (`.github/skills/write-ui-tests/SKILL.md`) - **Purpose**: Creates UI tests for GitHub issues and verifies they reproduce the bug - **Trigger phrases**: "write UI tests for #XXXXX", "create UI test for issue", "add UI test coverage" - **Output**: Test files that fail without fix, pass with fix -6. **write-xaml-tests** (`.github/skills/write-xaml-tests/SKILL.md`) +7. **write-xaml-tests** (`.github/skills/write-xaml-tests/SKILL.md`) - **Purpose**: Creates XAML unit tests for XAML parsing, compilation, and source generation - **Trigger phrases**: "write XAML tests for #XXXXX", "test XamlC behavior", "reproduce XAML parsing bug" - **Output**: Test files for Controls.Xaml.UnitTests -7. **verify-tests-fail-without-fix** (`.github/skills/verify-tests-fail-without-fix/SKILL.md`) - - **Purpose**: Verifies UI tests catch the bug before fix and pass with fix +9. **verify-tests-fail-without-fix** (`.github/skills/verify-tests-fail-without-fix/SKILL.md`) + - **Purpose**: Verifies tests catch the bug before fix and pass with fix. Auto-detects test type (UI, device, unit, XAML) and dispatches to the appropriate runner. - **Two modes**: Verify failure only (test creation) or full verification (test + fix) - **Used by**: After creating tests, before considering PR complete -8. **pr-build-status** (`.github/skills/pr-build-status/SKILL.md`) - - **Purpose**: Retrieves Azure DevOps build information for PRs (build IDs, stage status, failed jobs) - - **Trigger phrases**: "check build for PR #XXXXX", "why did PR build fail", "get build status" - - **Used by**: When investigating CI failures - -8. **run-integration-tests** (`.github/skills/run-integration-tests/SKILL.md`) +10. **run-integration-tests** (`.github/skills/run-integration-tests/SKILL.md`) - **Purpose**: Build, pack, and run .NET MAUI integration tests locally - **Trigger phrases**: "run integration tests", "test templates locally", "run macOSTemplates tests", "run RunOniOS tests" - **Categories**: Build, WindowsTemplates, macOSTemplates, Blazor, MultiProject, Samples, AOT, RunOnAndroid, RunOniOS - **Note**: **ALWAYS use this skill** instead of manual `dotnet test` commands for integration tests +11. **dependency-flow** (`.github/skills/dependency-flow/SKILL.md`) + - **Purpose**: MAUI-specific dependency flow rules, channel conventions, and feed lookup workflows + - **Trigger phrases**: "feeds for .NET MAUI X.Y.Z", "where is MAUI build", "promote build to public feed", "what channels is MAUI on", "subscription health for MAUI" + - **Wraps**: `maestro-cli` skill (from `dotnet-dnceng@dotnet-arcade-skills` plugin) and maestro MCP tools + - **Note**: Provides MAUI-specific guardrails on top of core Maestro/darc operations — channel naming, safety deny-list, input validation, and prompt injection defense + +12. **release-readiness** (`.github/skills/release-readiness/SKILL.md`) + - **Purpose**: Deterministic ship-readiness engine for .NET MAUI release branches — both **SR** (`release/*-srN`) and **Preview** (`release/*-previewN`). Surveys CI, computes what's actually shipping, classifies open regressions, identifies port candidates and rejected backports + - **Trigger phrases**: "release readiness for SRN", "is SR7 ready to ship", "survey the SR branch", "release readiness for preview6", "how does preview6 look (deterministic)", "status across all releases" (reads the live `[Release Readiness]` tracker issues by body marker — no survey re-run needed) + - **Scripts**: `Get-ReleaseReadiness.ps1` (SR lane), `Get-PreviewReadiness.ps1` (Preview lane), `Find-ReleaseReadinessTrackers.ps1` (tracker discovery) + - **Output**: JSON + Markdown report, list of source PRs, classification of regression issues (in-sr-active, rejected-from-sr, no-fix-yet, etc.) + - **Note**: Deterministic and reproducible — no MCP, no LLM judgment. Use **this skill directly** when you need raw output for a script, dashboard, cron job, or programmatic consumer. For natural-language verdict synthesis with WorkIQ enrichment, use the **`release-readiness-agent`** instead. + #### Internal Skills (Used by Agents) -9. **try-fix** (`.github/skills/try-fix/SKILL.md`) +13. **try-fix** (`.github/skills/try-fix/SKILL.md`) - **Purpose**: Proposes ONE independent fix approach, applies it, tests, records result with failure analysis, then reverts - **Used by**: pr agent Phase 3 (Fix phase) - rarely invoked directly by users - **Behavior**: Reads prior attempts to learn from failures. Max 5 attempts per session. @@ -294,6 +368,10 @@ Skills are modular capabilities that can be invoked directly or used by agents. - User: "Test this PR" → Immediately invoke **sandbox-agent** - User: "Fix issue #67890" (no PR exists) → Suggest using `/delegate` command - User: "Write tests for issue #12345" → Immediately invoke **write-tests-agent** +- User: "Is SR7 ready to ship?" → Immediately invoke **release-readiness-agent** +- User: "How does net11 preview6 look?" → Immediately invoke **release-readiness-agent** +- User: "Give me a status on releases / what needs attention across releases?" → Immediately invoke **release-readiness-agent** (portfolio mode — it enumerates active releases by reading the `[Release Readiness]` tracker issues; don't ask "which release?") +- User: "Give me the raw release-readiness JSON for SR8" → Use the **release-readiness** skill directly (no enrichment needed) **When NOT to delegate**: - User asks "What does PR #12345 do?" → Informational query, handle yourself diff --git a/.github/copilot/settings.json b/.github/copilot/settings.json new file mode 100644 index 000000000000..35358294c16e --- /dev/null +++ b/.github/copilot/settings.json @@ -0,0 +1,13 @@ +{ + "extraKnownMarketplaces": { + "dotnet-arcade-skills": { + "source": { + "source": "github", + "repo": "dotnet/arcade-skills" + } + } + }, + "enabledPlugins": { + "dotnet-dnceng@dotnet-arcade-skills": true + } +} diff --git a/.github/docs/agent-labels.md b/.github/docs/agent-labels.md new file mode 100644 index 000000000000..512b45ca52b8 --- /dev/null +++ b/.github/docs/agent-labels.md @@ -0,0 +1,179 @@ +# Agent Workflow Labels + +GitHub labels for tracking outcomes of the AI agent PR review workflow (`Review-PR.ps1`). + +All labels use the **`s/agent-*`** prefix for easy querying on GitHub. + +--- + +## Label Categories + +### Outcome Labels + +Mutually exclusive — exactly **one** is applied per PR review run. + +| Label | Color | Description | Applied When | +|-------|-------|-------------|--------------| +| `s/agent-approved` | 🟢 `#2E7D32` | AI agent recommends approval — PR fix is correct and optimal | Report phase recommends APPROVE | +| `s/agent-changes-requested` | 🟠 `#E65100` | AI agent recommends changes — found a better alternative or issues | Report phase recommends REQUEST CHANGES | +| `s/agent-review-incomplete` | 🔴 `#B71C1C` | AI agent could not complete all phases (blocker, timeout, error) | Agent exits without completing all phases | + +When a new outcome label is applied, any previously applied outcome label is automatically removed. + +### Signal Labels + +Additive — **multiple** can coexist on a single PR. + +| Label | Color | Description | Applied When | +|-------|-------|-------------|--------------| +| `s/agent-gate-passed` | 🟢 `#4CAF50` | AI verified tests catch the bug (fail without fix, pass with fix) | Validate phase passes | +| `s/agent-gate-failed` | 🟠 `#FF9800` | AI could not verify tests catch the bug | Validate phase fails | +| `s/agent-fix-win` | 🟢 `#66BB6A` | AI found a better alternative fix than the PR | Fix phase: alternative selected over PR's fix | +| `s/agent-fix-pr-picked` | 🟠 `#FF7043` | AI could not beat the PR fix — PR is the best among all candidates | Fix phase: PR selected as best after comparison | + +Validate labels (`gate-passed`/`gate-failed`) are mutually exclusive with each other. Fix labels (`fix-win`/`fix-lose`) are mutually exclusive with each other. + +### Tracking Label + +Always applied on every completed agent run. + +| Label | Color | Description | Applied When | +|-------|-------|-------------|--------------| +| `s/agent-reviewed` | 🔵 `#1565C0` | PR was reviewed by AI agent workflow (full 4-phase review) | Every completed agent run | + +### Manual / Queue Labels + +Manual labels are applied by MAUI maintainers. Queue labels are applied by deterministic automation, not by AI. + +| Label | Color | Description | Applied When | +|-------|-------|-------------|--------------| +| `s/agent-fix-implemented` | 🟣 `#7B1FA2` | PR author implemented the agent's suggested fix | Maintainer applies when PR author adopts agent's recommendation | +| `s/agent-ready-for-rerun` | 🟣 `#5319E7` | AI review has new PR activity and is ready for rerun | `/review rerun` finds new comments or commits after the latest AI Summary / previous rerun request | +| `s/agent-review-in-progress` | 🟡 `#FBCA04` | AI review is currently running for this PR | Applied before triggering the async AzDO review pipeline and removed by pipeline cleanup; stale locks can be recovered after a conservative timeout | + +--- + +## How It Works + +### Architecture + +``` +Review-PR.ps1 +├── Phase 1: Agent Review (Copilot CLI) +│ ├── Pre-Flight → writes content.md +│ ├── Validate → writes content.md +│ ├── Fix → writes content.md +│ └── Report → writes content.md +├── 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 + └── Non-fatal: errors warn but don't fail the workflow +``` + +Most review outcome labels are applied from `Review-PR.ps1` Phase 4. The exceptions are queue/lock labels: `s/agent-ready-for-rerun` is applied by the deterministic `/review rerun` GitHub Action path after checking for new comments or commits, and `s/agent-review-in-progress` is applied before triggering the async AzDO review pipeline. The rerun path does not use AI to decide whether these labels apply. The lock label normally clears in the AzDO cleanup stage; trigger paths treat very old locks as stale so a cancelled pipeline does not permanently block reviews. + +### How Labels Are Parsed + +The `Parse-PhaseOutcomes` function in `Update-AgentLabels.ps1` reads `content.md` files from each phase directory: + +| Source File | What's Parsed | Resulting Label | +|-------------|---------------|-----------------| +| `gate/content.md` | `**Result:** ✅ PASSED` | `s/agent-gate-passed` | +| `gate/content.md` | `**Result:** ❌ FAILED` | `s/agent-gate-failed` | +| `try-fix/content.md` | `**Selected Fix:** Candidate ...` | `s/agent-fix-win` | +| `try-fix/content.md` | `**Selected Fix:** PR ...` | `s/agent-fix-pr-picked` | +| `report/content.md` | `Final Recommendation: APPROVE` | `s/agent-approved` | +| `report/content.md` | `Final Recommendation: REQUEST CHANGES` | `s/agent-changes-requested` | +| *(missing report)* | No report file exists | `s/agent-review-incomplete` | + +### Self-Bootstrapping + +Labels are created automatically on first use via `Ensure-LabelExists`. No manual setup required. If a label already exists but has a stale description or color, it is updated. + +--- + +## Querying Labels + +All labels use the `s/agent-*` prefix, making them easy to filter on GitHub. + +### Common Queries + +``` +# PRs the agent approved +is:pr label:s/agent-approved + +# PRs where agent found a better fix +is:pr label:s/agent-fix-pr-picked + +# PRs where agent found better fix AND author implemented it +is:pr label:s/agent-changes-requested label:s/agent-fix-implemented + +# PRs where tests don't catch the bug +is:pr label:s/agent-gate-failed + +# Agent-reviewed PRs that are still open +is:pr is:open label:s/agent-reviewed + +# All agent-reviewed PRs (total count) +is:pr label:s/agent-reviewed +``` + +### Metrics You Can Derive + +| Metric | Query | +|--------|-------| +| Total agent reviews | `is:pr label:s/agent-reviewed` | +| Approval rate | Compare `label:s/agent-approved` vs `label:s/agent-changes-requested` counts | +| Validate pass rate | Compare `label:s/agent-gate-passed` vs `label:s/agent-gate-failed` counts | +| Fix win rate | Compare `label:s/agent-fix-win` vs `label:s/agent-fix-pr-picked` counts | +| Agent adoption rate | `label:s/agent-fix-implemented` / `label:s/agent-changes-requested` | +| Incomplete review rate | `label:s/agent-review-incomplete` / `label:s/agent-reviewed` | + +--- + +## Implementation Details + +### Files + +| File | Purpose | +|------|---------| +| `.github/scripts/shared/Update-AgentLabels.ps1` | Label helper module (all label logic) | +| `.github/scripts/Review-PR.ps1` | Orchestrator that calls `Apply-AgentLabels` in Phase 4 | +| `.github/scripts/Resolve-RerunEligibility.ps1` | Deterministic `/review rerun` checker that can apply `s/agent-ready-for-rerun` | +| `.github/scripts/Invoke-RerunReviewTrigger.ps1` | Safe-output handler that validates rerun decisions and emits an actions list; the scanner then dispatches `review-trigger.yml` (which applies `s/agent-review-in-progress` and triggers the AzDO review) | +| `.github/workflows/review-trigger.yml` | Manual `/review` trigger that applies `s/agent-review-in-progress` before triggering AzDO reviews | +| `eng/pipelines/ci-copilot.yml` | AzDO review pipeline that removes `s/agent-review-in-progress` in final cleanup | +| `.github/skills/pr-review/SKILL.md` | Documents label system for the pr-review skill | + +### Key Functions + +| Function | Description | +|----------|-------------| +| `Apply-AgentLabels` | Main entry point — parses phases and applies all labels | +| `Parse-PhaseOutcomes` | Reads `content.md` files, returns outcome/gate/fix results | +| `Update-AgentOutcomeLabel` | Applies one outcome label, removes conflicting ones | +| `Update-AgentSignalLabels` | Adds/removes validate and fix signal labels | +| `Update-AgentReviewedLabel` | Ensures tracking label is present | +| `Set-AgentReviewInProgress` | Applies the async review lock label | +| `Clear-AgentReviewInProgress` | Removes the async review lock label | +| `Test-AgentReviewInProgressIsStale` | Checks whether a lock label is old enough to recover | +| `Ensure-LabelExists` | Creates or updates a label in the repository | + +### Design Principles + +- **Idempotent**: Safe to re-run — checks before add/remove, GitHub ignores duplicate adds +- **Non-fatal**: Label failures emit warnings but never fail the overall workflow +- **Single source**: All labels applied from `Review-PR.ps1` only — no other scripts touch labels +- **Self-bootstrapping**: Labels are created on first use via GitHub API +- **Mutual exclusivity enforced**: Outcome labels and same-category signal labels automatically remove their counterpart + +--- + +## Migrated From + +The following old infrastructure was removed as part of this implementation: + +- **`Update-VerificationLabels`** function in `verify-tests-fail.ps1` — removed (labels now come from `Review-PR.ps1` only) +- **`s/ai-reproduction-confirmed`** / **`s/ai-reproduction-failed`** labels — superseded by `s/agent-gate-passed` / `s/agent-gate-failed` diff --git a/.github/docs/maui-ci-facts.md b/.github/docs/maui-ci-facts.md new file mode 100644 index 000000000000..7cc03cf1f4f6 --- /dev/null +++ b/.github/docs/maui-ci-facts.md @@ -0,0 +1,441 @@ + + +# .NET MAUI CI Facts + +Authoritative reference for dotnet/maui CI investigation, test-failure +classification, and merge-readiness assessment. Both the interactive +`azdo-build-investigator` skill and the automated `/review tests` +(`review-test-failures`) workflow reason from these same facts. + +## Pipelines + +**Organization**: `dnceng-public` / project `public` +(`https://dev.azure.com/dnceng-public/public/_apis/build/...`). + +| Pipeline Name | Definition ID | Purpose | +|---------------|---------------|---------| +| `maui-pr` | **302** | Main build + unit/integration validation — check this first | +| `maui-pr-devicetests` | **314** | Helix device tests (iOS, Android, Windows, MacCatalyst) | +| `maui-pr-uitests` | **313** | Appium-based UI tests | + +**Investigation priority order**: `maui-pr` → `maui-pr-devicetests` → `maui-pr-uitests`. +Most failures are in `maui-pr`. Focus on the first failing pipeline before others. + +> ⚠️ Older names like `maui-public` / `MAUI-public` / `MAUI-UITests-public` are +> **outdated**. The `ci-analysis` plugin reference doc still lists `maui-public` — +> ignore that and use the names above. + +**When CI hasn't run:** Community PRs require a maintainer to trigger builds via +`/azp run maui-pr` (or `maui-pr-devicetests`, `maui-pr-uitests`). `maui-pr-devicetests` +and `maui-pr-uitests` may not run automatically depending on the changed files. + +## AzDO data sources + +- Primary access is **anonymous/public** REST: `builds`, `builds/{id}/timeline`, + and `builds/{id}/logs/{logId}` under + `https://dev.azure.com/dnceng-public/public/_apis/build/...`. +- `_apis/test/...` endpoints often redirect to sign-in anonymously. Treat them as + **optional enrichment** only when an AzDO bearer token is available. Do not require + them to reach a verdict. +- If a build returns **404** even with authenticated access, classify it as + inaccessible/expired/insufficient data — do not assume unrelated or PR-caused. +- Helix work-item console output may live behind `helix.dot.net` and Azure Blob URLs. + +## Enumerate EVERY failed leg — Build Analysis is NOT exhaustive + +> 🚨 The single most common way to be **wrong** about "what's unique to this PR" is to +> answer from an incomplete failure list. Build a complete inventory first, then classify. + +- **Build Analysis `unmatchedFailures` is not a complete failure list.** It curates + test-like failures and recognized error strings; it routinely **omits whole failed build + jobs** — crossgen2/ReadyToRun (R2R), NativeAOT/ILC, the linker, `pack`, and plain MSBuild + `error` legs. Never treat it as the exhaustive set of what failed. +- **Enumerate every failed timeline record** (`result == failed`) and open the log of + **each** one — including records whose structured `issues[]` array is empty + (`issues == 0`). A failed record with zero structured issues is **not benign**: the real + error is in the **log**, not the `issues[]` array. `Build (Debug/Release)` and + `Build Microsoft.Maui.sln` legs are exactly where build breaks hide. +- **Not every failure is an xUnit `[FAIL]` test.** A broken build job has **no test name** — + it shows up as `error :` or `... : error : ...` lines (e.g. crossgen2 + `Failed to load assembly 'Microsoft.Maui'`). A test-name-only search will miss it entirely. +- Use `azdo_search_timeline` with `resultFilter=all` to get every record's result + logId, + then open each failed leg's specific `logId`. A bare `azdo_search_log` without a logId + only ranked-searches a subset of logs, so **0 matches there is inconclusive**, not "clean." +- Do not answer "nothing is unique to the PR" until you have confirmed you inspected + **all** failed legs. Absence of evidence is not evidence — it is usually an unread log. + +## MAUI-specific quirks + +### XHarness exit-0 blind spot + +XHarness (iOS/Android device tests in `maui-pr-devicetests`) **exits with code 0 even +when tests fail**. So the AzDO job shows ✅ "Succeeded", `ci-analysis` may report no +failures, but real failures are hidden inside the Helix work items. + +**Detect hidden failures** via the Helix per-job **work-items** endpoint: + +``` +GET https://helix.dot.net/api/2019-06-17/jobs/{correlationId}/workitems +``` + +> ⚠️ The older `/aggregated` endpoint returns **HTTP 404 anonymously** (verified against +> live maui jobs) — it is unreachable from the gh-aw runner, so do not rely on it. The +> per-job `/workitems` endpoint **is** reachable anonymously and returns one entry per +> work item, each carrying an `ExitCode` (int) and `State` (`Finished`/`Running`/...). +> Job detail (`GET .../jobs/{correlationId}`, also anonymous) supplies `InitialWorkItemCount` +> and the job-level `Finished` timestamp. + +A work item **failed** when it `Finished` with a non-zero `ExitCode`, even when the AzDO +build job is green. Always cross-check this for `maui-pr-devicetests` when a job is green +but device-test failures are suspected (or the PR carries `s/agent-gate-failed`). If Helix +work-item data is absent, state that device-test hidden failures could not be verified — +do not assume green = clean. + +> 🔎 **Job correlation is from the build's own logs, not a Helix query.** The Helix job +> `Build` property is **blank** for maui, so jobs cannot be correlated to an AzDO build via +> Helix; a `Source`+time-window query over-discovers (it mixes the concurrent `maui-pr-uitests` +> and `maui-pr-devicetests` jobs of the *same* PR, and even other builds in the window). The +> gatherer instead scans **every** `Run DeviceTests` leg's log (green **and** failed) for its +> `Sent Helix Job ... /jobs/{id}/workitems` line — a green leg's hidden work-item failure +> would otherwise never be discovered. + +**Deterministic enforcement in `/review tests`.** Because XHarness exit-0 makes a green +device-test check untrustworthy, the gatherer **force-inspects every device-test build** +(green or not) and only treats a green `maui-pr-devicetests` check as clean when it can +**positively confirm `Failed == 0`** — either every discovered Helix job's `/workitems` +set was read clean, or the authenticated test-API (when a token is present). +That confirmation requires a **complete, error-free read**: every discovered Helix job's +work-item set must be read without a thrown error (a job whose read fails may have carried the +hidden failures) **and** must be *complete* — a job is left **unverified** (which caps the +verdict) when any work item is not yet `Finished` or reports no `ExitCode`, when the job +itself never `Finished`, or when fewer work items were returned than the job's +`InitialWorkItemCount`. The test-API path **pages through every test run** via the +`x-ms-continuationtoken` header and refuses to confirm when the run set was truncated (a +retried device build publishes a new run per attempt, so a failing run can sit in an unread +page tail). When no positive confirmation is available — e.g. an unreadable/incomplete Helix +read, or when the anonymous AzDO test-results API redirects to sign-in — +the green device-test check is counted as `gate.deviceTestUnverified` and **hard-caps the +verdict ceiling at `Needs human investigation`**. A green device-test check is never a +false green. (SKIPPED device-test checks did not run, so they do not cap; RED ones are +ordinary failing checks.) + +**Deep work-item read (Phase 2): turn opaque device-test failures into attributable detail.** +A failed Helix work item exposes its uploaded files **fully anonymously**, so the gatherer +deep-reads them instead of emitting one opaque "hidden failure" line: + +``` +GET https://helix.dot.net/api/2019-06-17/jobs/{correlationId}/workitems/{workItemName} +``` + +returns a `Files[]` array (each `{ FileName, Uri }`) plus a `ConsoleOutputUri`. Each file +`Uri` (`.../workitems/{name}/files/{file}?api-version=2019-06-17`) responds **HTTP 302 → an +Azure blob** whose SAS token is embedded in the redirect, so following the redirect needs **no +auth** (verified live). The useful files are: + +- the aggregated `testResults.xml` when present; otherwise the per-category `TestResults-*.xml` + files (xUnit v2 ``), which the gatherer merges. Assembly/collection-level + `` nodes (fixture/cleanup crashes that carry no ``) are also + counted as named failures so a fixture crash can't vanish. The xUnit parse runs through an + `XmlReader` with `DtdProcessing=Prohibit` + `XmlResolver=$null`, so a malicious/garbled inline + `` (billion-laughs / XXE) safely fails the parse to NHI rather than expanding. +- `console.*.log` — carries the real failure reason (e.g. `[FAIL] Timeout waiting for + HybridWebView test results after 480 seconds`, an unhandled exception, or a non-zero exit line). +- a `*.dmp` crash dump when the host crashed. + +> ⚠️ **Content-type gotcha (decode bug, fixed).** Azure blob serves the `.xml` result files as +> `application/octet-stream`, so `Invoke-WebRequest`'s `.Content` is a **`byte[]`** — a plain +> `[string]` cast stringifies it as space-joined decimal byte values (`"60 63 120 …"`) and breaks +> XML parsing. Decode bytes as UTF-8 and strip any leading BOM (which would also make +> `XmlDocument.LoadXml` throw). `console.*.log` is served as `text/plain`, so its `.Content` is +> already a string — that asymmetry is why a console read can succeed while a TRX read silently +> returns nothing if bytes aren't handled. + +**This NEVER relaxes the verdict cap.** A non-zero-ExitCode work item still hard-caps the verdict +at *Needs human investigation*; the deep read only makes it **intelligible**. Real `result="Fail"` +tests become NAMED records (`source = helix-trx`) that flow through the normal dedup / base-exact- +match / known-issue attribution — so a named device-test failure that also fails on base can +dismiss as pre-existing, exactly like any other test. But an **incomplete** run ALSO emits a +capping `helix-workitem-incomplete` record **in addition to** any named failures — because a run +that didn't finish can mask a PR-caused failure in tests that never ran, so even if every named +failure dismisses on base the work item stays NHI. A run is treated as incomplete when **any** of: +the console shows it was killed/hung/crashed; a `*.dmp` crash dump **or** a crash signal (`core +dumped` / `segfault` / `.dmp`) is present (a SIGSEGV can flush a partial result file then kill the +run — so a crash forces the cap *even when some tests were named*); no result file was readable; +the result files read were themselves incomplete (a per-category file failed to download/parse, the +category list overflowed the read cap, or the file **declared** more failures than we could +extract); or the work item exited non-zero with **zero** named failures. A device-test work item is +**never** a false green. + +> 🔒 **Never-false-green coupling — keep these in sync (load-bearing).** The Phase-2 +> incompleteness cap narrows `isIncomplete` to markers that *prove* a run didn't finish, and that +> narrowing is **coupled to the Windows runner's exact echo strings**: +> - `[FAIL] Timeout waiting for test results after N seconds` — `eng/devices/run-windows-devicetests.cmd:503` (an unfinished category) +> - `All test processes may have crashed` — `run-windows-devicetests.cmd:430` (a total wipeout) +> - `Test execution completed with exit code: N` — `run-windows-devicetests.cmd:481` is **deliberately EXCLUDED** from the cap: the cmd echoes it **unconditionally** on every non-zero run (it only ever `exit /b 0|1`), so treating it as incompleteness over-caps *every* failed Windows work item and never lets a cleanly-named failure flow to base/known-issue attribution. +> +> The first two strings are the *independent* incompleteness markers in `Get-ConsoleFailureReason`'s +> `$incompleteRegex`. **If anyone rewords them in the cmd, mirror the change in +> `$incompleteRegex` (`Gather-TestFailureContext.ps1`)** — otherwise the cap silently weakens **with +> no test failing** (the coupling is enforced only by comments + this note, not by a shared +> constant). Also: any **new** call site that reads a `console.*.log` via `Invoke-HelixFileText` to +> judge completeness **must** pass `-Truncated ([ref]$flag)` and OR that flag into the incomplete +> cap — a console read without it reopens the head-only-`[FAIL]` truncation window (a >4 MB XHarness +> console keeps only the HEAD, where per-test `[FAIL]` lines live, and drops the TAIL where the +> timeout/crash marker prints). + +### Container artifact binlogs + +MAUI build artifacts are **Container** type, not `PipelineArtifact`: + +- `az pipelines runs artifact download` does **not** work for binlogs. +- Artifact names look like `Windows_NT_Build Windows (Debug)_Attempt1` (not `binlog`). +- Download needs a Bearer token: + `az account get-access-token --resource 499b84ac-1321-427f-aa17-267ca6975798`. +- Use the ADO File Container API: + `/_apis/resources/Containers/{id}?api-version=5.0-preview&$format=OctetStream`. + +If available, the `mcp-binlog-tool` / binlog MCP server can analyze downloaded +`.binlog` files. Optional — core investigation works via `gh` CLI and REST. + +## Test count deduplication + +**Never sum raw failed counts across test runs.** MAUI UI/device tests repeat the +same test across: + +- **Runtime variants**: CoreCLR and Mono +- **Platform versions**: e.g. iOS 18.5 and iOS latest, Android API 30 and API 36 +- **Retry attempts**: each retried job publishes a new test run + +A single failing test can appear in 4–8+ runs. Summing inflates counts dramatically. + +**Deduplicate** by grouping on **normalized test name + OS platform** (`android`, +`ios`, `macos`, `windows`, or `unknown`). "DatePicker_Format_D on iOS" and +"DatePicker_Format_D on Android" are distinct failures. Collapse retries and runtime +variants (coreclr/mono) of the same test on the same OS into one. Report retry/run IDs +as supporting evidence under the same distinct failure. + +## Baseline comparison (is it already red on the base branch?) + +A failure that is **already failing on the base branch** (e.g. `main`) for the same +pipeline is almost certainly **not** caused by the PR. + +- Compare each distinct PR failure (by normalized test name + OS platform) against + failures from the **most recent base-branch build of the same pipeline definition**. +- If the same `(test, platform)` key fails on the baseline build, treat it as + **pre-existing / likely unrelated** and subtract it from PR-caused — unless this PR + directly changes that test. (The deterministic scope guard in the gatherer name-matches + the failing test against PR-edited **test files** only; it does **not** automatically + detect snapshot/baseline-image or platform-source edits. When this PR touches the + snapshot/baseline or the platform code a still-red test exercises, do **not** subtract it + — the same-reason match is not proof it is pre-existing.) +- Base-build *result* alone is weaker evidence than a per-test match: a red base build + tells you the branch is unhealthy; a matching red **test** tells you this specific + failure is not yours. +- **Also diff at the JOB/leg level, not just the test level.** Build-job breaks (crossgen, + NativeAOT, `pack`, MSBuild errors) have **no test name**, so a test-only baseline diff + structurally cannot see them. For each **failed leg** on the PR (by normalized job name, + e.g. `Build macOS (Debug)`), compare the **same leg's result** on the most recent + base-branch builds. +- **"Failed on the PR, green on the base branch for the same leg" is the STRONGEST + PR-caused signal there is** — it outranks a test-name baseline match. If `Build macOS + (Debug)` is red on the PR but was green on the base build, the break is the PR's, full + stop, even if Build Analysis matched nothing. +- For **flow / dependency-update PRs** (`dotnet-maestro[bot]`): when a leg fails only on the + PR, diff the **SDK/runtime version** between PR and base — compare the `.dotnet/sdk/` + path in the PR's failing log against the base build's log. A bumped SDK/runtime + (e.g. `preview.5` → `preview.6`) that introduces a crossgen/R2R or linker break is a + PR-caused regression carried in by the dependency, not a pre-existing flake. +- If baseline data is missing or the base build is inaccessible, say so — do not assume + a failure is pre-existing without evidence. + +## Visual baseline failures + +Messages like `Baseline snapshot not yet created`, missing snapshot paths, or snapshot +environment-version mismatches are strong **unrelated** evidence — unless the PR adds or +modifies that visual test or the affected snapshot/platform. + +## Platform mismatch + +Platform mismatch is **supporting** evidence, not proof. An iOS-only test failing on a +Windows-only PR is likely unrelated when the message also points to missing iOS baseline +data — but it may still need investigation if the PR changes shared logic (e.g. +CarouselView) that runs on that platform. + +## Gradle / Maven / CFSClean failures + +**Error signatures** (these are build/feed issues, NOT test failures): + +``` +error XAGRDL0000: Could not resolve com.android.tools.build:gradle:8.11.1 + > Received status code 401: Unauthorized - No local versions of package +``` +``` +error XAGRDL0000: Could not GET '...pkgs.dev.azure.com/.../maven/v1/...' + > Unauthorized - Please provide authentication to save package from upstream +``` + +**Fix:** run `./eng/ingest-maven-deps.sh` locally to pre-ingest packages into the feed. + +**Do NOT:** +- Remove CFSClean from `ci-official.yml` — security compliance requirement. +- Upgrade Gradle past 8.x — `dotnet/android#10738`. +- Add `mavenCentral()` or `google()` back — use the Azure Artifacts feed. + +## Common failure patterns + +| Pattern | Where | Notes | +|---------|-------|-------| +| `error CS####` | `maui-pr` | C# compiler error — check file/line | +| `error XA####` | `maui-pr` | Android build error | +| `error : ... Failed to load assembly` | `maui-pr` `Build ` leg | **crossgen2 / ReadyToRun (R2R)** break — a failed **build job**, not a test, with no test name. Common after an SDK/runtime (`dotnet/dotnet`) bump on a flow PR. Job-level baseline diff: red on PR vs green on base ⇒ PR-caused. | +| `error IL####` / `ILC####` / NativeAOT publish fail | `maui-pr` AOT legs, `Run Integration Tests – AOT` | **NativeAOT / ILC** trim-analysis break. May be pre-existing (e.g. HybridWebView `IL2026`) — confirm with a job- AND test-level baseline diff before attributing to the PR. | +| `error NETSDK1144` | `maui-pr` TrimFull legs | Optimizing assemblies for size failed (often an ILLink warning promoted to error). Check whether the same leg is red on the base branch. | +| `XamlC` | `maui-pr` | XAML compiler — usually missing type or bad binding | +| `error XAGRDL0000` / `401` / `No local versions` | `maui-pr` or official build | Gradle/Maven feed issue — see above | +| `XHarness timeout` | `maui-pr-devicetests` Helix logs | Test killed by infrastructure; may be transient | +| `No test result files found` | `maui-pr-devicetests` Helix logs | Tests never ran or app crashed on launch | +| UI test screenshot diff | `maui-pr-uitests` | Visual regression; check baseline images | + +## Merge-readiness criteria + +Used by both the interactive investigator (answering "is this PR ready to merge?") and +the automated `/review tests` overall verdict. Assess **only CI/test health** — code +review and approval are separate, human-only decisions. + +| Overall verdict | Use when | +|-----------------|----------| +| `Ready to merge` | No failing checks, OR every distinct failure is confidently `Likely unrelated` (infra, missing baselines, known flake) or matches a baseline failure on the base branch. | +| `Not ready` | At least one distinct failure is `Likely PR-caused` — references changed files/tests/APIs/platform, or appears only on a path/platform this PR changes and is not on the baseline. | +| `Needs human investigation` | Evidence is mixed: a failure overlaps the PR area/platform but no direct causal link is clear, or required checks are pending/absent. | +| `Insufficient data` | Build records, test results, or logs are missing/inaccessible/expired — not enough evidence to make a responsible claim. | +| `No failures found` | No failing, pending, or inconclusive checks and no extracted failures. | + +Be conservative: do not declare `Ready to merge` while required checks are still +pending, and do not mark a failure unrelated just because it "looks flaky" — cite +concrete evidence (baseline match, infra message, known-issue link). + +### Flaky vs PR-specific: the deterministic proofs + +A failure may be called **not PR-specific** only with one of these concrete proofs — never +on appearance alone: + +1. **Baseline match** — the same `test+platform` also fails on the most recent base-branch + build (`alsoFailsOnBaseline = true`), **scoped to the same pipeline definition** (a + failure in one pipeline is never dismissed by a same-named base failure that only occurred + in a *different* pipeline). Pre-existing, not introduced by the PR. **One veto:** + if the PR failure exact-matches a base failure by name but the two fail for *different + reasons* (`baselineReasonConflict = true` — e.g. a PR-introduced + `NullReferenceException` vs a base-branch `TimeoutException` in the same test), the + dismissal is **refused** and the failure is forced to `indeterminate`, because the + name-based dedup key is message-blind for test failures. The reason comparison **unwraps + wrapper exceptions** (`AggregateException`/`TargetInvocationException`) to the inner cause — + and when a wrapper carries **multiple** inner exceptions, collapses them into a **sorted + compound token** so a PR-introduced inner cannot hide behind a base-matching first inner — + and, when neither side yields a known reason, falls back to a **normalized message + fingerprint** (PR text structurally absent from base ⇒ conflict; the fingerprint preserves + identifier-internal digits and hashes any tail past 120 chars, so two breaks differing only + by an identifier digit or a far-out suffix stay distinct). These fire only on data + present on both sides — with one deliberate exception: a dismissible **test** failure with + **no reason token and no message at all** (empty `errorMessage`) gives zero corroboration + that it is the same failure as the name match, so it too is forced to `indeterminate`. A + noisy/partially-present message still never inflates false reds. +2. **Job-level baseline match** — for a build break with no test name (crossgen/NativeAOT/ + linker/MSBuild), the same **leg** is also red on the most recent base build. Conversely, + a leg that is **red on the PR but green on base is PROOF the break is PR-caused** — this + is the strongest signal and a test-only diff cannot produce it. The automated lane now + **computes this in `Gather-TestFailureContext.ps1`** (per-failure `legBaselineResult` / + `legRegressedVsBase` / `legAlsoFailsOnBase` and a `deterministicAttribution` prior); the + interactive investigator does the same comparison by hand from the timelines. **Note the + asymmetry:** a leg being red on base (`legAlsoFailsOnBase`) is only **leg-level** + evidence — the leg can fail on base at a *different* test, so it does **not** on its own + prove *this* test is pre-existing. Only an **exact test+platform** base match + (`alsoFailsOnBaseline`, item 1) is strong enough to dismiss; a leg-only match is treated + as **indeterminate** (`Needs human investigation`), never dismissed. +3. **Known-issue match** — the failure message matches an open `Known Build Error` issue + (the dotnet Build Analysis registry). Cite the issue number/link — but treat it as a + **hint, not a dismissal**: a text match alone can shadow a real PR break with a broad + matcher. The automated lane only dismisses a known-issue match (`deterministicAttribution + = known-issue`) when the **exact same test+platform also failed on the base build** + (i.e. the same condition as `pre-existing-on-base`, item 1) — leg-level corroboration + (`legBaselineResult = failed-on-base` for the *leg*) is **too coarse** and no longer + dismisses, because a broad known-issue regex could otherwise launder a PR-caused break in + a *different* test that merely shares a red leg on base. An uncorroborated text match (no + exact base match) stays `indeterminate` (`Needs human investigation`). +4. **Retry recovery** — the failing leg was retried by CI and **passed** on a later + attempt (the recovered leg does not surface as a failure at all). A leg that was retried + and **still failed** (`retriedStillFailing = true`) is the opposite — **persistent**, + so do not call it flaky. + +If none of these hold, a failure on a path/platform the PR changes leans PR-caused. Note +that **not every failure is a test** — a red build leg with no extracted test name still +counts as a failure and must be classified, never silently dropped. + +The automated `/review tests` lane additionally computes a **deterministic verdict +ceiling** in `Gather-TestFailureContext.ps1` (`gate.verdictCeiling`): the overall verdict +can never be more favorable than what coverage allows. A green verdict +(`Ready to merge` / `No failures found`) is forbidden whenever a check is still pending, a +failing check could not be inspected, **or a failed build leg produced no extractable +failure** (`gate.unexplainedFailedLegs > 0` — the backstop that stops a crossgen/NativeAOT +build break from being silently counted as zero failures). The same green is also forbidden +whenever an **accessible** failing check produced **no** extractable failure **and no** +unexplained-leg record (`gate.unaccountedFailingChecks > 0` — the earned-green guard: a red +check whose log threw, had no log id, or fell past the per-build cap still pulls the ceiling +down to `Needs human investigation` instead of defaulting to green), whenever a failing check +**did not finish cleanly** (`gate.abortedFailingChecks > 0` — a `CANCELLED`/`TIMED_OUT`/ +`STARTUP_FAILURE`/`STALE`/`ACTION_REQUIRED` conclusion, whose aborted legs can carry no +`error` issue and so never become unexplained legs; a PR-induced hang that got a job +cancelled must not be masked green by a dismissible sibling on the same build), whenever a +backing **build's own result is `canceled`** regardless of the GitHub check conclusion +(`gate.canceledBuildChecks > 0` — broader than the conclusion-based aborted guard: a build +canceled mid-flight after a leg already posted `FAILURE`/`SUCCESS` slips past that guard, so +it is capped on the build metadata directly), whenever a **green device-test check could not +be confirmed `Failed == 0`** (`gate.deviceTestUnverified > 0` — XHarness exits 0 even when +device tests fail, so a green `maui-pr-devicetests` check is trusted only when a fail count +was positively observed all-zero; absent that, it caps to `Needs human investigation`), or +when a failure can be +attributed **neither** way — not a clean regression vs base, not pre-existing on base, not a +known issue (`gate.unattributedFailures > 0`; e.g. the base leg outcome was ambiguous, the +base build was missing/unreadable, or a device-test result fell outside the deterministic +build-error class). A `pre-existing-on-base` or exact-match `known-issue` dismissal is also +**refused** (downgraded to `indeterminate`) when the PR actually edits the failing test file +(`scopeGuardTripped` — the PR may have changed the test so it now fails for a new reason that +merely coincides with the base/known text) or when the PR and base failures of the same test +have a known **reason conflict** (`baselineReasonConflict`). It is likewise **capped at `Not ready`** whenever +a leg is red on the PR +but green on the same leg of the most recent base build (`gate.legsRegressedVsBase > 0` — the +computed job-level regression; a device-test BUILD break counts here, only device-test TEST +results are excluded). A proven regression sets the ceiling to `Not ready` even when softer +`Needs human investigation` reasons are also present — a definitive PR-introduced break is a +more actionable headline than "go investigate", and `Not ready` is still non-green so this +never enables a false green (the NHI reasons remain listed in `ceilingReasons`). The +gatherer also extracts build-job errors (not just xUnit `[FAIL]` lines) via +`Get-BuildErrorsFromLog`, so crossgen/R2R/linker breaks **and fatal non-coded breaks** +(native crash/segfault/OOM, test-host crash, unhandled exception — not the ordinary +`exit code 1` test-runner rollup) flow into dedup, the (computed) +baseline diff, and the gate — and it does so **even on a leg that also has a test failure** +(a pre-existing flaky test must not hide a *new* `error CS####` build break or a new native +crash in the same leg), +suppressing only the generic `##[error]` rollup line when a real test failure is already +present. It also inspects **`partiallySucceeded`** timeline records on both the PR and base +side (not just `failed`), symmetric with the leg-result map, so a PR-caused break in a +partially-succeeded task cannot escape the gate while a dismissible sibling accounts for the +build. The interactive investigator should apply the same discipline by hand. + +## Escalation + +For deep Helix log analysis (recurring failures, machine-specific issues, comparing +passing vs. failing runs), escalate to the `helix-investigation` skill. diff --git a/.github/docs/pr-review-workflow.md b/.github/docs/pr-review-workflow.md new file mode 100644 index 000000000000..9f559313ce73 --- /dev/null +++ b/.github/docs/pr-review-workflow.md @@ -0,0 +1,314 @@ +# .NET MAUI automated PR review workflow + +This guide explains the automated review commands used in dotnet/maui pull requests: + +- `/review` +- `/review rerun` +- `/review tests` + +It is intended for Microsoft maintainers and community contributors who want to understand when to request an automated review, what the automation does, and how to interpret the resulting comments. + +## Quick command reference + +| Command | Who can run it | What it does | Output | +| --- | --- | --- | --- | +| `/review` | Repository users with write, maintain, or admin access | Queues the full MAUI Copilot PR review pipeline. | Updates the PR with an `AI Summary` comment. | +| `/review ` | Repository users with write, maintain, or admin access | Queues the full review pipeline for a specific platform: `android`, `ios`, `catalyst`, or `windows`. | Updates the PR with an `AI Summary` comment. | +| `/review rerun` | Repository users with write, maintain, or admin access; or non-first-time PR authors on their own PR | Requests a fresh full review after comments, commits, or CI context changed. Applies the `s/agent-ready-for-rerun` label; the hourly scanner then triggers the review pipeline. | Adds or replaces a review session in the `AI Summary` comment. | +| `/review tests` | Repository users with write, maintain, or admin access | Reviews current CI/test failures and classifies whether they are likely PR-caused, unrelated, or insufficiently evidenced. | Adds or updates a `Test Failure Review` comment. | + +Community contributors can trigger `/review rerun` on their own PR after their first contribution. For `/review` and `/review tests`, only repository users with write access can trigger these commands. If you are a first-time contributor, ask a maintainer to run the relevant command for your PR. + +## Choosing the right command + +Use `/review` when you want the complete automated PR review. This is the normal entry point for maintainers reviewing a PR. + +Use `/review rerun` when the PR already has an AI review but something changed enough that the previous review may be stale. Typical reasons: + +- the author pushed new commits; +- the author replied to review feedback; +- CI or test results changed; +- a maintainer wants a deterministic fresh review session without manually interpreting older output. + +Use `/review tests` when the question is specifically about CI/test failures, for example: + +- "Is this failure likely caused by the PR?" +- "Is CI red because of a known flaky test?" +- "Did this PR introduce a missing snapshot/baseline?" +- "Are these failures unrelated infrastructure or existing failures?" + +Do not use `/review tests` as a substitute for a code review. It does not approve, request changes, apply labels, trigger reruns, or change the PR. It only posts evidence-based failure classification. + +## `/review`: full PR review + +### Trigger + +Comment `/review` on a pull request. + +Optional platform argument: + +```text +/review android +/review ios +/review catalyst +/review windows +``` + +You can also use explicit flags: + +```text +/review --platform ios +/review --branch main +``` + +The trigger is implemented by `.github/workflows/review-trigger.yml`. It: + +1. checks that the comment is on a pull request; +2. verifies the actor has `write`, `maintain`, or `admin` repository permission; +3. parses the platform and optional pipeline branch; +4. infers the platform from `platform/*` labels when no platform was supplied; +5. queues the DevDiv `maui-copilot` Azure DevOps pipeline; +6. minimizes (collapses) the command comment as resolved once authorized. + +The workflow intentionally does not handle `/review tests`; that subcommand is reserved for the test-failure review workflow. + +**Note**: Command comments are minimized (collapsed as "Resolved") after authorization to reduce conversation clutter while preserving the comment history for the automated rerun scanner. Unauthorized or malformed command comments remain fully visible. + +### Platform inference + +If you do not specify a platform, the trigger looks at PR labels: + +- `platform/iOS` -> `ios` +- `platform/macOS` -> `catalyst` +- `platform/android` -> `android` +- `platform/windows` -> `windows` + +If labels are inconclusive, it defaults to Android. If that is wrong for the PR, use an explicit platform argument. + +### What the review pipeline does + +The Azure DevOps review pipeline is defined in `eng/pipelines/ci-copilot.yml`. At a high level it has three stages: + +1. **ReviewPR**: checks out the PR, prepares the target platform, runs the Copilot PR review script, and publishes the initial review artifacts. +2. **RunDeepUITests**: runs detected UI test categories on the correct platform pool when the review identifies relevant UI tests. +3. **UpdateAISummaryComment**: updates the PR's `AI Summary` comment with review results and deep UI test results. + +The PR review script is `.github/scripts/Review-PR.ps1`. It orchestrates the core review phases: + +1. branch setup and PR merge for review; +2. UI category detection; +3. regression cross-reference; +4. gate verification; +5. candidate review and fix exploration; +6. AI summary posting; +7. review labels. + +The generated PR comment is a single session-based `AI Summary` comment. New runs replace the review and hide older sessions, keyed by the reviewed commit. + +## `/review rerun`: fresh full review + +Comment `/review rerun` when you want a new full review session after the PR changed. + +Operationally, `/review rerun` applies the `s/agent-ready-for-rerun` label to your PR. The hourly `rerun-review-scanner` workflow checks for eligible PRs with this label and triggers the review pipeline. This means your review may be delayed up to ~1 hour, or may not run if the PR is ineligible. + +If you need an immediate review, use `/review` instead (write access required). + +### Eligibility requirements + +The `/review rerun` workflow checks for **PR author activity** since the latest AI Summary or rerun checkpoint: + +- New commits (head SHA changed) +- New non-command comments from the PR author + +**Important**: Reviewer or maintainer reminder comments do NOT satisfy the rerun eligibility check. Only author activity triggers a rerun. + +When eligible, the workflow applies the `s/agent-ready-for-rerun` label. An automated hourly scanner processes queued reruns and triggers them when appropriate. + +### Automated rerun scanner + +The repository includes an hourly gh-aw workflow (`.github/workflows/rerun-review-scanner.md`) that: + +1. Queries PRs labeled `s/agent-ready-for-rerun` +2. Uses AI to decide `trigger` or `skip` for each PR +3. Triggers approved reruns via Azure DevOps +4. Cleans up queue labels and posts reactions + +This ensures reruns are processed automatically without manual intervention. + +### When to use `/review rerun` + +Use it when: + +- a previous AI summary is stale; +- the author pushed a fix after review feedback; +- the previous run analyzed the wrong commit or incomplete context; +- a maintainer wants to replace an older session with a fresh one for the current PR head. + +Avoid using it repeatedly without new commits or author comments. It consumes CI and agent capacity, and repeated identical runs are unlikely to add useful information. + +## `/review tests`: test-failure review + +### Trigger + +Comment `/review tests` on a pull request. + +The trigger is implemented by `.github/workflows/copilot-review-tests.md`, compiled to `.github/workflows/copilot-review-tests.lock.yml`. + +Because gh-aw slash commands match only the first command token, the workflow listens for `/review` and then neutrally skips unless the comment uses the canonical `/review tests` subcommand. The regular `/review` trigger excludes `/review tests` so the two workflows do not both run. + +**Note**: Like `/review`, the command comment is minimized (collapsed as "Resolved") after authorization to reduce conversation clutter. + +### What it does + +`/review tests` is comment-only. It does not: + +- approve or request changes; +- apply labels; +- trigger CI reruns; +- change code; +- start the full PR review pipeline. + +It gathers evidence from: + +- GitHub PR metadata, labels, changed files, and check rollup; +- Azure DevOps build metadata, timelines, and build logs; +- Helix references when available for device tests; +- optional authenticated AzDO data when `AZDO_TOKEN` or local Azure CLI auth is available; +- PR scope, including changed platforms, areas, and test files. + +Then it posts a `Test Failure Review` comment that classifies failures as: + +- **Likely PR-caused** +- **Likely unrelated** +- **Needs human investigation** +- **Insufficient data** + +The comment includes status badges, a short summary, a per-failure table, recommended action, and collapsible evidence details. + +### Local usage + +Maintainers can run the same flow locally: + +```powershell +pwsh .github/scripts/Review-Tests.ps1 -PRNumber 29800 -BuildId 1443464 +``` + +By default this writes local artifacts only: + +```text +CustomAgentLogsTmp/TestFailureReview//context.json +CustomAgentLogsTmp/TestFailureReview//context.md +CustomAgentLogsTmp/TestFailureReview//report.md +CustomAgentLogsTmp/TestFailureReview//comment.md +``` + +To post the generated comment: + +```powershell +pwsh .github/scripts/Review-Tests.ps1 -PRNumber 29800 -BuildId 1443464 -PostComment +``` + +To gather evidence without invoking Copilot: + +```powershell +pwsh .github/scripts/Review-Tests.ps1 -PRNumber 29800 -BuildId 1443464 -GatherOnly +``` + +Local runs can use Azure CLI to acquire an Azure DevOps bearer token. If available, the gatherer records that in the generated context. If builds are still inaccessible after authenticated access, the report should say so and classify affected checks as `Insufficient data`. + +### Interpreting `Insufficient data` + +`Insufficient data` means the workflow saw a failing check but did not have enough reliable evidence to attribute it. + +Common causes: + +- AzDO build records returned 404 or expired; +- logs were inaccessible; +- the build is still running; +- authenticated AzDO test APIs were unavailable; +- device-test failures may be hidden in Helix and no Helix data was available. + +Do not treat `Insufficient data` as "unrelated." It means a human or a rerun with better data is needed. + +## How to read the review comments + +### AI Summary + +The full `/review` and `/review rerun` pipeline posts an `AI Summary` comment. It may include: + +- gate status; +- UI test results; +- regression cross-reference; +- pre-flight context; +- code review findings; +- fix/candidate analysis; +- final recommendation. + +The review sessions are collapsed by default — expand the **Review Sessions** section to read the latest session, which is keyed to the current HEAD commit. Previous review comments are minimized and hidden as outdated. + +### Test Failure Review + +`/review tests` posts a separate `Test Failure Review` comment. This comment is intentionally separate from the `AI Summary` so readers can quickly answer, "Why is CI red?" without reading the full review. + +The top-level title is always: + +```markdown +## Tests Failure Analysis +``` + +The verdict details and "Test Failure Review" label live in badges and in the expanded review session. + +## Recommended workflow for maintainers + +1. Make sure the PR has appropriate `area-*` and `platform/*` labels. The agentic labeler normally handles this on PR open/reopen. +2. Run `/review` when a PR is ready for automated review. +3. Read the `AI Summary` comment and check whether the review found actionable issues. +4. If CI is red or ambiguous, run `/review tests` to get a focused failure-causality report. +5. If the author pushes fixes or comments materially change the context, run `/review rerun`. +6. Use human judgment for merge decisions. These workflows provide evidence and recommendations, not final approval authority. + +## Recommended workflow for community contributors + +1. Open the PR with a clear description and linked issue when possible. +2. Wait for labels and CI to run. +3. If you need an automated review, ask a maintainer to run `/review`. +4. If CI is red and you are unsure whether it is caused by your changes, ask a maintainer to run `/review tests`. +5. When an automated comment is posted, read the summary first, then expand evidence sections for details. +6. Push fixes or reply with clarifying information, then ask a maintainer whether `/review rerun` is useful. + +## Safety and trust boundaries + +The review automation analyzes untrusted PR code and untrusted comments. The workflows are designed so privileged writes happen through controlled steps and safe outputs. + +Important safeguards: + +- `/review` requires repository write-level permissions and queues a trusted AzDO pipeline. +- `/review tests` is comment-only and uses gh-aw safe outputs for PR comments. +- The full review pipeline keeps PR-controlled code separated from trusted scripts where possible. +- Review comments should be treated as assistant-generated evidence, not as a substitute for human review. + +## Troubleshooting + +| Symptom | Likely cause | What to do | +| --- | --- | --- | +| `/review` does nothing | The commenter does not have write/maintain/admin access, or the comment is not on a PR. | Ask a maintainer to run the command on the PR. | +| `/review` used the wrong platform | Platform labels were missing or ambiguous. | Re-run with an explicit platform, for example `/review ios`. | +| `/review tests` says `Insufficient data` | Build/log/Helix evidence was inaccessible or incomplete. | Re-run later, provide a build ID, or run locally with Azure CLI/AzDO auth. | +| The AI Summary looks stale | New commits or comments landed after the last review. | Run `/review rerun`. | +| There are multiple old AI Summary comments | Each comment holds only the latest session (keyed to its HEAD commit); previous review comments are minimized and hidden as outdated. | Expand the **Review Sessions** section in the newest comment — it reflects the current HEAD commit. | +| `/review rerun` didn't trigger | Only PR author activity (commits or non-command comments) satisfies eligibility. Reviewer comments don't trigger reruns. | Wait for author activity or use `/review` to force a new review. | +| Command comment is still visible | The commenter may lack authorization, or the command was malformed. | Check actor permissions and command syntax. Authorized commands are minimized after processing. | + +## Related files + +- `.github/workflows/review-trigger.yml` — GitHub comment trigger for `/review`. +- `eng/pipelines/ci-copilot.yml` — Azure DevOps PR review pipeline. +- `.github/scripts/Review-PR.ps1` — local script orchestrating full PR review phases. +- `.github/scripts/post-ai-summary-comment.ps1` — AI Summary comment formatter. +- `.github/workflows/copilot-review-tests.md` — gh-aw source for `/review tests`. +- `.github/workflows/rerun-review-scanner.md` — gh-aw hourly scanner for queued `/review rerun` requests. +- `.github/scripts/Resolve-RerunEligibility.ps1` — determines if a PR is eligible for rerun based on author activity. +- `.github/scripts/Query-RerunReadyPRs.ps1` — queries PRs labeled `s/agent-ready-for-rerun`. +- `.github/skills/review-test-failures/SKILL.md` — classification rubric for test-failure reviews. +- `.github/scripts/Review-Tests.ps1` — local runner for `/review tests`. +- `.github/docs/trigger-azdo-pipeline-setup.md` — OIDC setup for triggering AzDO pipelines from GitHub Actions. diff --git a/.github/docs/trigger-azdo-pipeline-setup.md b/.github/docs/trigger-azdo-pipeline-setup.md new file mode 100644 index 000000000000..7a2c7252262f --- /dev/null +++ b/.github/docs/trigger-azdo-pipeline-setup.md @@ -0,0 +1,225 @@ +# Triggering Azure DevOps Pipelines from GitHub Actions (No PAT) + +This guide explains how to invoke Azure DevOps pipelines (e.g. in **dnceng-public** or **DevDiv**) +from GitHub Actions using **OIDC federated credentials** — no PAT or stored secrets needed. + +## Architecture + +``` +GitHub Actions ──► GitHub OIDC Provider ──► Azure AD (federated credential) ──► AzDO REST API + (JWT id-token) (exchange for bearer token) (Run Pipeline) +``` + +1. The workflow requests an OIDC JWT from GitHub's token endpoint +2. The JWT is exchanged with Azure AD via the managed identity's federated credential +3. Azure AD returns a bearer token scoped to Azure DevOps +4. The bearer token is used to call the AzDO REST API to trigger the pipeline + +> **Important:** The `azure/login` GitHub Action may be **blocked by org policy** +> (e.g. in the `dotnet` org). The workflow uses **manual OIDC token exchange via +> `curl`** instead, which works everywhere that `id-token: write` is allowed. + +## Prerequisites + +- Azure CLI installed locally (for one-time setup) +- Access to an Azure subscription + resource group +- **Project Collection Administrator** (or delegated) access in the target AzDO org to add users +- GitHub repo admin access to configure secrets + +--- + +## Step 1: Create a User-Assigned Managed Identity + +```bash +# Choose your resource group and identity name +RG="rg-maui-automation" +IDENTITY_NAME="id-maui-azdo-trigger" +LOCATION="eastus" + +# Create the resource group if it doesn't exist +az group create --name $RG --location $LOCATION + +# Create the managed identity +az identity create --name $IDENTITY_NAME --resource-group $RG --location $LOCATION + +# Capture the IDs you'll need +CLIENT_ID=$(az identity show --name $IDENTITY_NAME --resource-group $RG --query clientId -o tsv) +PRINCIPAL_ID=$(az identity show --name $IDENTITY_NAME --resource-group $RG --query principalId -o tsv) +TENANT_ID=$(az account show --query tenantId -o tsv) +SUBSCRIPTION_ID=$(az account show --query id -o tsv) + +echo "CLIENT_ID: $CLIENT_ID" +echo "PRINCIPAL_ID: $PRINCIPAL_ID" +echo "TENANT_ID: $TENANT_ID" +echo "SUBSCRIPTION_ID: $SUBSCRIPTION_ID" +``` + +## Step 2: Add OIDC Federated Credential for GitHub Actions + +This lets GitHub Actions authenticate as the identity without storing any secrets. + +> **Critical: Subject claim is CASE-SENSITIVE.** The GitHub username/org in the +> subject must match the exact casing used by GitHub (e.g. `JanKrivanek` not +> `jankrivanek`). A mismatch produces `AADSTS70021`. + +> **Microsoft tenant restriction:** For managed identities in the Microsoft +> corporate tenant (`72f988bf-...`), the OIDC token must include an `enterprise` +> claim with value `microsoft`, `github`, or `microsoftopensource`. Personal forks +> outside these GitHub Enterprise orgs will fail with `AADSTS7002381`. +> This means **only repos in `dotnet`, `microsoft`, etc. orgs work** — not personal forks. + +```bash +# Allow from main branch +az identity federated-credential create \ + --name github-actions-main \ + --identity-name $IDENTITY_NAME \ + --resource-group $RG \ + --issuer "https://token.actions.githubusercontent.com" \ + --subject "repo:dotnet/maui:ref:refs/heads/main" \ + --audiences "api://AzureADTokenExchange" +``` + +> **Subject claim mapping:** The OIDC token's `sub` claim is what Azure AD matches +> against the `--subject` parameter. For `issue_comment` events (like the `/review` +> command), the workflow runs from the default branch, so the subject is +> `repo:dotnet/maui:ref:refs/heads/main`. For `pull_request` events, the subject +> would be `repo:dotnet/maui:pull_request`. This is why the case-sensitivity +> warning above is critical — the `sub` claim value must match exactly. + +Add more federated credentials for other branches or trigger types as needed: + +```bash +# Specific dev branch +az identity federated-credential create \ + --name github-actions-dev-branch \ + --identity-name $IDENTITY_NAME \ + --resource-group $RG \ + --issuer "https://token.actions.githubusercontent.com" \ + --subject "repo:dotnet/maui:ref:refs/heads/dev/myteam/feature" \ + --audiences "api://AzureADTokenExchange" + +# Pull request events +az identity federated-credential create \ + --name github-actions-pr \ + --identity-name $IDENTITY_NAME \ + --resource-group $RG \ + --issuer "https://token.actions.githubusercontent.com" \ + --subject "repo:dotnet/maui:pull_request" \ + --audiences "api://AzureADTokenExchange" + +# GitHub environment (recommended for production — enables approval gates) +az identity federated-credential create \ + --name github-actions-env-azdo \ + --identity-name $IDENTITY_NAME \ + --resource-group $RG \ + --issuer "https://token.actions.githubusercontent.com" \ + --subject "repo:dotnet/maui:environment:azdo-trigger" \ + --audiences "api://AzureADTokenExchange" +``` + +## Step 3: Add the Identity to Azure DevOps + +The managed identity must be added as a user in **each** AzDO organization you want to trigger pipelines in. + +### Adding the identity + +1. Go to the AzDO org → **Organization Settings** → **Users** +2. Click **Add users** +3. Search for the managed identity by its **display name** +4. Set **Access level** to **Basic** (see note below) +5. Add the user to the target project +6. Click **Add** + +> **Critical: Access level must be Basic, not Stakeholder.** Stakeholder access +> does not grant sufficient permissions for build operations. Even with explicit +> "Queue builds" permissions, Stakeholder-level identities get `TF215106: Access +> denied` errors. Request **Basic** access when filing the request. + +> **Important:** Use the identity's **Object (Principal) ID** from the +> **Enterprise Applications** pane in Entra admin center — NOT the App +> Registration object ID. + +### Grant Build Queue Permission + +The identity needs **"Queue builds"** permission on the target pipeline(s): + +1. Go to the project → **Pipelines** → find the target pipeline +2. Click the **⋮** menu → **Manage security** +3. Find your managed identity user +4. Set **"Queue builds"** to **Allow** + +### Per-organization requirements + +| AzDO Organization | Project | Example Pipelines | +|---|---|---| +| `dnceng-public` | `public` | 302 (maui-pr), 314 (maui-pr-devicetests) | +| `DevDiv` | `DevDiv` | 27723 | + +## Step 4: Set GitHub Repository Secrets + +In **dotnet/maui** → **Settings** → **Secrets and variables** → **Actions**, add: + +| Secret Name | Value | +|---|---| +| `AZDO_TRIGGER_CLIENT_ID` | The managed identity's Client ID | +| `AZDO_TRIGGER_TENANT_ID` | Your Azure AD Tenant ID | +| `AZDO_TRIGGER_SUBSCRIPTION_ID` | Your Azure Subscription ID | + +> Using distinct secret names (prefixed with `AZDO_TRIGGER_`) avoids conflicts +> with any existing `AZURE_*` secrets in the repo. + +## Step 5: Create the GitHub Actions Workflow + +See [`.github/workflows/review-trigger.yml`](../workflows/review-trigger.yml) for a ready-to-use workflow. + +## How It Works (Token Flow) + +``` +1. Workflow declares `permissions: { id-token: write }` at job level +2. Step 1 requests an OIDC JWT from GitHub's token endpoint via + $ACTIONS_ID_TOKEN_REQUEST_URL (audience: api://AzureADTokenExchange) +3. Step 2 sends the JWT to Azure AD token endpoint as a client_assertion + (grant_type=client_credentials) for the managed identity's client_id +4. Azure AD validates the JWT against the federated credential and returns + a bearer token scoped to AzDO (resource: 499b84ac-1321-427f-aa17-267ca6975798) +5. Step 3 calls POST dev.azure.com/{org}/{project}/_apis/pipelines/{id}/runs + with the bearer token +6. AzDO validates the token, checks the identity's permissions, and queues the build +``` + +> **Why not `azure/login`?** The `dotnet` GitHub org restricts which third-party +> Actions can run. `azure/login@v3` causes `startup_failure` because it's not in +> the org's allowed actions list. The manual `curl`-based OIDC exchange achieves +> the same result without any third-party dependencies. + +## Troubleshooting + +| Symptom | Cause | Fix | +|---------|-------|-----| +| `startup_failure` (no logs at all) | Third-party Action blocked by org policy | Don't use `azure/login`. Use manual `curl`-based OIDC exchange. | +| `AADSTS70021: No matching federated identity record found` | Subject claim case mismatch | Federated credential subject is **case-sensitive**. Use exact GitHub username casing (e.g. `JanKrivanek` not `jankrivanek`). | +| `AADSTS7002381: ... enterprise claim ... actual value is ''` | Personal fork outside GitHub Enterprise | Microsoft tenant requires `enterprise` claim. Only repos in `dotnet`, `microsoft`, etc. GitHub Enterprise orgs work. | +| `TF215106: Access denied. needs Queue builds permissions` | Stakeholder access level or missing permission | Upgrade identity to **Basic** access (not Stakeholder). Verify "Queue builds" is explicitly allowed on the pipeline. | +| `TF401444: Sign-in required` | Identity not added to AzDO org | Add the MI as a user in the AzDO Organization Settings → Users. | +| `403` from AzDO REST API | Missing permissions | Ensure the identity has "Queue builds" on the specific pipeline AND Basic access level. | +| `OIDC environment variables not available` | Missing `id-token: write` permission | Add `permissions: { id-token: write }` at the **job** level (not workflow level). | +| `Failed to get Azure AD token` | Wrong client_id/tenant_id or federated credential mismatch | Verify secrets match the MI's Client ID and Tenant ID. Check federated credential subject matches the actual OIDC claim. | + +## Lessons Learned + +1. **`azure/login` Action is blocked** in the `dotnet` GitHub org — use manual + `curl`-based OIDC token exchange instead. +2. **Federated credential subjects are case-sensitive** — `JanKrivanek` ≠ + `jankrivanek`. Always verify exact GitHub username/org casing. +3. **Microsoft tenant requires GitHub Enterprise membership** — personal forks + fail with `AADSTS7002381`. Only repos in enterprise-managed orgs work. +4. **Stakeholder access is insufficient** — even with explicit "Queue builds" + permissions, Stakeholder-level identities get `TF215106`. Request Basic. +5. **Add identity to EACH AzDO org separately** — permissions in `dnceng-public` + don't carry over to `DevDiv` and vice versa. + +## References + +- [Use service principals and managed identities in Azure DevOps](https://learn.microsoft.com/en-us/azure/devops/integrate/get-started/authentication/service-principal-managed-identity) +- [AzDO Pipelines REST API — Run Pipeline](https://learn.microsoft.com/en-us/rest/api/azure/devops/pipelines/runs/run-pipeline?view=azure-devops-rest-7.1) +- [GitHub OIDC token docs](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect) diff --git a/.github/instructions/android.instructions.md b/.github/instructions/android.instructions.md index ba013f3c27a0..93d94cd3a19f 100644 --- a/.github/instructions/android.instructions.md +++ b/.github/instructions/android.instructions.md @@ -4,6 +4,9 @@ applyTo: - "**/Android/**/*.cs" - "**/Platforms/Android/**/*.cs" - "**/Platform/Android/**/*.cs" + - "**/AndroidNative/**" + - "eng/init.gradle" + - "eng/ingest-maven-deps.sh" --- # Android Platform Development Guidelines @@ -123,3 +126,8 @@ protected override void DisconnectHandler(RecyclerView platformView) | Listener not working | Check lifecycle (register/unregister) | | Memory leak | Ensure Dispose() called on Java.Lang.Object | | Threading error | Use `platformView.Post()` for UI thread | +| Gradle 401 / Maven dependency failure | Run `./eng/ingest-maven-deps.sh` — see `copilot-instructions.md` | + +## Gradle / Maven Dependency Failures + +CI uses CFSClean which blocks Maven Central. All deps go through the `dotnet-public-maven` Azure Artifacts feed. If a new package hasn't been ingested, CI fails with `XAGRDL0000` / 401. Run `./eng/ingest-maven-deps.sh` locally to fix. Do NOT upgrade Gradle past 8.x (`dotnet/android#10738`). diff --git a/.github/instructions/ci-copilot-pipeline-security.instructions.md b/.github/instructions/ci-copilot-pipeline-security.instructions.md new file mode 100644 index 000000000000..b77f09904152 --- /dev/null +++ b/.github/instructions/ci-copilot-pipeline-security.instructions.md @@ -0,0 +1,52 @@ +--- +description: "Security rules for the Copilot PR-review pipeline. Read before editing." +applyTo: "eng/pipelines/ci-copilot.yml,eng/scripts/detect-ui-test-categories.ps1,.github/scripts/**,.github/pr-review/**,.github/skills/pr-review/**,.github/skills/verify-tests-fail-without-fix/**,.github/skills/try-fix/**,.github/skills/run-device-tests/**,.github/workflows/review-trigger.yml,.github/workflows/pr-review-queue.yml,.github/workflows/copilot-evaluate-tests.*" +--- + +# CI Copilot pipeline — security rules + +This pipeline runs **untrusted PR code** on AzDO agents with these tokens in scope: + +- `GH_COMMENT_TOKEN` / `GH_TOKEN` — `maui-bot` PAT (post comments, labels, reviews on any PR) +- `COPILOT_GITHUB_TOKEN` — Copilot CLI install token +- AzDO GitHub service-connection PAT — repo contents, PRs, checks, workflows + +Once the PR is merged into the worktree, the author controls every `.csproj`, `Directory.Build.targets`, source generator, analyzer, test, `.ps1`, and `.yml` the pipeline subsequently runs. + +## Rules + +1. **Per-task `env:` scoping.** Only put tokens a task needs. The Copilot-agent task gets `COPILOT_GITHUB_TOKEN` only — never `GH_TOKEN`. Pass `--secret-env-vars=GH_TOKEN,GITHUB_TOKEN,COPILOT_GITHUB_TOKEN` to the Copilot CLI. + +2. **`persistCredentials: false` on every `checkout: self`** unless the task pushes. Default checkout writes the service-connection PAT into `.git/config` as `extraheader`, readable by any subprocess. + +3. **Trusted-copy scripts before merging the PR.** Setup task (still on `main`) copies `.github/scripts`, `.github/skills`, `eng/scripts` to `$(Build.ArtifactStagingDirectory)/trusted-github/`, then `chmod -R a-w`. Later tasks invoke scripts from `$TRUSTED/...`, never from the merged worktree. In PowerShell use `$ScriptsDir` / `$SkillsDir` / `$EngScriptsDir` (canonical impl in `Review-PR.ps1`). New post-merge scripts must be added to the Setup copy block. + +4. **Strip tokens before invoking PR-controlled code.** Wrap every `dotnet build|test|run|pack`, `msbuild`, `dotnet cake`, `BuildAndRun*.ps1`, `Run-DeviceTests.ps1`, `Invoke-UITestWithRetry.ps1` in `Invoke-WithoutGhTokens { ... }` (defined in `Review-PR.ps1` and `verify-tests-fail.ps1` — saves/clears/restores `GH_TOKEN`, `GITHUB_TOKEN`, `COPILOT_GITHUB_TOKEN`). **Wrap as close to the subprocess as possible, not at the outer trusted-script boundary** — a trusted script may itself need `gh` for metadata (e.g., `verify-tests-fail.ps1` calls `Detect-TestsInDiff.ps1` which uses `gh api`), so wrapping the whole script breaks its detection path. Wrap only the line that launches the PR-controlled process. Exception: scripts that ONLY call `gh` for PR metadata (`Detect-TestsInDiff.ps1`, `Find-RegressionRisks.ps1`, `detect-ui-test-categories.ps1`) don't need wrapping at all — they keep the token. + +5. **Cross-phase signal files in `$(Agent.TempDirectory)`** (or `$TRUSTED`), never `$RepoRoot/...`. PR code can overwrite anything in the worktree, including a gate verdict. Readers must not silently fall back to a worktree path if the trusted one is missing. + +6. **Strip `##vso[...]` from PR-controlled stdout.** Pipe through `tr -d '\r' | sed -E 's/##vso\[[^]]*\]//g'` — bare `sed` misses CRLF lines and the agent will execute the directive. + +7. **`gh-aw` workflows.** Pin compiler version (≥ v0.68.4 strips `pull-requests: write` per `gh-aw#28767`). Regenerate `.lock.yml` with `gh aw compile` in the **same commit** as any `.md` frontmatter edit (stale lock ⇒ all dispatches fail). `workflow_dispatch` triggers must restore trusted `.github/` from main (see `Checkout-GhAwPr.ps1`). + +8. **No token republish.** Don't `setvariable` a token (visible to every later task, even with `issecret=true`). Don't write tokens to worktree files. Don't echo token names. + +## Review checklist + +- [ ] New `checkout: self` has `persistCredentials: false`. +- [ ] New `env:` block lists only the tokens that task needs; Copilot task has no `GH_TOKEN`. +- [ ] New post-merge script invoked via `$ScriptsDir` / `$SkillsDir` / `$EngScriptsDir`, not `$RepoRoot/...`, AND added to Setup copy block. +- [ ] New invocation of PR-controlled code (`dotnet test|build|run`, `BuildAndRun*`, `Run-DeviceTests`, `Invoke-UITestWithRetry`) is wrapped in `Invoke-WithoutGhTokens` AT THE CALL SITE (not at an outer boundary). +- [ ] New cross-phase state file lives under `$(Agent.TempDirectory)` / `$TRUSTED`. +- [ ] New PR-stdout pipe uses `tr -d '\r' | sed -E 's/##vso\[[^]]*\]//g'`. +- [ ] Edited `.github/workflows/*.md` has matching `.lock.yml` regenerated in same commit. + +## Grep these during review + +```bash +git grep -nE 'dotnet (test|build|run|pack)' eng/pipelines/ci-copilot.yml .github/scripts .github/skills | grep -v Invoke-WithoutGhTokens +git grep -nE 'Join-Path \$RepoRoot ".*\.(ps1|sh)"' .github/scripts .github/skills +git grep -nA1 'checkout: self' eng/pipelines/ci-copilot.yml | grep -v persistCredentials +git grep -nE 'Set-Content.*\$RepoRoot.*(gate-result|sentinel|verdict)' .github/scripts .github/skills +git grep -nE 'sed.*##vso' eng/pipelines/ci-copilot.yml | grep -v 'tr -d' +``` diff --git a/.github/instructions/collectionview-android.instructions.md b/.github/instructions/collectionview-android.instructions.md new file mode 100644 index 000000000000..1c7e53e88636 --- /dev/null +++ b/.github/instructions/collectionview-android.instructions.md @@ -0,0 +1,32 @@ +--- +applyTo: + - "src/Controls/src/Core/Handlers/Items/Android/**" + - "src/Controls/src/Core/Handlers/Items/*.Android.cs" + - "src/Controls/src/Core/Handlers/Items/*.android.cs" +--- +# CollectionView — Android (Items/ Handler) + +> Items/ is the sole Android handler — see `collectionview-handler-detection.instructions.md` for the full platform→handler mapping. + +## RecyclerView Adapter Patterns +- Use range-specific notifications (`NotifyItemRangeInserted`, `NotifyItemRangeRemoved`, `NotifyItemRangeChanged`) when INCC semantics provide exact affected ranges — prefer these over `NotifyDataSetChanged` for preserving scroll position and animations +- Full refresh via `NotifyDataSetChanged` is valid for `Reset` actions, ambiguous index cases, and header/footer template changes +- Handle all `ObservableCollection` change actions: Add, Remove, Replace, Move, Reset +- On `Reset`, recalculate adapter state from scratch — do not assume incremental consistency + +## ViewHolder Recycling +- `BindingContext` MUST be updated on every rebind — stale data from a previous holder is a common source of visual glitches +- Do not store item-specific state in the ViewHolder outside of `BindViewHolder` — recycled holders carry state from previous items +- Dispose and recreate platform views only when the template changes, not on every rebind + +## Layout Manager +- Select layout manager (Linear, Grid, custom) based on `ItemsLayout` specification — do not hardcode +- `ItemsLayout` property changes require full layout manager replacement, not partial reconfiguration +- Account for Android pixel rounding in item measurement — fractional dp values cause 1px gaps + +## Memory and Lifecycle +- Unsubscribe from `ScrollChange` and adapter observers in `DisconnectHandler` — do NOT call Dispose on platform objects +- Scroll position restoration after adapter data changes must handle empty-collection edge case + +## Regression Patterns +- Test across empty collection, single item, many items, and with grouping — a fix for one layout scenario routinely breaks another diff --git a/.github/instructions/collectionview-ios.instructions.md b/.github/instructions/collectionview-ios.instructions.md new file mode 100644 index 000000000000..3c7ac2911020 --- /dev/null +++ b/.github/instructions/collectionview-ios.instructions.md @@ -0,0 +1,34 @@ +--- +applyTo: + - "src/Controls/src/Core/Handlers/Items2/**" + - "src/Controls/src/Core/Handlers/Items/iOS/**" + - "src/Controls/src/Core/Handlers/Items/*.iOS.cs" + - "src/Controls/src/Core/Handlers/Items/*.ios.cs" +--- +# CollectionView — iOS/MacCatalyst (Items2/ Handler) + +> New iOS/MacCatalyst work targets Items2/. Items/iOS/ is deprecated. See `collectionview-handler-detection.instructions.md` for the full platform→handler mapping. + +## UICollectionView Cell Measurement +- Scope cell layout invalidation to the affected cell — avoid `InvalidateLayout()` on the entire collection for single-cell changes +- Custom `UICollectionViewCell` subclasses should handle `MeasureInvalidated` only when the hosted MAUI control actually needs remeasuring +- Cell sizing must stay in sync between the measure pass and the layout pass — out-of-sync causes visual glitches + +## UICollectionViewCompositionalLayout +- Layout configuration must match the `ItemsLayout` specification (Linear, Grid, or custom) +- `ItemsLayout` property changes require full layout reconfiguration — partial updates leave stale section configuration +- Group header/footer template changes must invalidate the correct section scope, not the entire layout + +## Memory Management +- Use static callback patterns to avoid retain cycles between cells and their hosting handler +- Remove `NSNotificationCenter` observers in `DisconnectHandler` +- Weak references for long-lived observers of short-lived cells — cells are recycled and reused + +## Regression Patterns +- Test across empty collection, single item, many items, and with grouping — a fix for one layout scenario routinely breaks another + +## Items/ iOS (Deprecated) +- Files in `Handlers/Items/*.iOS.cs` are deprecated — prefer Items2/ for new work +- Only modify Items/ iOS code for explicit legacy maintenance or backward-compatibility fixes + +> **Platform file extension rules** (`.ios.cs` vs `.maccatalyst.cs` compilation targets) are defined in `copilot-instructions.md` § Platform-Specific File Extensions. See also `collectionview-handler-detection.instructions.md` for which handler directory to target per platform. diff --git a/.github/instructions/collectionview-windows.instructions.md b/.github/instructions/collectionview-windows.instructions.md new file mode 100644 index 000000000000..a9eff1612549 --- /dev/null +++ b/.github/instructions/collectionview-windows.instructions.md @@ -0,0 +1,26 @@ +--- +applyTo: + - "src/Controls/src/Core/Handlers/Items/*.Windows.cs" + - "src/Controls/src/Core/Handlers/Items/*.windows.cs" +--- +# CollectionView — Windows (Items/ Handler) + +Items/ is the **ONLY** Windows CollectionView implementation. Items2/ has NO Windows code. + +## WinUI ListView/ItemsRepeater Patterns +- Preserve WinUI XAML styles applied via native theming — clearing a MAUI property must restore the style-applied value, not a hardcoded default +- `double.NaN` is the WinUI convention for unconstrained dimensions — do not confuse with MAUI's `double.PositiveInfinity` +- Use `DispatcherQueue.TryEnqueue` for deferred UI thread work — do not use `Dispatcher.BeginInvoke` + +## Data Source and Change Notifications +- Handle all `ObservableCollection` change actions (Add, Remove, Replace, Move, Reset) with range-scoped updates +- Avoid full source refresh (`NotifyDataSetChanged` equivalent) — it kills selection state and scroll position +- Selection mode changes must propagate correctly to the native `SelectionMode` property + +## Layout Configuration +- `ItemsLayout` changes require reconfiguration of the underlying panel (e.g., `ItemsWrapGrid`, `ItemsStackPanel`) +- Verify that `ItemsLayout.Span` for grid layouts maps correctly to WinUI's `MaximumRowsOrColumns` + +## Cross-Platform Consistency +- Default values for control properties must produce the same visual result as Android and iOS +- Event firing order (selection changed, scrolled) should match other platforms for the same user interaction diff --git a/.github/instructions/handler-patterns.instructions.md b/.github/instructions/handler-patterns.instructions.md new file mode 100644 index 000000000000..986d9d8e8e9c --- /dev/null +++ b/.github/instructions/handler-patterns.instructions.md @@ -0,0 +1,36 @@ +--- +applyTo: + - "src/Core/src/Handlers/**" + - "src/Controls/src/Core/Handlers/**" +--- +# Handler Mapper and Property Patterns + +## Property Update Flow +- Property updates MUST go through `Handler.UpdateValue(nameof(Property))` — never call mapper methods directly +- Direct calls bypass user-registered `AppendToMapping`/`PrependToMapping` customizations +- Map dependencies before dependents — if property B reads property A, A's mapper must run first +- `CommandMapper` entries return void and use the `(handler, view, args)` signature + +## Lifecycle Management +- **ConnectHandler**: Register listeners, subscribe to events, capture native defaults BEFORE applying cross-platform properties +- **DisconnectHandler**: Unsubscribe all events, dispose platform resources, null out references +- Call `base.ConnectHandler`/`base.DisconnectHandler` — base class performs platform hookup/teardown (NOT mapper initialization, which happens in `SetVirtualView`) + +## Null Safety in Callbacks +- Null-check `VirtualView` before access in every mapper method and platform callback — it is null during disconnect +- Validate `MauiContext` before use — throw `InvalidOperationException` with descriptive message if null +- After async operations, verify the handler is still connected before applying results + +## Native Defaults Preservation +- Capture native default values (colors, fonts, styles) in `ConnectHandler` BEFORE any cross-platform property is applied +- Clearing a cross-platform property (setting to null/default) must restore the captured native default, not a hardcoded fallback +- On Windows, preserve WinUI XAML style-applied values; on Android, cache theme-inherited drawables; on iOS, capture UIAppearance defaults + +## Mapper Extensibility +- When extending existing mappers, ensure the base mapper chain is called — do not silently replace +- Static mapper methods should be `public static` to enable platform-specific overrides +- Use `nameof()` for property keys — avoid magic strings + +## Fire-and-Forget Async +- Use `.FireAndForget(handler)` for async operations in mapper methods — never use bare `async void` +- The `FireAndForget` overload accepting a handler logs exceptions through the handler's service provider diff --git a/.github/instructions/layout-system.instructions.md b/.github/instructions/layout-system.instructions.md new file mode 100644 index 000000000000..6959fd540f3a --- /dev/null +++ b/.github/instructions/layout-system.instructions.md @@ -0,0 +1,30 @@ +--- +applyTo: + - "src/Core/src/Layouts/**" + - "src/Controls/src/Core/Layout/**" + - "src/Core/src/Handlers/Layout/**" +--- +# Layout System Rules + +## Measure/Arrange Contract +- `Measure(widthConstraint, heightConstraint)` returns the desired size — it must not mutate layout state or trigger side effects +- `ArrangeChildren(bounds)` positions children within the given bounds — it must respect the size returned from `Measure`, not compute independently +- A child measured with `widthConstraint=200` must not be arranged with `width=300` — constraints must stay consistent across passes +- Subtract padding, margin, and border thickness from constraints BEFORE passing to children + +## Constraint Propagation +- Stack layouts pass `double.PositiveInfinity` along the stacking axis and the parent constraint along the cross axis +- Grid cells compute per-cell constraints from column/row definitions — do not pass the full grid constraint to each child +- `MeasureContent` helpers on `IContentView` handle inset subtraction — use them instead of manual arithmetic +- On Windows, `double.NaN` represents unconstrained dimensions (WinUI convention) — do not confuse with `double.PositiveInfinity` (MAUI convention) + +## Infinite Loop Avoidance +- Never create circular dependencies where child size depends on parent AND parent size depends on child +- Layout invalidation (`InvalidateMeasure`) must not re-trigger during an active measure pass +- ScrollView content must always be re-measured on layout trigger — do not aggressively cache child measurements in scrollable containers + +## Performance on Hot Paths + +> Layout measure/arrange methods are hot paths. All rules from `performance-hotpaths.instructions.md` apply — no LINQ, closures, or allocations. Additionally for layout: +- Cache expensive computations (e.g., `GridStructure`) and invalidate only when inputs change +- Bindable property change handlers should skip layout invalidation when the value has not actually changed diff --git a/.github/instructions/performance-hotpaths.instructions.md b/.github/instructions/performance-hotpaths.instructions.md new file mode 100644 index 000000000000..11632fb978b1 --- /dev/null +++ b/.github/instructions/performance-hotpaths.instructions.md @@ -0,0 +1,34 @@ +--- +applyTo: + - "src/Core/src/Layouts/**" + - "src/Core/src/Platform/**" + - "src/Controls/src/Core/Handlers/Items/**" + - "src/Controls/src/Core/Handlers/Items2/**" + - "src/Controls/src/Core/ScrollView/**" +--- +# Performance-Critical Path Rules + +> **Scope rationale**: globs target the actual hot paths — layout measure/arrange, +> platform scroll/touch callbacks, CollectionView/CarouselView (Items + Items2) +> recycling, and ScrollView. The full handler tree is intentionally NOT included; +> most handlers (Button, DatePicker, CheckBox, …) are not hot paths and don't need +> these constraints. If you're working on a handler that IS allocation-sensitive +> (image decoding, animation tick, etc.), apply these rules anyway. + +## Hot Paths in MAUI +Measure/arrange cycles, scrolling callbacks, binding propagation, and property change notifications are called at high frequency. All rules below apply to code on these paths. + +## Allocation Avoidance +- No LINQ methods (`.Where`, `.Select`, `.FirstOrDefault`, `.ToList`) — use indexed `for` loops +- No closures or lambdas that capture variables — these allocate a compiler-generated class per invocation +- No string concatenation or interpolation — use `StringBuilder` or pre-allocated strings if logging is required +- Prefer `Count` + indexer over `IEnumerable` iteration to avoid enumerator allocation + +## Caching and Invalidation +- Cache results of expensive computations called multiple times per layout pass +- Invalidate caches when inputs change — stale caches cause incorrect layout +- Skip redundant work: if a property's new value equals the old value, do not trigger layout invalidation or re-render + +## Collection Iteration +- When the source implements `IList` or `IReadOnlyList`, use `for (int i = 0; i < list.Count; i++)` instead of `foreach` +- `ObservableCollection` change handlers should process only the affected range, not re-enumerate the entire collection diff --git a/.github/instructions/public-api.instructions.md b/.github/instructions/public-api.instructions.md new file mode 100644 index 000000000000..3edd33ba70d0 --- /dev/null +++ b/.github/instructions/public-api.instructions.md @@ -0,0 +1,36 @@ +--- +applyTo: + - "**/PublicAPI.Unshipped.txt" + - "src/Core/src/**/*.cs" + - "src/Controls/src/**/*.cs" + - "src/Essentials/src/**/*.cs" +--- +# Public API Surface Design + +> **Activation guard**: only apply this guidance when the diff actually changes +> public API surface — `public`/`protected` types or members, interface +> definitions, builder/extension methods on public types, `[Obsolete]` markers, +> or `PublicAPI.*.txt` entries. The broad `.cs` globs exist so this guidance +> loads at the moment of API design (writing `public class Foo` in `Button.cs`), +> not just when the analyzer-generated `Unshipped.txt` is updated afterward. +> If the diff only changes internals or implementation details, ignore. + +## API Addition Rules +- New public APIs must have clear, demonstrated use cases — no speculative additions +- API naming must follow .NET design guidelines and be consistent with existing MAUI patterns +- Interfaces belong at `IView`/`IElement` level when behavior is cross-cutting — do not duplicate per-control + +## PublicAPI.Unshipped.txt +- Entries must exactly match the actual API shape (namespace, type, member signature) + +> For PublicAPI.Unshipped.txt file management workflow (never disable analyzers, `dotnet format analyzers`, revert-then-add pattern), see `copilot-instructions.md` § PublicAPI.Unshipped.txt File Management. + +## Obsolescence and Removal +- Deprecated APIs must go through `[Obsolete("message")]` with migration guidance before removal +- Include the replacement API or pattern in the obsolete message +- Breaking changes require explicit design justification documented in the PR + +## Visibility Decisions +- Default to `internal` — make `public` only when external consumers need access +- `protected` members in unsealed types are part of the public API surface — treat with same rigor +- Use `[EditorBrowsable(EditorBrowsableState.Never)]` for APIs that must be public for technical reasons but should not appear in IntelliSense diff --git a/.github/instructions/safe-area-ios.instructions.md b/.github/instructions/safe-area-ios.instructions.md new file mode 100644 index 000000000000..0ee81f2c462f --- /dev/null +++ b/.github/instructions/safe-area-ios.instructions.md @@ -0,0 +1,34 @@ +--- +applyTo: + - "**/Platform/iOS/MauiView.cs" + - "**/Platform/iOS/MauiScrollView.cs" + - "**/Platform/iOS/*SafeArea*" +--- + +# Safe Area Guidelines (iOS/macCatalyst) + +## Platform Differences + +| | macOS 14/15 | macOS 26+ | +|-|-------------|-----------| +| Title bar inset | ~28px | ~0px | +| Used in CI | ✅ | ❌ | + +Local macOS 26+ testing does NOT validate CI behavior. Fixes must pass CI on macOS 14/15. + +| Platform | `UseSafeArea` default | +|----------|-----------------------| +| iOS | `false` | +| macCatalyst | `true` | + +## Architecture (PR #34024) + +**`IsParentHandlingSafeArea`** — before applying adjustments, `MauiView`/`MauiScrollView` walk ancestors to check if any ancestor handles the **same edges**. If so, descendant skips (avoids double-padding). Edge-aware: parent handling `Top` does not block child handling `Bottom`. Result cached in `bool? _parentHandlesSafeArea`; cleared on `SafeAreaInsetsDidChange`, `InvalidateSafeArea`, `MovedToWindow`. `AppliesSafeAreaAdjustments` is `internal` for cross-type ancestor checks. + +**`EqualsAtPixelLevel`** — safe area compared at device-pixel resolution to absorb sub-pixel animation noise (`0.0000001pt` during `TranslateToAsync`), preventing oscillation loops (#32586, #33934). + +## Anti-Patterns + +**❌ Window Guard** — comparing `Window.SafeAreaInsets` to filter callbacks blocks legitimate updates. On macCatalyst + custom TitleBar, `WindowViewController` pushes content down, changing the **view's** `SafeAreaInsets` without changing the **window's**. Caused 28px CI shift (macOS 14/15 only). Never gate per-view callbacks on window-level insets. + +**❌ Semantic mismatch** — `_safeArea` is filtered by `GetSafeAreaForEdge` (zeroes edges per `SafeAreaRegions`); raw `UIView.SafeAreaInsets` includes all edges. Never compare them — compare raw-to-raw or adjusted-to-adjusted. diff --git a/.github/instructions/sandbox.instructions.md b/.github/instructions/sandbox.instructions.md index 6977fa1fdb1e..ddff19634ba2 100644 --- a/.github/instructions/sandbox.instructions.md +++ b/.github/instructions/sandbox.instructions.md @@ -170,7 +170,7 @@ Work with the Sandbox app for manual testing, PR validation, issue reproduction, ## Distinction: Code Review vs. Functional Testing -**Code Review** (pr agent): +**Code Review** (pr-review skill): - Analyzes code quality, patterns, best practices - Reviews test coverage and correctness - Checks for potential bugs or issues in the code itself diff --git a/.github/instructions/threading-async.instructions.md b/.github/instructions/threading-async.instructions.md new file mode 100644 index 000000000000..b9aeeb0282ce --- /dev/null +++ b/.github/instructions/threading-async.instructions.md @@ -0,0 +1,34 @@ +--- +applyTo: + - "**/*.Android.cs" + - "**/*.android.cs" + - "**/*.iOS.cs" + - "**/*.ios.cs" + - "**/*.Windows.cs" + - "**/*.windows.cs" + - "**/Platform/**" + - "**/Platforms/**" +--- +# Threading and Async Patterns + +## UI Thread Dispatch +- Platform view modifications MUST happen on the UI thread +- **Android**: `platformView.Post(() => { })` or `Looper.MainLooper` for thread checks +- **iOS/MacCatalyst**: `MainThread.BeginInvokeOnMainThread()` or `DispatchQueue.MainQueue` +- **Windows**: `DispatcherQueue.TryEnqueue()` — be aware of COM threading apartment model + +## Async Handler Operations +- Use `.FireAndForget(handler)` for async work in mapper methods — never bare `async void` +- After `await`, verify the handler is still connected (`VirtualView != null`) before applying results +- Thread `CancellationToken` through long-running operations and check at appropriate yield points + +## Race Condition Prevention +- Concurrent access to shared state must use `lock`, `Interlocked`, or immutable patterns +- Image loading and other async pipelines must cancel in-flight operations when the source changes +- Guard against stale callbacks — platform callbacks may fire after `DisconnectHandler` + +## Platform-Specific Patterns +- **Android**: API-level checks use `Build.VERSION.SdkInt` comparison, not version string parsing +- **iOS**: Use `System.OperatingSystem.IsIOSVersionAtLeast()` for linker-friendly runtime checks +- **Windows**: WinUI version checks may be needed for features in specific Windows App SDK versions +- Use `System.OperatingSystem` APIs over `RuntimeInformation` — they are trimmer/AOT-friendly diff --git a/.github/instructions/uitests.instructions.md b/.github/instructions/uitests.instructions.md index f69bcb4f4aee..3b0e5c7747d7 100644 --- a/.github/instructions/uitests.instructions.md +++ b/.github/instructions/uitests.instructions.md @@ -523,6 +523,17 @@ cat /tmp/ios_crash.log | grep -A 20 -B 5 "Exception" 5. **Check for platform-specific issues** - iOS version compatibility, permissions, etc. 6. If you can't determine the fix, **ask for guidance** with the full exception details +### Dangerous System Commands (Never Run) + +**🚨 NEVER run these commands — they cause destructive system-wide side effects:** + +- **`tccutil reset`** — Wipes ALL macOS permissions (Accessibility, Camera, etc.) system-wide. This breaks Appium/WebDriverAgent, Xcode, and other tools. Once reset, permissions must be manually re-granted through System Settings. +- **`csrutil disable`** — Disables System Integrity Protection +- **`networksetup`** — Modifies network configuration +- **`defaults delete`** on system domains — Resets system preferences + +**General rule:** Do not run commands that modify macOS system-level privacy, security, or permission settings. If you need to check permissions, read them — never reset or modify them. + ## Before Committing Verify the following checklist before committing UI tests: @@ -720,3 +731,45 @@ grep -r "UITestEntry\|UITestEditor\|UITestSearchBar" src/Controls/tests/TestCase - Common helper methods - Platform-specific workarounds - UITest optimized control usage + +### Safe Area Testing (iOS/MacCatalyst) + +**⚠️ CRITICAL for macCatalyst safe area tests:** + +Safe area behavior differs significantly between macOS versions. Tests must account for this variability. + +| macOS Version | Title Bar Safe Area | CI Environment | +|---------------|---------------------|----------------| +| **macOS 14/15** | ~28px top inset | ✅ Used by CI | +| **macOS 26 (Liquid Glass)** | ~0px top inset | ❌ Local dev only | + +**Rules for safe area tests:** + +1. **Use tolerances for safe area measurements** - Exact pixel values vary by macOS version +2. **Test behavior, not exact values** - Verify content is NOT obscured, rather than checking exact padding pixels +3. **Use `GetRect()` for child content position** - Measure where content actually appears, not parent size +4. **Never hardcode safe area expectations** - Tests should pass on macOS 14/15 AND macOS 26 + +**Example patterns:** + +```csharp +// ❌ BAD: Hardcoded safe area value (breaks across macOS versions) +var safeArea = element.GetRect(); +Assert.That(safeArea.Y, Is.EqualTo(28)); // Fails on macOS 26 + +// ✅ GOOD: Test that content is not obscured by title bar +var contentRect = App.WaitForElement("MyContent").GetRect(); +var titleBarRect = App.WaitForElement("TitleBar").GetRect(); +Assert.That(contentRect.Y, Is.GreaterThanOrEqualTo(titleBarRect.Height), + "Content should not be obscured by title bar"); + +// ✅ GOOD: Use tolerance for safe area (accounts for OS differences) +Assert.That(contentRect.Y, Is.GreaterThan(0).And.LessThan(50), + "Content should have some top padding but not excessive"); +``` + +**Test category**: Use `UITestCategories.SafeAreaEdges` for safe area tests. + +**Platform scope**: Safe area tests should typically run on iOS and MacCatalyst (not just one). + +**See also**: `.github/instructions/safe-area-debugging.instructions.md` for investigation guidelines diff --git a/.github/plugin.json b/.github/plugin.json new file mode 100644 index 000000000000..687dc4af25d1 --- /dev/null +++ b/.github/plugin.json @@ -0,0 +1,6 @@ +{ + "name": "dotnet-maui-repo", + "version": "0.1.0", + "description": "Skills and agents for the dotnet/maui repository.", + "skills": ["./skills/"] +} diff --git a/.github/policies/resourceManagement.yml b/.github/policies/resourceManagement.yml index 59f088784c49..7d9edabb69ed 100644 --- a/.github/policies/resourceManagement.yml +++ b/.github/policies/resourceManagement.yml @@ -154,7 +154,7 @@ configuration: actions: - addReply: reply: >- - Hi @${issueAuthor}. + Hi ${issueAuthor}. It seems you haven't touched this PR for the last two weeks. To avoid accumulating old PRs, we're marking it as `stale`. As a result, it will be closed if no further activity occurs **within 4 days of this comment**. You can learn more about our Issue Management Policies [here](https://github.com/dotnet/maui/blob/main/docs/IssueManagementPolicies.md). - addLabel: @@ -272,7 +272,7 @@ configuration: label: s/needs-info then: - addReply: - reply: Hi @${issueAuthor}. We have added the "s/needs-info" label to this issue, which indicates that we have an open question for you before we can take further action. This issue will be closed automatically in 7 days if we do not hear back from you by then - please feel free to re-open it if you come back to this issue after that time. + reply: Hi ${issueAuthor}. We have added the "s/needs-info" label to this issue, which indicates that we have an open question for you before we can take further action. This issue will be closed automatically in 7 days if we do not hear back from you by then - please feel free to re-open it if you come back to this issue after that time. description: Add comment when 's/needs-info' is applied to issue - if: - payloadType: Issues @@ -281,7 +281,7 @@ configuration: then: - addReply: reply: >- - Hi @${issueAuthor}. We have added the "s/needs-repro" label to this issue, which indicates that we require steps and sample code to reproduce the issue before we can take further action. Please try to create a minimal sample project/solution or code samples which reproduce the issue, ideally as a GitHub repo that we can clone. See more details about creating repros here: https://github.com/dotnet/maui/blob/main/.github/repro.md + Hi ${issueAuthor}. We have added the "s/needs-repro" label to this issue, which indicates that we require steps and sample code to reproduce the issue before we can take further action. Please try to create a minimal sample project/solution or code samples which reproduce the issue, ideally as a GitHub repo that we can clone. See more details about creating repros here: https://github.com/dotnet/maui/blob/main/.github/repro.md This issue will be closed automatically in 7 days if we do not hear back from you by then - please feel free to re-open it if you come back to this issue after that time. @@ -357,7 +357,7 @@ configuration: then: - addReply: reply: >- - Thanks for the issue report @${issueAuthor}! This issue appears to be a problem with Visual Studio (Code), so we ask that you use the VS feedback tool to report the issue. That way it will get to the routed to the team that owns this experience in VS (Code). + Thanks for the issue report ${issueAuthor}! This issue appears to be a problem with Visual Studio (Code), so we ask that you use the VS feedback tool to report the issue. That way it will get routed to the team that owns this experience in VS (Code). If you encounter a problem with Visual Studio or the .NET MAUI VS Code Extension, we want to know about it so that we can diagnose and fix it. By using the Report a Problem tool, you can collect detailed information about the problem, and send it to Microsoft with just a few button clicks. @@ -386,7 +386,7 @@ configuration: then: - addReply: reply: >- - Hi @${issueAuthor}. We have added the "s/try-latest-version" label to this issue, which indicates that we'd like you to try and reproduce this issue on the latest available public version. This can happen because we think that this issue was fixed in a version that has just been released, or the information provided by you indicates that you might be working with an older version. + Hi ${issueAuthor}. We have added the "s/try-latest-version" label to this issue, which indicates that we'd like you to try and reproduce this issue on the latest available public version. This can happen because we think that this issue was fixed in a version that has just been released, or the information provided by you indicates that you might be working with an older version. You can install the latest version by installing the latest Visual Studio (Preview) with the .NET MAUI workload installed. If the issue still persists, please let us know with any additional details and ideally a [reproduction project](https://github.com/dotnet/maui/blob/main/.github/repro.md) provided through a GitHub repository. @@ -520,7 +520,7 @@ configuration: - addLabel: label: community ✨ - addReply: - reply: Hey there @${issueAuthor}! Thank you so much for your PR! Someone from the team will get assigned to your PR shortly and we'll get it reviewed. + reply: Hey there ${issueAuthor}! Thank you so much for your PR! Someone from the team will get assigned to your PR shortly and we'll get it reviewed. description: Add 'community ✨' label to community contributions - if: - payloadType: Pull_Request @@ -546,7 +546,7 @@ configuration: label: s/pr-needs-author-input then: - addReply: - reply: Hi @${issueAuthor}. We have added the "s/pr-needs-author-input" label to this issue, which indicates that we have an open question/action for you before we can take further action. This PRwill be closed automatically in 14 days if we do not hear back from you by then - please feel free to re-open it if you come back to this PR after that time. + reply: Hi ${issueAuthor}. We have added the "s/pr-needs-author-input" label to this PR, which indicates that we have an open question/action for you before we can take further action. This PR will be closed automatically in 14 days if we do not hear back from you by then - please feel free to re-open it if you come back to this PR after that time. description: Add comment when 's/pr-needs-author-input' is applied to PR - if: - payloadType: Issues diff --git a/.github/pr-review/pr-gate.md b/.github/pr-review/pr-gate.md new file mode 100644 index 000000000000..78f98ddc5d54 --- /dev/null +++ b/.github/pr-review/pr-gate.md @@ -0,0 +1,107 @@ +# PR Gate - Test Before and After Fix + +> **⛔ This phase MUST pass before continuing to Try-Fix. If it fails, stop and inform user.** + +> In CI (Review-PR.ps1), the gate runs `verify-tests-fail.ps1` directly as a script step. +> For manual usage, you can invoke it yourself or via a task agent. + +--- + +## Prerequisites + +- Pre-Flight phase must be ✅ COMPLETE before starting +- Platform must be selected (affected by bug AND available on host) + +### Platform Selection + +Choose a platform that is BOTH affected by the bug AND available on the current host: + +| Host OS | Available Platforms | +|---------|---------------------| +| Windows | Android, Windows | +| macOS | Android, iOS, MacCatalyst | + +⚠️ Do NOT test on a platform unaffected by the bug — the test will pass regardless. + +--- + +## Steps + +1. **Detect tests in PR** using the shared detection script: + ```bash + pwsh .github/scripts/shared/Detect-TestsInDiff.ps1 -PRNumber XXXXX + ``` + This auto-detects all test types: UI tests, device tests, unit tests, XAML tests. + If NO tests detected → inform user, suggest `write-tests-agent`. Gate is ⚠️ SKIPPED. + +2. **Select platform** — must be affected by bug AND available on host (see table above). + +3. **Run verification** via `verify-tests-fail.ps1`: + ```bash + pwsh .github/skills/verify-tests-fail-without-fix/scripts/verify-tests-fail.ps1 \ + -Platform {platform} -RequireFullVerification + ``` + In CI, `Review-PR.ps1` calls this script directly. For manual usage, you can also invoke + it via a task agent for isolation: + ``` + Invoke the `task` agent with this prompt: + + "Invoke the verify-tests-fail-without-fix skill for this PR: + - Platform: {platform} + - RequireFullVerification: true + + Report back: Did tests FAIL without fix? Did tests PASS with fix? Final status?" + ``` + +--- + +## If Gate Fails + +- **Tests PASS without fix** → Tests don't catch the bug. Inform user, suggest `write-tests-agent`. +- **Tests FAIL with fix** → PR's fix doesn't work. Skip Try-Fix, proceed to Report with ⚠️ REQUEST CHANGES. + +--- + +## Output File + +> 🚨 **CRITICAL OUTPUT RULES:** +> - Write gate results ONLY to `gate/content.md` — NEVER copy gate results into other phases (pre-flight, try-fix, report) +> - Use the EXACT template below — no extra explanations, no "Reason:" paragraphs, no "Notes:" sections +> - Keep it SHORT — the template is the complete output + +```bash +mkdir -p CustomAgentLogsTmp/PRState/{PRNumber}/PRAgent/gate +``` + +Write `content.md` using this **exact** template (fill in values, don't add anything else): + +```markdown +### Gate Result: {✅ PASSED / ❌ FAILED / ⚠️ SKIPPED} + +**Platform:** {platform} + +| # | Type | Test Name | Filter | +|---|------|-----------|--------| +| 1 | {type} | {name} | `{filter}` | + +| Step | Expected | Actual | Result | +|------|----------|--------|--------| +| Without fix | FAIL | {FAIL/PASS} | {✅/❌} | +| With fix | PASS | {FAIL/PASS} | {✅/❌} | +``` + +If gate is SKIPPED (no tests found), write only: + +```markdown +### Gate Result: ⚠️ SKIPPED + +No tests detected in PR. Suggest adding tests via `write-tests-agent`. +``` + +--- + +## Common Mistakes + +- ❌ Adding verbose explanations to gate/content.md — use the exact template above +- ❌ Copying gate results into try-fix/content.md or report/content.md — gate results belong ONLY in gate/content.md +- ❌ Skipping gate because tests are device tests, not UI tests — the skill supports all test types diff --git a/.github/pr-review/pr-preflight.md b/.github/pr-review/pr-preflight.md new file mode 100644 index 000000000000..0491bc37fe2d --- /dev/null +++ b/.github/pr-review/pr-preflight.md @@ -0,0 +1,160 @@ +# PR Pre-Flight — Context Gathering & Code Review + +> **SCOPE:** Gather context, classify files, and perform deep code review. No code changes. No fix selection. No test execution. + +--- + +## Part A: Context Gathering (Steps 1–6) + +1. **Read the issue** — full body + ALL comments via GitHub MCP tools +2. **Find the PR** — read description, diff summary, review comments, inline feedback +3. **Fetch PR discussion** — detect prior agent reviews, import findings if found +4. **Classify files** — separate fix files from test files, identify test type (UI / Device / Unit) +5. **Document edge cases** — from comments mentioning "what about...", "does this work with..." +6. **Record PR's fix** in Fix Candidates table (pending validation) +7. **Identify impacted UI test categories** — analyze which UI controls could be affected by this PR (see below) + +```bash +# Fetch PR metadata +gh pr view XXXXX --json title,body,url,author,labels,files + +# Find linked issue +gh pr view XXXXX --json body --jq '.body' | grep -oE "(Fixes|Closes|Resolves) #[0-9]+" | head -1 +gh issue view ISSUE_NUMBER --json title,body,comments + +# PR comments +gh pr view XXXXX --json comments --jq '.comments[] | "Author: \(.author.login)\n\(.body)\n---"' + +# Inline review comments (CRITICAL — often contains key technical feedback) +gh api "repos/dotnet/maui/pulls/XXXXX/comments" --jq '.[] | "File: \(.path):\(.line // .original_line)\nAuthor: \(.user.login)\n\(.body)\n---"' + +# Detect prior agent reviews +gh pr view XXXXX --json comments --jq '.comments[] | select(.body | contains("Final Recommendation") and contains("| Phase | Status |")) | .body' +``` + +**If prior agent review found:** Parse phase statuses, import findings, resume from incomplete phase. + +--- + +## Step 7: Identify Impacted UI Test Categories + +After classifying files, determine which UI test categories could be affected by the PR changes. This enables targeted UI test runs instead of running the full matrix (~2h). + +**How to identify categories:** +1. Look at the **controls modified** in the PR (e.g., changes to `Button` handler → `Button` category) +2. Consider **indirect impacts** (e.g., a layout change could affect `Layout`, `CollectionView`, `ListView`) +3. Check the **issue description** for mentions of specific controls +4. Consider **platform-specific impacts** (e.g., iOS SafeArea changes → `SafeAreaEdges`) + +**Available categories:** +Read the canonical list from [`src/Controls/tests/TestCases.Shared.Tests/UITestCategories.cs`](../../src/Controls/tests/TestCases.Shared.Tests/UITestCategories.cs) — every `public const string` value in that file is a valid category. Only use category names defined there; AI-suggested names that aren't in the file will be filtered out by `detect-ui-test-categories.ps1` to avoid creating empty matrix jobs. + +**Output file:** +```bash +mkdir -p CustomAgentLogsTmp/PRState/{PRNumber}/PRAgent/uitests +``` + +Write `ai-categories.md`: +```markdown +Button — PR modifies ButtonHandler click event logic +Layout — Changes to StackLayout could affect child arrangement +``` + +One category per line, followed by ` — ` and a brief justification. Write `NONE` if the PR has no UI impact (e.g., docs-only, build scripts, backend-only changes). + +--- + +## Part B: Code Review (Step 8) + +> **Purpose:** Perform deep code analysis using the `code-review` skill to surface correctness issues, safety concerns, and MAUI convention violations BEFORE Try-Fix explores alternatives. These findings guide Try-Fix models toward higher-quality fixes. + +> **🚨 Independence-first requirement:** Step 8 MUST be invoked as a **separate sub-agent** (via the `task` tool with `agent_type: "general-purpose"`) so the code-review skill can form its assessment from the code BEFORE reading any PR narrative. The sub-agent receives ONLY the PR number — not the context gathered in Part A. This prevents anchoring bias. +> +> **Validation constraint:** The Step 8 prompt MUST NOT contain issue titles, root-cause descriptions, bug summaries, or any Part A content — only `PR #XXXXX`. If you find yourself adding context "to help" the sub-agent, you are violating independence-first. + +8. **Invoke the code-review skill as a sub-agent:** + + Use the `task` tool to launch a separate agent. The prompt MUST NOT contain issue titles, root-cause descriptions, or any Part A context — only the PR number. + + ```python + task( + name="code-review", + description="Code review for PR", + agent_type="general-purpose", + mode="sync", + prompt=""" + Run the code-review skill for PR #XXXXX. + Follow the full 6-step workflow in .github/skills/code-review/SKILL.md. + Output the review in the format specified by that skill. + """ + ) + ``` + + The sub-agent internally follows the code-review skill's 6-step workflow: + 1. Gather code context (independence-first — reads code BEFORE PR description) + 2. Load MAUI review rules from `.github/skills/code-review/references/review-rules.md` + 3. Form independent assessment + 4. Reconcile with PR narrative and prior reviews + 5. Check CI status + 6. Blast radius, failure-mode probing, and verdict + +**If Step 8 fails, times out, or returns malformed output:** +- Write `pre-flight/code-review.md` with: `## Code Review: SKIPPED\n\nReason: {failure description}` +- Set verdict to `SKIPPED` in the Code Review Summary section of `content.md` +- Omit `hints` from Try-Fix prompts (the `hints` field becomes optional when code review is unavailable) +- Do NOT apply the code-review hard gate in Phase 3 (Report) — treat as if code review was not run + +**Store the sub-agent's full output** in `pre-flight/code-review.md` — use the exact output format from the code-review skill (do NOT reformat or summarize into a different template). + +**Extract key items for Try-Fix consumption** and add to `content.md`: +- All ❌ Error findings (with file:line references) +- All ⚠️ Warning findings (with file:line references) +- Failure-mode probes and their answers +- Blast radius assessment summary +- The overall verdict and confidence level + +--- + +## Output Files + +```bash +mkdir -p CustomAgentLogsTmp/PRState/{PRNumber}/PRAgent/pre-flight +``` + +Write `content.md`: +```markdown +**Issue:** #{IssueNumber} - {Title} +**PR:** #{PRNumber} - {Title} +**Platforms Affected:** {platforms} +**Files Changed:** {count} implementation, {count} test + +### Key Findings +- {Finding 1} +- {Finding 2} + +### Code Review Summary +**Verdict:** {LGTM / NEEDS_CHANGES / NEEDS_DISCUSSION / SKIPPED} +**Confidence:** {high / medium / low / N/A} +**Errors:** {count} | **Warnings:** {count} | **Suggestions:** {count} + +Key code review findings: +- {✗/⚠/ℹ} {Brief finding with file:line reference} +- ... +*(If SKIPPED: "Code review sub-agent failed or timed out. Reason: {details}")* + +### Fix Candidates +| # | Source | Approach | Test Result | Files Changed | Notes | +|---|--------|----------|-------------|---------------|-------| +| PR | PR #XXXXX | {approach} | ⏳ PENDING (Gate) | `file.cs` | Original PR | +``` + +Write `code-review.md` — the exact output from the code-review sub-agent, in the format specified by `.github/skills/code-review/SKILL.md` (Review Output Format section). Do NOT reformat or create a custom template — preserve the skill's native output verbatim. + +--- + +## Common Mistakes + +- ❌ Skipping the code-review step — it provides critical findings for Try-Fix +- ❌ Reading the PR description before code in Step 7 — independence-first prevents anchoring bias +- ❌ Running tests — that's the Gate phase +- ❌ Proposing fixes — save fix ideas for Try-Fix phase diff --git a/.github/pr-review/pr-report.md b/.github/pr-review/pr-report.md new file mode 100644 index 000000000000..b715ba365327 --- /dev/null +++ b/.github/pr-review/pr-report.md @@ -0,0 +1,101 @@ +# PR Report — Final Recommendation + +> **SCOPE:** Deliver the review recommendation. Output files only — no comments posted. + +> 🚨 **DO NOT post any comments.** This phase only produces output files. + +> 🚨 **DO NOT duplicate content from other phases.** Reference gate/try-fix results by status only (e.g., "Gate: ✅ PASSED") — do NOT copy their full output into report/content.md. + +--- + +## Prerequisites + +- Phases 1-2 (Pre-Flight, Try-Fix) must be complete before starting +- Gate result is available from the prompt (ran separately before this skill) +- **Read `pre-flight/content.md`** to get the code-review summary (verdict, confidence, error/warning counts) +- Optionally read `pre-flight/code-review.md` for full findings if needed for the recommendation + +--- + +## Steps + +1. **Determine recommendation** (rows evaluated in order — first match wins): + + | Priority | Condition | Recommendation | + |----------|-----------|----------------| + | 1 | Code review verdict is `NEEDS_CHANGES` (any ❌ errors) | `⚠️ REQUEST CHANGES` — code review found errors | + | 2 | Gate failed (tests fail with fix) | `⚠️ REQUEST CHANGES` — fix doesn't work | + | 3 | Alternative fix found via Try-Fix that is simpler/better | `⚠️ REQUEST CHANGES` — suggest alternative | + | 4 | Code review verdict is `NEEDS_DISCUSSION` | `⚠️ REQUEST CHANGES` — include code review concerns | + | 5 | PR's fix selected AND Gate passed AND code review LGTM or SKIPPED | `✅ APPROVE` | + + **🚨 Hard gate:** If the code review (from Pre-Flight) has verdict `NEEDS_CHANGES`, the final recommendation MUST be `REQUEST CHANGES` regardless of Gate or Try-Fix results. Code-review ❌ Errors cannot be overridden by passing tests alone. + + **Code review SKIPPED:** If the code-review sub-agent failed or timed out (verdict = `SKIPPED`), the hard gate does NOT apply. Proceed as if code review was not available — base the recommendation on Gate and Try-Fix results only. Note in the report that code review was unavailable. + +2. **Write output files** — Save recommendation to `content.md` + +> 🚨 **DO NOT post comments.** This phase only produces output files. +> +> 🚨 **DO NOT run pr-finalize.** That is a separate skill invoked only when the user explicitly requests it. + +--- + +## Output File + +```bash +mkdir -p CustomAgentLogsTmp/PRState/{PRNumber}/PRAgent/report +``` + +Write `content.md`: +```markdown +## {✅/⚠️} Final Recommendation: {APPROVE/REQUEST CHANGES} + +### Phase Status +| Phase | Status | Notes | +|---|---|---| +| Pre-Flight | ✅ COMPLETE | {notes} | +| Code Review | {verdict} ({confidence}) | {error_count} errors, {warning_count} warnings | +| Gate | ✅ PASSED | {platform} | +| Try-Fix | ✅ COMPLETE | {N} attempts, {M} passing | +| Report | ✅ COMPLETE | | + +### Code Review Impact on Try-Fix +{Brief description of how code-review findings influenced try-fix exploration. Did any model specifically address a code review ❌ Error? Did failure-mode probes reveal issues that guided fix approaches?} + +### Summary +{Brief summary of the review} + +### Root Cause +{Root cause analysis} + +### Fix Quality +{Assessment of the fix — informed by both gate results and code review findings} +``` + +--- + +## Agent Labels (Automated) + +After Report completes, `Review-PR.ps1` automatically applies labels based on `content.md` files: + +| Label | When Applied | +|-------|-------------| +| `s/agent-approved` | Report recommends APPROVE | +| `s/agent-changes-requested` | Report recommends REQUEST CHANGES | +| `s/agent-review-incomplete` | Agent didn't complete all phases | +| `s/agent-gate-passed` | Gate phase passes | +| `s/agent-gate-failed` | Gate phase fails | +| `s/agent-fix-win` | Agent found a better alternative | +| `s/agent-fix-pr-picked` | PR's fix was best | +| `s/agent-reviewed` | Every completed run | + +Standard markers in content.md: `✅ PASSED`, `❌ FAILED`, `Selected Fix: PR`, `Final Recommendation: APPROVE`. + +--- + +## Common Mistakes + +- ❌ Rushing the report — take time for clear justification +- ❌ Running git commands — user handles commit/push +- ❌ Posting comments — this phase only produces output files, never posts to GitHub diff --git a/.github/scripts/Aggregate-CopilotTokenUsage.Tests.ps1 b/.github/scripts/Aggregate-CopilotTokenUsage.Tests.ps1 new file mode 100644 index 000000000000..dadd038730b3 --- /dev/null +++ b/.github/scripts/Aggregate-CopilotTokenUsage.Tests.ps1 @@ -0,0 +1,95 @@ +#!/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 + reasoningOutputTokens = 5 + 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.cachedInputTokens | Should -Be 10 + $summary.totals.reasoningOutputTokens | Should -Be 5 + $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.reasoningOutputTokens | Should -Be 5 + $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.reasoningOutputTokens | 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/BuildAndRunHostApp.ps1 b/.github/scripts/BuildAndRunHostApp.ps1 index 2a7f6a31d6b7..729ad0a071d7 100644 --- a/.github/scripts/BuildAndRunHostApp.ps1 +++ b/.github/scripts/BuildAndRunHostApp.ps1 @@ -53,7 +53,7 @@ param( [ValidateSet("android", "ios", "catalyst", "maccatalyst", "windows")] [string]$Platform, - [Parameter(Mandatory = $true, ParameterSetName = "TestFilter")] + [Parameter(Mandatory = $false, ParameterSetName = "TestFilter")] [string]$TestFilter, [Parameter(Mandatory = $true, ParameterSetName = "Category")] @@ -81,6 +81,12 @@ if ($Platform -eq "maccatalyst") { # Import shared utilities . "$PSScriptRoot/shared/shared-utils.ps1" +# Derive the .NET TFM version from the checked-out repo (Directory.Build.props) so the +# HostApp + test assemblies build for the branch's framework (e.g. net11.0-android on the +# net11.0 branch) instead of a hardcoded net10.0. +$DotNetTfm = Get-MauiTfmVersion -RepoRoot $RepoRoot +Write-Info "Using .NET TFM version: net$DotNetTfm (from Directory.Build.props)" + # Banner Write-Host @" @@ -126,17 +132,17 @@ Write-Success "Prerequisites validated" # Set target framework and app identifiers if ($Platform -eq "android") { - $TargetFramework = "net10.0-android" + $TargetFramework = "net$DotNetTfm-android" $AppPackage = "com.microsoft.maui.uitests" $AppActivity = "com.microsoft.maui.uitests.MainActivity" } elseif ($Platform -eq "ios") { - $TargetFramework = "net10.0-ios" + $TargetFramework = "net$DotNetTfm-ios" $AppBundleId = "com.microsoft.maui.uitests" } elseif ($Platform -eq "catalyst") { - $TargetFramework = "net10.0-maccatalyst" + $TargetFramework = "net$DotNetTfm-maccatalyst" $AppBundleId = "com.microsoft.maui.uitests" } elseif ($Platform -eq "windows") { - $TargetFramework = "net10.0-windows10.0.19041.0" + $TargetFramework = "net$DotNetTfm-windows10.0.19041.0" $AppPackage = "com.microsoft.maui.uitests" } @@ -219,19 +225,93 @@ Write-Success "Test project: $TestProject" #region Run Tests -# Determine the filter to use +# Determine the filter to use. +# NOTE: The CI pipeline `maui-pr-uitests` (definition 313) uses `TestCategory=` +# (see eng/pipelines/common/ui-tests-steps.yml lines 116-164). NUnit accepts +# both `Category=` and `TestCategory=` but Cake's RunTestWithLocalDotNet uses +# `TestCategory=` so we mirror that here for byte-for-byte parity with CI. if ($Category) { - $effectiveFilter = "Category=$Category" + $effectiveFilter = "TestCategory=$Category" Write-Step "Running UI tests with category: $Category" -} else { +} elseif ($TestFilter) { $effectiveFilter = $TestFilter Write-Step "Running UI tests with filter: $TestFilter" +} else { + $effectiveFilter = $null + Write-Step "Running ALL UI tests (no filter)" } # Clear device logs before test if ($Platform -eq "android") { Write-Info "Clearing Android logcat buffer before test..." & adb -s $DeviceUdid logcat -c + + # Wait for Android settings service to be available. + Write-Info "Waiting for Android settings service..." + $settingsReady = $false + for ($i = 0; $i -lt 30; $i++) { + $settingsCheck = & adb -s $DeviceUdid shell settings get global device_name 2>&1 + if ($settingsCheck -and $settingsCheck -notmatch "Can't find service|error") { + $settingsReady = $true + Write-Success "Settings service ready (device_name=$settingsCheck)" + break + } + Write-Info " Settings service not ready yet (attempt $($i+1)/30)..." + Start-Sleep -Seconds 5 + } + if (-not $settingsReady) { + Write-Warn "Settings service may not be ready — tests might fail" + } + + # Warm up the emulator / SystemUI right before launching the app for tests. + # On the deep-UI-test (platform-pool) stage the emulator may have sat idle + # for ~15-20 min during workload install + the app build, after which SystemUI + # can ANR — the app then launches but its first page never renders, so Appium's + # OneTimeSetUp times out ("Timed out waiting for Go To Test button"). This + # mirrors the gate's "Warm Up Android Emulator" step but runs at the precise + # moment (right before dotnet test), independent of how long the build took. + # We only touch SystemUI / the launcher here — never the HostApp itself + # (Appium's UiAutomator2 driver owns the HostApp lifecycle). + Write-Info "Warming up emulator/SystemUI before test..." + $bootChk = & adb -s $DeviceUdid shell getprop sys.boot_completed 2>$null + if ("$bootChk".Trim() -ne "1") { + Write-Warn "Device not responding before test — restarting adb server..." + & adb kill-server 2>$null; Start-Sleep -Seconds 2 + & adb start-server 2>$null; Start-Sleep -Seconds 2 + # Bound `adb wait-for-device` to 90s portably — the external `timeout` binary differs on + # Windows (interactive countdown) and may be absent, so use the .NET process timeout. + $waitProc = Start-Process -FilePath 'adb' -ArgumentList @('-s', $DeviceUdid, 'wait-for-device') -PassThru -NoNewWindow + if (-not $waitProc.WaitForExit(90000)) { + Write-Warn "adb wait-for-device timed out after 90s — killing" + try { $waitProc.Kill() } catch { <# best effort #> } + } + } + # Wake + dismiss any system dialogs (run twice for reliability). + foreach ($pass in 1..2) { + & adb -s $DeviceUdid shell input keyevent KEYCODE_WAKEUP 2>$null + & adb -s $DeviceUdid shell am broadcast -a android.intent.action.CLOSE_SYSTEM_DIALOGS 2>$null + & adb -s $DeviceUdid shell input keyevent KEYCODE_BACK 2>$null + Start-Sleep -Seconds 1 + } + # If a SystemUI ANR ("isn't responding") dialog is up, force it away. HOME only + # backgrounds the launcher (the HostApp isn't running yet), so this is safe. + $winState = & adb -s $DeviceUdid shell dumpsys window 2>$null + if ("$winState" -match "Application Not Responding|ANR ") { + Write-Warn "ANR dialog detected before test — dismissing (HOME + close dialogs)" + & adb -s $DeviceUdid shell input keyevent KEYCODE_HOME 2>$null + Start-Sleep -Seconds 2 + & adb -s $DeviceUdid shell am broadcast -a android.intent.action.CLOSE_SYSTEM_DIALOGS 2>$null + & adb -s $DeviceUdid shell input keyevent KEYCODE_BACK 2>$null + } + # Exercise the system briefly to confirm SystemUI is responsive, then clean up + # (force-stop targets the Settings app, never the HostApp). + & adb -s $DeviceUdid shell am start -a android.settings.SETTINGS 2>$null + Start-Sleep -Seconds 2 + & adb -s $DeviceUdid shell am force-stop com.android.settings 2>$null + & adb -s $DeviceUdid shell am broadcast -a android.intent.action.CLOSE_SYSTEM_DIALOGS 2>$null + & adb -s $DeviceUdid shell input keyevent KEYCODE_HOME 2>$null + & adb -s $DeviceUdid logcat -c 2>$null + Write-Success "Emulator warmed up and responsive" } # Capture test start time for iOS logs @@ -260,35 +340,235 @@ if ($Platform -eq "catalyst") { & chmod +x $executablePath } - Write-Success "MacCatalyst app prepared (Appium will launch with test name)" + # Set MAC_APP_PATH so Appium mac2 driver can launch the app directly + $env:MAC_APP_PATH = $appPath + Write-Success "MacCatalyst app prepared (MAC_APP_PATH=$appPath)" } else { - Write-Warning "MacCatalyst app not found at: $appPath" - Write-Warning "Test may use wrong app bundle if another version is registered" + Write-Warn "MacCatalyst app not found at: $appPath" + Write-Warn "Test may use wrong app bundle if another version is registered" } # Set log file path directly - app will write ILogger output here $env:MAUI_LOG_FILE = $deviceLogFile } -Write-Info "Executing: dotnet test --filter `"$effectiveFilter`"" +# For Windows, point the test at the actual built HostApp .exe. UITest.cs +# (TestDevice.Windows) otherwise computes the app path RELATIVE to the test +# assembly ("../../../Controls.TestCases.HostApp/..."), which does NOT resolve to +# the repo's `artifacts/bin` output layout — so WinAppDriver fails OneTimeSetUp +# with "The system cannot find the file specified" and 0 tests run. Setting +# WINDOWS_APP_PATH (honored first by UITest.cs) to the known build output fixes it. +if ($Platform -eq "windows") { + $hostAppBin = Join-Path $RepoRoot "artifacts/bin/Controls.TestCases.HostApp/Debug/$TargetFramework" + $winAppExe = $null + if (Test-Path $hostAppBin) { + $winAppExe = Get-ChildItem -Path $hostAppBin -Filter "Controls.TestCases.HostApp.exe" -Recurse -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + } + if ($winAppExe) { + $env:WINDOWS_APP_PATH = $winAppExe + Write-Success "Set WINDOWS_APP_PATH=$winAppExe" + } else { + Write-Warn "Windows HostApp .exe not found under $hostAppBin — test will fall back to relative-path resolution (may fail to launch)" + } +} + +$filterDisplay = if ($effectiveFilter) { "--filter `"$effectiveFilter`"" } else { "(no filter — all tests)" } +Write-Info "Executing: dotnet test $filterDisplay" Write-Host "" # Set environment variables for the test $env:DEVICE_UDID = $DeviceUdid Write-Info "Set DEVICE_UDID environment variable: $DeviceUdid" +# Set APPIUM_LOG_FILE so UITestBase saves screenshots/page-source to our log directory +$appiumLogFile = Join-Path $HostAppLogsDir "appium.log" +$env:APPIUM_LOG_FILE = $appiumLogFile +Write-Info "Set APPIUM_LOG_FILE: $appiumLogFile (screenshots will be saved here)" + +# ── TRX setup (mirrors CI: eng/cake/dotnet.cake `RunTestWithLocalDotNet`) ── +# CI writes one trx per test run via: +# --logger "trx;LogFileName=.trx" +# --logger "console;verbosity=normal" +# --results-directory +# /p:VStestUseMSBuildOutput=false +# We reproduce that here so STEP 3's renderer can parse authoritative +# pass/fail counts from the TRX (instead of scraping console output, which is +# fragile when many tests run and lines get interleaved or wrapped). +$trxResultsDir = Join-Path $HostAppLogsDir "TestResults" +if (-not (Test-Path $trxResultsDir)) { + New-Item -ItemType Directory -Path $trxResultsDir -Force | Out-Null +} +# Sanitize the trx file name. NUnit/MSTest reject some characters. We keep +# alpha-numeric, dash, underscore and dot — same set Cake's +# SanitizeTestResultsFilename uses. +$trxBaseName = if ($Category) { "$Category-$Platform" } + elseif ($TestFilter) { ($TestFilter -replace '[^A-Za-z0-9._-]', '_') } + else { "ALL-$Platform" } +$trxBaseName = $trxBaseName -replace '[^A-Za-z0-9._-]', '_' +$trxFileName = "$trxBaseName.trx" +$trxFilePath = Join-Path $trxResultsDir $trxFileName +# Pre-clean stale TRX so we never read a previous run's results +if (Test-Path $trxFilePath) { Remove-Item $trxFilePath -Force -ErrorAction SilentlyContinue } + +Write-Info "TRX file will be written to: $trxFilePath" + try { - # Run dotnet test and capture output - $testOutput = & dotnet test $TestProject --filter $effectiveFilter --logger "console;verbosity=detailed" 2>&1 + # Run dotnet test using the SAME loggers and arguments CI uses in + # `RunTestWithLocalDotNet` (eng/cake/dotnet.cake line 943-981). + $trxRunStart = Get-Date + $testArgs = @($TestProject, + "--logger", "trx;LogFileName=$trxFileName", + "--logger", "console;verbosity=normal", + "--results-directory", $trxResultsDir, + "/p:VStestUseMSBuildOutput=false") + if ($effectiveFilter) { + $testArgs = @($TestProject, "--filter", $effectiveFilter) + $testArgs[1..($testArgs.Length-1)] + } + Write-Info "Actual dotnet test args: $($testArgs -join ' ')" + $testOutput = & dotnet test @testArgs 2>&1 # Save test output to file $testOutput | Out-File -FilePath $testOutputFile -Encoding UTF8 - # Display test output - $testOutput | ForEach-Object { Write-Host $_ } - + # Output test results to the output stream so callers can capture them + # (Write-Host goes to the Information stream which is not captured by 2>&1) + $testOutput | ForEach-Object { Write-Output $_ } + + # Surface the TRX path on a marker line so callers (Invoke-UITestWithRetry + # and Review-PR.ps1) can locate the authoritative results file regardless + # of where the working directory was when this script ran. + if (Test-Path $trxFilePath) { + Write-Output ">>> TRX_RESULT_FILE: $trxFilePath" + } else { + # dotnet test may have written the TRX with a slightly different name + # (e.g. LogFileName argument stripped on Windows, or it injected a + # timestamp). Fall back to scanning the results dir for any .trx + # written AFTER this run started — never pick up a stale TRX from a + # previous category that shares the same results directory. + $latestTrx = Get-ChildItem -Path $trxResultsDir -Filter "*.trx" -ErrorAction SilentlyContinue | + Where-Object { $_.LastWriteTime -ge $trxRunStart } | + Sort-Object LastWriteTime -Descending | Select-Object -First 1 + if ($latestTrx) { + Write-Output ">>> TRX_RESULT_FILE: $($latestTrx.FullName)" + } + } + $testExitCode = $LASTEXITCODE + # ── Per-test retry for flaky failures (Android emulator instability) ── + # Parse the TRX for failed tests and re-run them once. This catches + # emulator-induced timeouts and transient ADB failures that aren't + # real test bugs. Only retry on Android where flake rate is ~5%. + if ($testExitCode -ne 0 -and $Platform -eq 'android' -and (Test-Path $trxFilePath)) { + . "$PSScriptRoot/shared/Get-TrxResults.ps1" + $firstRun = Get-TrxResults -TrxPath $trxFilePath + if ($firstRun -and [int]$firstRun.Failed -gt 0 -and [int]$firstRun.Passed -gt 0) { + $failedNames = @($firstRun.Results | Where-Object { $_.status -eq 'Failed' } | ForEach-Object { $_.name }) + Write-Host "" + Write-Warn "🔄 Retrying $($failedNames.Count) failed test(s) on Android..." + + # Build a FullyQualifiedName filter for just the failed tests. + # Strip parameter signatures (e.g. TestMethod(arg: "val")) because + # VSTest filter grammar treats ( ) | & ! as operators. Using the + # bare method name with ~ (contains) is safe and sufficient. + $safeNames = @($failedNames | ForEach-Object { $_ -replace '\(.*$', '' } | Select-Object -Unique) + $retryFilter = ($safeNames | ForEach-Object { "FullyQualifiedName~$_" }) -join ' | ' + $retryTrx = Join-Path $trxResultsDir "retry-$trxBaseName.trx" + Remove-Item $retryTrx -Force -ErrorAction SilentlyContinue + + $retryArgs = @($TestProject, "--filter", $retryFilter, + "--logger", "trx;LogFileName=retry-$trxFileName", + "--logger", "console;verbosity=normal", + "--results-directory", $trxResultsDir, + "/p:VStestUseMSBuildOutput=false", "--no-build") + Write-Info "Retry args: dotnet test --filter '$retryFilter' --no-build" + $retryOutput = & dotnet test @retryArgs 2>&1 + $retryOutput | ForEach-Object { Write-Output $_ } + $retryExitCode = $LASTEXITCODE + + # Parse retry TRX and count how many passed on retry + $retryTrxPath = Join-Path $trxResultsDir "retry-$trxFileName" + if (Test-Path $retryTrxPath) { + $retryResults = Get-TrxResults -TrxPath $retryTrxPath + if ($retryResults) { + $retryPassed = @($retryResults.Results | Where-Object { $_.status -eq 'Passed' }).Count + $retryFailed = @($retryResults.Results | Where-Object { $_.status -eq 'Failed' }).Count + Write-Host " Retry results: $retryPassed passed, $retryFailed failed (of $($failedNames.Count) retried)" -ForegroundColor Cyan + + if ($retryFailed -eq 0) { + Write-Success "All $retryPassed flaky test(s) passed on retry!" + $testExitCode = 0 + } else { + Write-Warn "$retryFailed test(s) still failing after retry (real failures)" + } + # Merge retry results into the original TRX: replace only the + # retried test entries in the original with their retry outcomes, + # preserving all tests that passed on the first run. This avoids + # the prior bug where Copy-Item overwrote the full TRX with the + # retry-only TRX, losing the first-run passing tests entirely. + try { + [xml]$origXml = Get-Content -Path $trxFilePath -Raw -Encoding UTF8 + [xml]$retryXml = Get-Content -Path $retryTrxPath -Raw -Encoding UTF8 + $nsUri = 'http://microsoft.com/schemas/VisualStudio/TeamTest/2010' + $nsMgr = New-Object System.Xml.XmlNamespaceManager($origXml.NameTable) + $nsMgr.AddNamespace('t', $nsUri) + $retryNsMgr = New-Object System.Xml.XmlNamespaceManager($retryXml.NameTable) + $retryNsMgr.AddNamespace('t', $nsUri) + + # Build a lookup of retry results by testName + $retryByName = @{} + foreach ($rr in $retryXml.SelectNodes('//t:UnitTestResult', $retryNsMgr)) { + $retryByName[$rr.GetAttribute('testName')] = $rr + } + + # Only replace entries that were in the original failed set. + # The retry filter uses substring matching (~) so the retry TRX + # may contain tests that passed on the first run (e.g. other + # parameterizations of the same method). We must NOT overwrite + # those — only replace originally-failed entries. + $failedNameSet = New-Object 'System.Collections.Generic.HashSet[string]' + foreach ($fn in $failedNames) { [void]$failedNameSet.Add($fn) } + + foreach ($origResult in $origXml.SelectNodes('//t:UnitTestResult', $nsMgr)) { + $tName = $origResult.GetAttribute('testName') + if ($failedNameSet.Contains($tName) -and $retryByName.ContainsKey($tName)) { + $imported = $origXml.ImportNode($retryByName[$tName], $true) + $origResult.ParentNode.ReplaceChild($imported, $origResult) | Out-Null + } + } + + # Update counters to reflect merged results. Count outcomes + # using the same logic as Get-TrxResults: Passed stays Passed, + # NotExecuted/Inconclusive are Skipped, everything else is Failed. + $allResults = $origXml.SelectNodes('//t:UnitTestResult', $nsMgr) + $mergedTotal = $allResults.Count + $mergedPassed = @($allResults | Where-Object { $_.GetAttribute('outcome') -eq 'Passed' }).Count + $skippedOutcomes = @('NotExecuted', 'Inconclusive') + $mergedSkipped = @($allResults | Where-Object { $_.GetAttribute('outcome') -in $skippedOutcomes }).Count + $mergedFailed = $mergedTotal - $mergedPassed - $mergedSkipped + $mergedExecuted = $mergedPassed + $mergedFailed + $counters = $origXml.SelectSingleNode('//t:ResultSummary/t:Counters', $nsMgr) + if ($counters) { + $counters.SetAttribute('total', $mergedTotal) + $counters.SetAttribute('executed', $mergedExecuted) + $counters.SetAttribute('passed', $mergedPassed) + $counters.SetAttribute('failed', $mergedFailed) + } + + $origXml.Save($trxFilePath) + Write-Info "Merged retry results into original TRX ($mergedTotal total, $mergedPassed passed, $mergedFailed failed)" + } catch { + Write-Warn "Failed to merge TRX — falling back to retry-only TRX: $_" + Copy-Item $retryTrxPath $trxFilePath -Force + } + # Remove the retry TRX to prevent double-counting by downstream aggregators + Remove-Item $retryTrxPath -Force -ErrorAction SilentlyContinue + } + } + } + } + Write-Host "" Write-Info "Test output saved to: $testOutputFile" @@ -311,6 +591,38 @@ try { #endregion +#region Collect Test Artifacts (screenshots, page source) + +Write-Step "Collecting test artifacts (screenshots, page source)..." + +# Collect any screenshots/page source from the test assembly output directory +# UITestBase saves these via TestContext.AddTestAttachment to the assembly dir +$testAssemblyDirs = @( + (Join-Path $RepoRoot "artifacts/bin/Controls.TestCases.Android.Tests/Debug/net$DotNetTfm"), + (Join-Path $RepoRoot "artifacts/bin/Controls.TestCases.iOS.Tests/Debug/net$DotNetTfm"), + (Join-Path $RepoRoot "artifacts/bin/Controls.TestCases.Mac.Tests/Debug/net$DotNetTfm"), + (Join-Path $RepoRoot "artifacts/bin/Controls.TestCases.WinUI.Tests/Debug/net$DotNetTfm-windows10.0.19041.0") +) + +$copiedCount = 0 +foreach ($dir in $testAssemblyDirs) { + if (Test-Path $dir) { + $artifacts = Get-ChildItem -Path $dir -File -Include "*.png","*.txt" -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match "ScreenShot|PageSource" } + foreach ($artifact in $artifacts) { + Copy-Item -Path $artifact.FullName -Destination $HostAppLogsDir -Force + $copiedCount++ + } + } +} + +# Also check the HostAppLogsDir itself for screenshots saved via APPIUM_LOG_FILE +$screenshotCount = (Get-ChildItem -Path $HostAppLogsDir -Filter "*.png" -ErrorAction SilentlyContinue).Count +$pageSourceCount = (Get-ChildItem -Path $HostAppLogsDir -Filter "*PageSource*" -ErrorAction SilentlyContinue).Count +Write-Info "Test artifacts collected: $screenshotCount screenshot(s), $pageSourceCount page source(s) (copied $copiedCount from assembly dir)" + +#endregion + #region Capture Device Logs Write-Step "Capturing device logs..." @@ -323,7 +635,7 @@ if ($Platform -eq "android") { & adb -s $DeviceUdid logcat -d | Select-String "com.microsoft.maui.uitests|DOTNET" > $deviceLogFile if ((Get-Item $deviceLogFile).Length -eq 0) { - Write-Warning "No logs found for com.microsoft.maui.uitests, dumping entire logcat..." + Write-Warn "No logs found for com.microsoft.maui.uitests, dumping entire logcat..." & adb -s $DeviceUdid logcat -d > $deviceLogFile } @@ -381,6 +693,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 @@ -397,7 +711,7 @@ if (Test-Path $deviceLogFile) { Write-Host "" Write-Info "Full device log: $deviceLogFile" } else { - Write-Warning "Could not read device log file" + Write-Warn "Could not read device log file" } Write-Host "═══════════════════════════════════════════════════════" -ForegroundColor Cyan @@ -427,7 +741,7 @@ Write-Host @" ╠═══════════════════════════════════════════════════════════╣ ║ Platform: $($Platform.ToUpper().PadRight(10)) ║ ║ Device: $($DeviceUdid.Substring(0, [Math]::Min(40, $DeviceUdid.Length)).PadRight(40)) ║ -║ Test Filter: $($effectiveFilter.Substring(0, [Math]::Min(40, $effectiveFilter.Length)).PadRight(40)) ║ +║ Test Filter: $($(if ($effectiveFilter) { $effectiveFilter.Substring(0, [Math]::Min(40, $effectiveFilter.Length)) } else { '(all tests)' }).PadRight(40)) ║ ║ Result: SUCCESS ✅ ║ ║ Logs: $HostAppLogsDir ╚═══════════════════════════════════════════════════════════╝ diff --git a/.github/scripts/BuildAndRunSandbox.ps1 b/.github/scripts/BuildAndRunSandbox.ps1 index ccdffaafc082..c537f7c22af2 100644 --- a/.github/scripts/BuildAndRunSandbox.ps1 +++ b/.github/scripts/BuildAndRunSandbox.ps1 @@ -258,7 +258,7 @@ if ($Platform -eq "catalyst") { Write-Success "MacCatalyst Sandbox app launched with log capture" } } else { - Write-Warning "MacCatalyst Sandbox app not found at: $appPath" + Write-Warn "MacCatalyst Sandbox app not found at: $appPath" } } @@ -379,7 +379,7 @@ try { # Fallback: If we couldn't get PID, dump entire logcat buffer (unfiltered) # This ensures we always have logs for the agent to analyze Write-Host "" - Write-Warning "Could not capture app PID from Appium test output" + Write-Warn "Could not capture app PID from Appium test output" Write-Info "Dumping entire logcat buffer (unfiltered)..." & adb -s $DeviceUdid logcat -d > $deviceLogFile Write-Info "Logcat dumped to: $deviceLogFile (UNFILTERED - contains all apps)" @@ -469,7 +469,7 @@ try { Write-Info "All logs are from Sandbox app only (Maui.Controls.Sample.Sandbox)" } } else { - Write-Warning "Could not read device log file" + Write-Warn "Could not read device log file" } Write-Host "═══════════════════════════════════════════════════════" -ForegroundColor Cyan diff --git a/.github/scripts/DetectUITestCategories.Tests.ps1 b/.github/scripts/DetectUITestCategories.Tests.ps1 new file mode 100644 index 000000000000..deb4132bfb47 --- /dev/null +++ b/.github/scripts/DetectUITestCategories.Tests.ps1 @@ -0,0 +1,54 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } + +# Unit tests for the prepared-review-worktree detection in +# eng/scripts/detect-ui-test-categories.ps1. We AST-extract the pure helper +# Test-PreparedReviewWorktreeSubject (no load-time side effects) and exercise it +# in isolation, mirroring the repo's existing *.Tests.ps1 pattern. + +BeforeAll { + $script:detectScript = Join-Path $PSScriptRoot '..' '..' 'eng' 'scripts' 'detect-ui-test-categories.ps1' + $script:detectScript = (Resolve-Path $script:detectScript).Path + + $tokens = $null; $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile($script:detectScript, [ref]$tokens, [ref]$errors) + $fn = $ast.Find({ + param($n) + $n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $n.Name -eq 'Test-PreparedReviewWorktreeSubject' + }, $true) + if (-not $fn) { throw "Test-PreparedReviewWorktreeSubject not found in $script:detectScript" } + Invoke-Expression $fn.Extent.Text +} + +Describe 'Test-PreparedReviewWorktreeSubject' { + It 'returns $true for the canonical squash-merge commit subject' { + Test-PreparedReviewWorktreeSubject -HeadSubject 'PR #33192 squashed for review' -PrNumber '33192' | Should -BeTrue + } + + It 'matches when the subject has a trailing token after the marker' { + Test-PreparedReviewWorktreeSubject -HeadSubject 'PR #35578 squashed for review (rerun)' -PrNumber '35578' | Should -BeTrue + } + + It 'tolerates surrounding/normalizable whitespace on both inputs' { + Test-PreparedReviewWorktreeSubject -HeadSubject ' PR #34408 squashed for review ' -PrNumber ' 34408 ' | Should -BeTrue + } + + It 'returns $false when the PR number does not match (no cross-PR false positive)' { + Test-PreparedReviewWorktreeSubject -HeadSubject 'PR #33192 squashed for review' -PrNumber '3319' | Should -BeFalse + Test-PreparedReviewWorktreeSubject -HeadSubject 'PR #33192 squashed for review' -PrNumber '331920' | Should -BeFalse + } + + It 'returns $false for an ordinary commit subject (standalone/local run)' { + Test-PreparedReviewWorktreeSubject -HeadSubject 'Fix Shell flyout ScrollView header' -PrNumber '33192' | Should -BeFalse + } + + It 'returns $false when the marker is not at the start of the subject' { + Test-PreparedReviewWorktreeSubject -HeadSubject 'chore: PR #33192 squashed for review' -PrNumber '33192' | Should -BeFalse + } + + It 'returns $false for empty / null inputs' { + Test-PreparedReviewWorktreeSubject -HeadSubject '' -PrNumber '33192' | Should -BeFalse + Test-PreparedReviewWorktreeSubject -HeadSubject 'PR #33192 squashed for review' -PrNumber '' | Should -BeFalse + Test-PreparedReviewWorktreeSubject -HeadSubject $null -PrNumber $null | Should -BeFalse + } +} diff --git a/.github/scripts/EstablishBrokenBaseline.ps1 b/.github/scripts/EstablishBrokenBaseline.ps1 index 70a2f8a1c19b..0fe68a2f8d34 100644 --- a/.github/scripts/EstablishBrokenBaseline.ps1 +++ b/.github/scripts/EstablishBrokenBaseline.ps1 @@ -64,6 +64,9 @@ $script:TestPathPatterns = @( "*.Tests/*", "*.UnitTests/*", "*TestCases*", + "*TestUtils*", + "*DeviceTests.Runners*", + "*DeviceTests.Shared*", "*snapshots*", "*.png", "*.jpg", @@ -335,11 +338,31 @@ if ($Restore) { } } +# ============================================================ +# AUTO-RESTORE: If a previous baseline is still active, restore it first +# ============================================================ +# This prevents the Establish→fail→Establish loop that caused build #13539436 +# to waste 3.7 hours. Instead of erroring on a dirty tree, we detect that a +# prior baseline was never restored and clean it up automatically. + +$existingState = Get-BaselineState +if ($existingState) { + Write-Host "⚠️ Previous baseline still active — auto-restoring before re-establishing..." -ForegroundColor Yellow + + foreach ($file in $existingState.RevertedFiles) { + Write-Host " Restoring: $file" -ForegroundColor Gray + git checkout HEAD -- $file 2>&1 | Out-Null + } + + Remove-BaselineState + Write-Host " Previous baseline restored." -ForegroundColor Green +} + # ============================================================ # FAIL-FAST: Require clean working directory # ============================================================ -# This check ensures every successful baseline establishment started from a clean state. -# If this script completes without error, the baseline was valid - no checkpoint logging needed. +# After auto-restore above, the tree should be clean. If it's still dirty, +# something else is wrong (manual edits, uncommitted work, etc.). $dirtyFiles = git status --porcelain --untracked-files=no 2>$null if ($dirtyFiles) { diff --git a/.github/scripts/Find-RegressionRisks.ps1 b/.github/scripts/Find-RegressionRisks.ps1 new file mode 100644 index 000000000000..715bb974129f --- /dev/null +++ b/.github/scripts/Find-RegressionRisks.ps1 @@ -0,0 +1,827 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Detects regression risks by cross-referencing a PR's deletions against lines added by recent bug-fix PRs. + +.DESCRIPTION + Purely mechanical (no AI / LLM). For each implementation file in the PR diff: + 1. Collects lines REMOVED by the PR being reviewed. + 2. Uses `git log` to find PRs that touched the same file in the last N months. + 3. Filters those to bug-fix PRs (label match: i/regression, t/bug, p/0, p/1; or + linked-issue label match). + 4. Pulls each fix PR's diff and collects lines it ADDED to that same file. + 5. Compares (whitespace-insensitive). If a removed line equals a line a fix PR + 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 review. + - risks.json Structured findings for downstream agents. + - result.txt One token: CLEAN | OVERLAP | REVERT (used by Review-PR.ps1 + for branching). + - inline-findings.json (only when -WriteInlineFindings is set and reverts found) + +.PARAMETER PRNumber + The PR number being analyzed. + +.PARAMETER Repo + Repository in `owner/name` form. Defaults to dotnet/maui. + +.PARAMETER FilePaths + Optional list of files to analyze. If omitted, auto-detected from `gh pr diff`. + +.PARAMETER MonthsBack + How many months of history to scan for fix PRs. Default 6. + +.PARAMETER MaxRecentPRsPerFile + Cap on how many recent PRs to inspect per file (rate-limit guard). Default 20. + +.PARAMETER OutputDir + Directory to write content.md, risks.json, result.txt. If omitted, only console output. + +.PARAMETER WriteInlineFindings + When set, append entries to inline-findings.json at the file:line where reverted code + was deleted. Off by default until accuracy is validated. + +.EXAMPLE + pwsh .github/scripts/Find-RegressionRisks.ps1 -PRNumber 33908 + +.EXAMPLE + pwsh .github/scripts/Find-RegressionRisks.ps1 -PRNumber 33908 ` + -OutputDir "CustomAgentLogsTmp/PRState/33908/PRAgent/regression-check" +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [int]$PRNumber, + + [Parameter(Mandatory = $false)] + [string]$Repo = "dotnet/maui", + + [Parameter(Mandatory = $false)] + [string[]]$FilePaths, + + [Parameter(Mandatory = $false)] + [int]$MonthsBack = 6, + + [Parameter(Mandatory = $false)] + [int]$MaxRecentPRsPerFile = 20, + + [Parameter(Mandatory = $false)] + [string]$BaseBranch = 'main', + + [Parameter(Mandatory = $false)] + [string]$OutputDir, + + [Parameter(Mandatory = $false)] + [switch]$WriteInlineFindings +) + +$ErrorActionPreference = 'Continue' + +# ─── Helpers ────────────────────────────────────────────────────────────────── + +function Write-Banner { + param([string]$Title) + Write-Host "" + Write-Host "═══════════════════════════════════════════════════════════" -ForegroundColor Cyan + Write-Host " $Title" -ForegroundColor Cyan + Write-Host "═══════════════════════════════════════════════════════════" -ForegroundColor Cyan +} + +function ConvertTo-NormalizedLine { + # Whitespace-insensitive comparison key. Collapses runs of whitespace to a single space + # so an indent change alone won't trigger a false REVERT. + param([string]$Line) + return ($Line -replace '\s+', ' ').Trim() +} + +function Test-IsImplementationFile { + param([string]$Path) + if ($Path -notmatch '\.(cs|xaml)$') { return $false } + if ($Path -match '(?i)(Tests|TestCases|tests|snapshots|samples)/') { return $false } + if ($Path -match '\.Designer\.cs$') { return $false } + if ($Path -match '\.g\.cs$') { return $false } + return $true +} + +function Test-IsTestFile { + param([string]$Path) + if ($Path -notmatch '\.cs$') { return $false } + if ($Path -match '(?i)(Tests|TestCases)/') { return $true } + return $false +} + +function Get-PRDiffText { + param( + [int]$Number, + [string]$Repo + ) + $raw = gh pr diff $Number --repo $Repo 2>$null + if (-not $raw) { return $null } + if ($raw -is [array]) { $raw = $raw -join "`n" } + return $raw +} + +function Get-DiffLinesByFile { + <# + Parses a unified diff. Returns a hashtable: + { filePath -> [PSCustomObject]@{ Sign = '+' | '-'; Text = '...'; Line = } } + Line numbers are tracked from hunk headers so we can post inline findings. + #> + param( + [string]$DiffText + ) + $byFile = @{} + $currentFile = $null + $newLineCursor = 0 + $oldLineCursor = 0 + + foreach ($rawLine in ($DiffText -split "`n")) { + # Strip trailing CR (Windows-style line endings can survive in diff output) + $line = $rawLine.TrimEnd("`r") + + if ($line -match '^diff --git a/(.*) b/(.*)$') { + $currentFile = $Matches[2] + if (-not $byFile.ContainsKey($currentFile)) { + $byFile[$currentFile] = [System.Collections.Generic.List[object]]::new() + } + continue + } + if (-not $currentFile) { continue } + + if ($line -match '^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@') { + $oldLineCursor = [int]$Matches[1] + $newLineCursor = [int]$Matches[2] + continue + } + + # Skip diff metadata lines + if ($line -match '^(---|\+\+\+|index |new file|deleted file|similarity|rename|Binary)') { continue } + + # "\ No newline at end of file" marker — explicitly skip without advancing cursors + if ($line -match '^\\ No newline at end of file') { continue } + + if ($line.Length -eq 0) { + # Empty diff line outside a hunk — ignore (cursors only matter inside hunks) + continue + } + + $sign = $line.Substring(0, 1) + $text = if ($line.Length -gt 1) { $line.Substring(1) } else { '' } + + switch ($sign) { + '+' { + $byFile[$currentFile].Add([PSCustomObject]@{ + Sign = '+'; Text = $text; Line = $newLineCursor + }) + $newLineCursor++ + } + '-' { + $byFile[$currentFile].Add([PSCustomObject]@{ + Sign = '-'; Text = $text; Line = $oldLineCursor + }) + $oldLineCursor++ + } + ' ' { + $oldLineCursor++ + $newLineCursor++ + } + default { + # Unknown line — don't advance cursors + } + } + } + return $byFile +} + +function Test-IsTrivialLine { + # Filters out lines that produce meaningless matches (control-flow keywords alone, + # punctuation, single-token braces). A line must contain a substantive identifier + # or expression to be a useful match key. + param([string]$NormalizedText) + + if ([string]::IsNullOrWhiteSpace($NormalizedText)) { return $true } + if ($NormalizedText.Length -le 4) { return $true } + + # Punctuation/brace-only lines + if ($NormalizedText -match '^[\s\{\}\(\)\[\];,:]+$') { return $true } + + # Pure control-flow / scope keywords with optional terminator + if ($NormalizedText -match '^(return|break|continue|throw|else|try|finally|do|true|false|null);?\s*$') { return $true } + + # `using xyz;` and `namespace xyz` are very common — not interesting unless they + # appear next to surrounding context which we don't compare here. Skip. + if ($NormalizedText -match '^(using|namespace)\s+[\w\.]+;?\s*$') { return $true } + + # Comment-only lines + if ($NormalizedText -match '^(//|/\*|\*|#)') { return $true } + + return $false +} + +function Test-IsBugFixLabel { + param([string]$Label) + # Only definitive bug-fix labels. p/0 and p/1 are priority labels that also + # apply to enhancements — they're used as secondary signal in Get-PRMetadataIfBugFix + # (AND-ed with linked-issue bug labels) but not as standalone classifiers. + return $Label -match '^(i/regression|t/bug)$' +} + +function Get-LinkedIssueNumbers { + param([string]$PRBody) + if (-not $PRBody) { return @() } + if ($PRBody -is [array]) { $PRBody = $PRBody -join "`n" } + $normalized = $PRBody -replace "`r`n", "`n" + $set = New-Object 'System.Collections.Generic.HashSet[int]' + + $patterns = @( + '(?i)(?:Fix(?:es|ed)?|Close[sd]?|Resolve[sd]?)\s+(?:https://github\.com/dotnet/maui/issues/)?#?(\d+)', + '(?m)^\s*-\s+#(\d+)\s*$', + '(?m)^\s*-\s+https://github\.com/dotnet/maui/issues/(\d+)\s*$' + ) + foreach ($pat in $patterns) { + foreach ($m in [regex]::Matches($normalized, $pat)) { + [void]$set.Add([int]$m.Groups[1].Value) + } + } + return @($set) +} + +function Get-PRMetadataIfBugFix { + param([int]$Number, [string]$Repo) + + # Single gh call for labels + title + body + merge commit (was 3 separate calls before). + $json = gh pr view $Number --repo $Repo --json labels,title,body,mergeCommit 2>$null + if (-not $json) { return $null } + if ($json -is [array]) { $json = $json -join "`n" } + + try { + $data = $json | ConvertFrom-Json + } catch { + return $null + } + + $labelNames = @() + if ($data.labels) { + $labelNames = @($data.labels | ForEach-Object { $_.name } | Where-Object { $_ }) + } + + $matched = @($labelNames | Where-Object { Test-IsBugFixLabel $_ }) + $title = if ($data.title) { $data.title } else { '(unknown)' } + $linkedIssues = Get-LinkedIssueNumbers $data.body + + # Secondary signal: high-priority labels (p/0, p/1) combined with + # linked-issue bug labels suggest a bug-fix even when the PR itself + # lacks t/bug or i/regression. + $hasPriorityLabel = @($labelNames | Where-Object { $_ -match '^(p/0|p/1)$' }).Count -gt 0 + + # Fall back to linked-issue labels (the PR itself may not be labeled even though + # it fixes a bug — common for fork PRs where labels weren't applied at merge). + if ($matched.Count -eq 0 -and $linkedIssues.Count -gt 0) { + foreach ($issueNum in $linkedIssues) { + $issueLabelsRaw = gh issue view $issueNum --repo $Repo --json labels --jq '.labels[].name' 2>$null + if (-not $issueLabelsRaw) { continue } + foreach ($il in ($issueLabelsRaw -split "`n")) { + if (Test-IsBugFixLabel $il) { + $matched += "$il (from #$issueNum)" + } + } + } + } + + # p/0 and p/1 only count as bug-fix signals when combined with a + # definitive bug label from the PR or its linked issues. + if ($matched.Count -gt 0 -and $hasPriorityLabel) { + $matched += @($labelNames | Where-Object { $_ -match '^(p/0|p/1)$' }) + } + + if ($matched.Count -eq 0) { return $null } + + $mergeOid = $null + if ($data.mergeCommit -and $data.mergeCommit.oid) { + $mergeOid = $data.mergeCommit.oid + } + + return [PSCustomObject]@{ + Number = $Number + Title = $title + Labels = $matched + LinkedIssues = $linkedIssues + MergeCommit = $mergeOid + } +} + +# ─── Main ───────────────────────────────────────────────────────────────────── + +# Validate gh authentication before making any API calls. +# Silent auth failures would cause every PR lookup to return empty, +# producing a false CLEAN result for risky PRs. +$authCheck = gh auth status 2>&1 +if ($LASTEXITCODE -ne 0) { + Write-Host "❌ GitHub CLI not authenticated. Cannot reliably analyze regression risks." -ForegroundColor Red + Write-Host " Run 'gh auth login' or set GH_TOKEN. Auth output:" -ForegroundColor Red + Write-Host " $authCheck" -ForegroundColor Gray + exit 2 +} + +Write-Banner "Regression Cross-Reference — PR #$PRNumber" + +# Resolve files +if (-not $FilePaths -or $FilePaths.Count -eq 0) { + Write-Host "📂 Auto-detecting implementation files from PR #$PRNumber…" -ForegroundColor Yellow + $prFiles = gh pr diff $PRNumber --repo $Repo --name-only 2>$null + if (-not $prFiles) { + Write-Host "❌ Could not get PR diff. Make sure gh is authenticated." -ForegroundColor Red + exit 2 + } + $FilePaths = @($prFiles | Where-Object { Test-IsImplementationFile $_ }) + Write-Host " Found $($FilePaths.Count) implementation file(s)" -ForegroundColor Gray +} + +if ($FilePaths.Count -eq 0) { + Write-Host "● No implementation files to check." -ForegroundColor Green + if ($OutputDir) { + New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null + "● No implementation files modified — skipping regression cross-reference." | + Set-Content (Join-Path $OutputDir "content.md") -Encoding UTF8 + '{ "pr_number": ' + $PRNumber + ', "result": "CLEAN", "risks": [] }' | + Set-Content (Join-Path $OutputDir "risks.json") -Encoding UTF8 + "CLEAN" | Set-Content (Join-Path $OutputDir "result.txt") -Encoding UTF8 + } + exit 0 +} + +# Step 1: PR diff (lines removed) +Write-Host "" +Write-Host "📝 Reading current PR diff…" -ForegroundColor Yellow +$prDiff = Get-PRDiffText -Number $PRNumber -Repo $Repo +if (-not $prDiff) { + Write-Host "❌ Empty PR diff." -ForegroundColor Red + exit 2 +} +$prDiffByFile = Get-DiffLinesByFile -DiffText $prDiff + +# Per-file: removed lines (non-trivial) AND added lines (for move-suppression). +$removedByFile = @{} +$addedNormByFile = @{} +foreach ($file in $prDiffByFile.Keys) { + $removed = @($prDiffByFile[$file] | Where-Object { + $_.Sign -eq '-' -and -not (Test-IsTrivialLine (ConvertTo-NormalizedLine $_.Text)) + }) + if ($removed.Count -gt 0) { + $removedByFile[$file] = $removed + } + + $added = $prDiffByFile[$file] | Where-Object { $_.Sign -eq '+' } | + ForEach-Object { ConvertTo-NormalizedLine $_.Text } + $addedSet = New-Object 'System.Collections.Generic.HashSet[string]' + foreach ($a in $added) { [void]$addedSet.Add($a) } + $addedNormByFile[$file] = $addedSet +} + +# Resolve the base ref for git log scope. Try local refs first; if neither exists, fall +# back to --all (with a warning) so the script still produces useful output. +$gitLogRef = $null +foreach ($candidate in @($BaseBranch, "origin/$BaseBranch", "upstream/$BaseBranch")) { + git rev-parse --verify --quiet $candidate 2>$null | Out-Null + if ($LASTEXITCODE -eq 0) { + $gitLogRef = $candidate + break + } +} +if (-not $gitLogRef) { + Write-Host " ⚠️ Base ref '$BaseBranch' not found locally — falling back to --all (may include unrelated history)." -ForegroundColor Yellow +} + +# Resolve the PR's base branch so we can verify that fix PRs were actually merged +# into it. A fix merged to inflight/current won't be reachable from main. +$prBaseRef = $null +$prBaseJson = gh pr view $PRNumber --repo $Repo --json baseRefName --jq '.baseRefName' 2>$null +if ($prBaseJson) { + foreach ($candidate in @($prBaseJson, "origin/$prBaseJson", "upstream/$prBaseJson")) { + git rev-parse --verify --quiet $candidate 2>$null | Out-Null + if ($LASTEXITCODE -eq 0) { + $prBaseRef = $candidate + break + } + } +} +if ($prBaseRef) { + Write-Host " 📌 PR targets '$prBaseJson' — verifying fix PRs are reachable from $prBaseRef" -ForegroundColor Gray +} else { + Write-Host " ⚠️ Could not resolve PR base branch — skipping ancestry verification" -ForegroundColor Yellow +} + +# Steps 2-5: per file +$risks = New-Object System.Collections.Generic.List[object] +$inspectedPRs = @{} +$fixDiffCache = @{} +$ghCallCount = 0 + +foreach ($filePath in $FilePaths) { + Write-Host "" + Write-Host "🔍 $filePath" -ForegroundColor Cyan + + # Step 2: recent PRs touching this file + $sinceDate = (Get-Date).AddMonths(-$MonthsBack).ToString("yyyy-MM-dd") + if ($gitLogRef) { + # `--follow` traces through renames so we don't lose history when a file moves. + # `--follow` is single-file only, which matches our per-file loop. + $commitLog = git log --oneline --follow --since="$sinceDate" $gitLogRef -- $filePath 2>$null + } else { + $commitLog = git log --oneline --follow --since="$sinceDate" --all -- $filePath 2>$null + } + if (-not $commitLog) { + Write-Host " ● No recent commits." -ForegroundColor Green + continue + } + + $recentPRs = New-Object 'System.Collections.Generic.List[int]' + $seen = New-Object 'System.Collections.Generic.HashSet[int]' + foreach ($line in ($commitLog -split "`n")) { + if ($line -match '\(#(\d+)\)') { + $n = [int]$Matches[1] + if ($n -ne $PRNumber -and $seen.Add($n)) { + $recentPRs.Add($n) + if ($recentPRs.Count -ge $MaxRecentPRsPerFile) { break } + } + } + } + + if ($recentPRs.Count -eq 0) { + Write-Host " ● No recent PRs reference this file." -ForegroundColor Green + continue + } + + Write-Host " Found $($recentPRs.Count) recent PR(s)" -ForegroundColor Gray + + # Step 3: filter to bug-fix PRs + foreach ($recentPR in $recentPRs) { + Write-Host " 📋 #$recentPR…" -ForegroundColor Gray -NoNewline + + if ($inspectedPRs.ContainsKey($recentPR)) { + $meta = $inspectedPRs[$recentPR] + } else { + $meta = Get-PRMetadataIfBugFix -Number $recentPR -Repo $Repo + $inspectedPRs[$recentPR] = $meta + # Single combined `gh pr view --json labels,title,body` + up to one `gh issue + # view` per linked issue. Average ≈ 1-3 calls per fix-PR candidate. + $ghCallCount += 1 + ($(if ($meta -and $meta.LinkedIssues) { @($meta.LinkedIssues).Count } else { 0 })) + if ($ghCallCount -gt 100) { + Write-Host " (rate-limit guard: $ghCallCount gh calls so far)" -ForegroundColor DarkYellow + } + } + if (-not $meta) { + Write-Host " not a bug-fix" -ForegroundColor DarkGray + continue + } + Write-Host " bug-fix [$($meta.Labels -join ', ')]" -ForegroundColor Yellow + + # Verify fix PR was actually merged into the PR's base branch. A fix merged + # to inflight/current (or another branch) won't be in a PR targeting main. + if ($prBaseRef -and $meta.MergeCommit) { + git merge-base --is-ancestor $meta.MergeCommit $prBaseRef 2>$null + if ($LASTEXITCODE -ne 0) { + Write-Host " ⏭️ fix not in PR's base branch (merged to different branch)" -ForegroundColor DarkGray + continue + } + } + + # Step 4: parsed fix-PR diff (cache the *parsed* output, not just raw text). + if ($fixDiffCache.ContainsKey($recentPR)) { + $fixByFile = $fixDiffCache[$recentPR] + } else { + $fixDiff = Get-PRDiffText -Number $recentPR -Repo $Repo + $ghCallCount++ + $fixByFile = if ($fixDiff) { Get-DiffLinesByFile -DiffText $fixDiff } else { @{} } + $fixDiffCache[$recentPR] = $fixByFile + } + if ($fixByFile.Count -eq 0) { + # Fix PR diff unavailable — record only if we actually deleted something here. + if ($removedByFile.ContainsKey($filePath)) { + $risks.Add([PSCustomObject]@{ + File = $filePath + RecentPR = $recentPR + PRTitle = $meta.Title + FixedIssues = ($meta.LinkedIssues | ForEach-Object { "#$_" }) -join ', ' + Labels = $meta.Labels -join ', ' + Risk = 'OVERLAP' + Details = 'Fix PR diff unavailable' + RevertedLines = @() + }) + } + continue + } + + if (-not $fixByFile.ContainsKey($filePath)) { + continue + } + + $addedByFix = @($fixByFile[$filePath] | + Where-Object { $_.Sign -eq '+' -and -not (Test-IsTrivialLine (ConvertTo-NormalizedLine $_.Text)) } | + ForEach-Object { ConvertTo-NormalizedLine $_.Text }) | Select-Object -Unique + if ($addedByFix.Count -eq 0) { continue } + + $removedHere = $removedByFile[$filePath] + # OVERLAP only matters when the current PR actually deleted something from this + # file. Otherwise, "same file, different lines" isn't regression evidence. + if (-not $removedHere) { + continue + } + + # Step 5: compare. Suppress matches the current PR also re-added (move/refactor). + $addedSet = New-Object 'System.Collections.Generic.HashSet[string]' + foreach ($n in $addedByFix) { [void]$addedSet.Add($n) } + $currentAddedSet = $addedNormByFile[$filePath] + + $reverted = New-Object System.Collections.Generic.List[object] + $seenLines = New-Object 'System.Collections.Generic.HashSet[string]' + foreach ($r in $removedHere) { + $key = ConvertTo-NormalizedLine $r.Text + if (-not $addedSet.Contains($key)) { continue } + if ($currentAddedSet -and $currentAddedSet.Contains($key)) { continue } # moved within PR + if (-not $seenLines.Add($key)) { continue } # dedup repeats + $reverted.Add([PSCustomObject]@{ Text = $r.Text; Line = $r.Line }) + } + + # Pre-compute values outside [PSCustomObject]@{} to avoid PowerShell evaluation + # context issues (observed "Argument types do not match" when $reverted.Count is + # evaluated inside a hashtable literal passed to List[object].Add()). + $issueLinks = ($meta.LinkedIssues | ForEach-Object { "#$_" }) -join ', ' + $labelJoined = $meta.Labels -join ', ' + $revertCount = $reverted.Count + $revertedArr = $reverted.ToArray() + + if ($revertCount -gt 0) { + Write-Host " ● REVERT — $revertCount line(s) from #$recentPR being removed" -ForegroundColor Red + foreach ($rl in $reverted) { Write-Host " - $($rl.Text.Trim())" -ForegroundColor Red } + $riskEntry = [PSCustomObject]@{ + File = $filePath + RecentPR = $recentPR + PRTitle = $meta.Title + FixedIssues = $issueLinks + Labels = $labelJoined + Risk = 'REVERT' + Details = "Removes $revertCount line(s) added by fix PR #$recentPR" + RevertedLines = $revertedArr + } + $risks.Add($riskEntry) + } else { + $riskEntry = [PSCustomObject]@{ + File = $filePath + RecentPR = $recentPR + PRTitle = $meta.Title + FixedIssues = $issueLinks + Labels = $labelJoined + Risk = 'OVERLAP' + Details = 'Same file, different lines' + RevertedLines = @() + } + $risks.Add($riskEntry) + } + } +} + +# ─── Extract test files from fix PRs that triggered REVERT ───────────────────── +# For each REVERT, find test files the fix PR added/modified and classify them +# via Detect-TestsInDiff.ps1 (if available). This enables downstream test execution. + +$detectTestsScript = Join-Path $PSScriptRoot "shared/Detect-TestsInDiff.ps1" +$hasTestDetector = Test-Path $detectTestsScript + +$fixPRsWithTests = @{} # fixPR -> array of test metadata + +if ($hasTestDetector) { + # Extract tests for ALL risk entries (REVERT and OVERLAP) for maximum confidence + $allFixPRs = @($risks | Select-Object -ExpandProperty RecentPR -Unique) + + foreach ($fixPR in $allFixPRs) { + if ($fixPRsWithTests.ContainsKey($fixPR)) { continue } + + # Get all file paths from the fix PR diff (already cached) + $fixFiles = @() + if ($fixDiffCache.ContainsKey($fixPR)) { + $fixFiles = @($fixDiffCache[$fixPR].Keys | Where-Object { Test-IsTestFile $_ }) + } + + if ($fixFiles.Count -eq 0) { + Write-Host " [info] Fix PR #$fixPR`: no test files in diff" -ForegroundColor DarkGray + $fixPRsWithTests[$fixPR] = @() + continue + } + + Write-Host " 🧪 Fix PR #$fixPR`: detecting tests from $($fixFiles.Count) test file(s)…" -ForegroundColor Cyan + try { + $detected = & $detectTestsScript -ChangedFiles $fixFiles 2>&1 + # Filter out Write-Host output — only keep returned objects + $testEntries = @($detected | Where-Object { $_ -is [hashtable] -or ($_ -is [PSCustomObject]) }) + if ($testEntries.Count -gt 0) { + Write-Host " Found $($testEntries.Count) test(s)" -ForegroundColor Green + $fixPRsWithTests[$fixPR] = $testEntries + } else { + Write-Host " No classifiable tests found" -ForegroundColor DarkGray + $fixPRsWithTests[$fixPR] = @() + } + } catch { + Write-Host " ⚠️ Test detection failed: $_" -ForegroundColor Yellow + $fixPRsWithTests[$fixPR] = @() + } + } +} else { + Write-Host " ℹ️ Detect-TestsInDiff.ps1 not found — skipping test extraction" -ForegroundColor DarkGray +} + +# Attach test metadata to ALL risk entries (REVERT and OVERLAP) +foreach ($r in $risks) { + $r | Add-Member -NotePropertyName TestsFromFixPR -NotePropertyValue @() -Force + if ($fixPRsWithTests.ContainsKey($r.RecentPR)) { + $r.TestsFromFixPR = $fixPRsWithTests[$r.RecentPR] + } +} + +Write-Banner "Results" + +$reverts = @($risks | Where-Object { $_.Risk -eq 'REVERT' }) +$overlaps = @($risks | Where-Object { $_.Risk -eq 'OVERLAP' }) +$result = if ($reverts.Count -gt 0) { 'REVERT' } + elseif ($overlaps.Count -gt 0) { 'OVERLAP' } + else { 'CLEAN' } + +switch ($result) { + 'REVERT' { + Write-Host "● REVERT RISKS: $($reverts.Count)" -ForegroundColor Red + foreach ($r in $reverts) { + Write-Host "" + Write-Host " File: $($r.File)" -ForegroundColor Red + Write-Host " Fix PR: #$($r.RecentPR) — $($r.PRTitle)" -ForegroundColor Red + Write-Host " Fixed: $($r.FixedIssues)" -ForegroundColor Red + Write-Host " Reverted: $((@($r.RevertedLines) | Select-Object -First 3 | ForEach-Object { $_.Text.Trim() }) -join ' | ')" -ForegroundColor Red + } + $allIssues = @($reverts | ForEach-Object { $_.FixedIssues -split ',\s*' } | + Where-Object { $_ } | Select-Object -Unique | Sort-Object) + if ($allIssues.Count -gt 0) { + Write-Host "" + Write-Host "⚠️ Verify that issues $($allIssues -join ', ') do not re-regress." -ForegroundColor Yellow + } + } + 'OVERLAP' { + Write-Host "● OVERLAPS: $($overlaps.Count) (lower risk — same files, different lines)" -ForegroundColor Yellow + foreach ($o in $overlaps) { + Write-Host " $($o.File) — fix PR #$($o.RecentPR) ($($o.FixedIssues))" -ForegroundColor Yellow + } + } + 'CLEAN' { + Write-Host "● No regression risks detected." -ForegroundColor Green + } +} + +Write-Host "" +Write-Host "(gh API calls: $ghCallCount; PRs inspected: $($inspectedPRs.Count))" -ForegroundColor DarkGray + +# ─── Output files ───────────────────────────────────────────────────────────── + +if ($OutputDir) { + New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null + + # result.txt + $result | Set-Content (Join-Path $OutputDir 'result.txt') -Encoding UTF8 + + # risks.json — structured output for agent consumption + $jsonRisks = @($risks | ForEach-Object { + $entry = @{ + file = $_.File + recent_pr = $_.RecentPR + pr_title = $_.PRTitle + fixed_issues = $_.FixedIssues + labels = $_.Labels + risk = $_.Risk + details = $_.Details + reverted_lines = @(@($_.RevertedLines) | ForEach-Object { @{ text = $_.Text; line = $_.Line } }) + } + # Include test metadata for all risk entries (REVERT and OVERLAP) + if ($_.TestsFromFixPR -and $_.TestsFromFixPR.Count -gt 0) { + $entry['regression_tests'] = @($_.TestsFromFixPR | ForEach-Object { + @{ + type = $_.Type + test_name = $_.TestName + filter = $_.Filter + project_path = $_.ProjectPath + project = $_.Project + runner = $_.Runner + files = @($_.Files) + } + }) + } else { + $entry['regression_tests'] = @() + } + $entry + }) + $payload = @{ + pr_number = $PRNumber + result = $result + revert_count = $reverts.Count + overlap_count= $overlaps.Count + risks = $jsonRisks + } | ConvertTo-Json -Depth 6 + $payload | Set-Content (Join-Path $OutputDir 'risks.json') -Encoding UTF8 + + # 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() + switch ($result) { + 'REVERT' { + [void]$md.AppendLine("✗ **Revert risks detected** — this PR removes $($reverts.Count) line(s) previously added by labeled bug-fix PRs.") + [void]$md.AppendLine() + [void]$md.AppendLine("| File | Fix PR | Fixed issue(s) | Risk | Reverted line |") + [void]$md.AppendLine("|---|---|---|---|---|") + foreach ($r in $reverts) { + $sample = @($r.RevertedLines) | Select-Object -First 1 | ForEach-Object { $_.Text.Trim() } + $sampleEsc = ($sample -replace '\|', '\|') + [void]$md.AppendLine("| ``$($r.File)`` | #$($r.RecentPR) | $($r.FixedIssues) | ✗ REVERT | ``$sampleEsc`` |") + } + $allIssues = @($reverts | ForEach-Object { $_.FixedIssues -split ',\s*' } | + Where-Object { $_ } | Select-Object -Unique | Sort-Object) + if ($allIssues.Count -gt 0) { + [void]$md.AppendLine() + [void]$md.AppendLine("**Action required:** Verify that issues $($allIssues -join ', ') do not re-regress before merging.") + } + + # List regression tests that should be run + $allRegressionTests = @($reverts | Where-Object { $_.TestsFromFixPR.Count -gt 0 } | + ForEach-Object { $pr = $_.RecentPR; $_.TestsFromFixPR | ForEach-Object { + [PSCustomObject]@{ FixPR = $pr; Type = $_.Type; TestName = $_.TestName; Filter = $_.Filter; Runner = $_.Runner } + }}) + if ($allRegressionTests.Count -gt 0) { + [void]$md.AppendLine() + [void]$md.AppendLine("### 🧪 Regression Tests to Verify") + [void]$md.AppendLine() + [void]$md.AppendLine("These tests were added by the fix PRs being reverted. They must still pass:") + [void]$md.AppendLine() + [void]$md.AppendLine("| Fix PR | Type | Test | Filter |") + [void]$md.AppendLine("|---|---|---|---|") + foreach ($t in $allRegressionTests) { + [void]$md.AppendLine("| #$($t.FixPR) | $($t.Type) | $($t.TestName) | ``$($t.Filter)`` |") + } + } + } + 'OVERLAP' { + [void]$md.AppendLine("⚠ **Overlaps with prior bug-fix PRs** — same files modified, but no exact line revert detected.") + [void]$md.AppendLine() + [void]$md.AppendLine("| File | Fix PR | Fixed issue(s) |") + [void]$md.AppendLine("|---|---|---|") + foreach ($o in $overlaps) { + [void]$md.AppendLine("| ``$($o.File)`` | #$($o.RecentPR) | $($o.FixedIssues) |") + } + + # List regression tests from overlapping fix PRs + $overlapTests = @($overlaps | Where-Object { $_.TestsFromFixPR.Count -gt 0 } | + ForEach-Object { $pr = $_.RecentPR; $_.TestsFromFixPR | ForEach-Object { + [PSCustomObject]@{ FixPR = $pr; Type = $_.Type; TestName = $_.TestName; Filter = $_.Filter; Runner = $_.Runner } + }}) + if ($overlapTests.Count -gt 0) { + [void]$md.AppendLine() + [void]$md.AppendLine("### 🧪 Regression Tests to Verify") + [void]$md.AppendLine() + [void]$md.AppendLine("These tests were added by the overlapping fix PRs. Running them to verify no side-effect regressions:") + [void]$md.AppendLine() + [void]$md.AppendLine("| Fix PR | Type | Test | Filter |") + [void]$md.AppendLine("|---|---|---|---|") + foreach ($t in $overlapTests) { + [void]$md.AppendLine("| #$($t.FixPR) | $($t.Type) | $($t.TestName) | ``$($t.Filter)`` |") + } + } + } + 'CLEAN' { + [void]$md.AppendLine("● No regression risks detected. No labeled bug-fix PRs in the last $MonthsBack months touched the modified files.") + } + } + $md.ToString() | Set-Content (Join-Path $OutputDir 'content.md') -Encoding UTF8 + + # inline-findings.json — optional, only if reverts found + if ($WriteInlineFindings -and $reverts.Count -gt 0) { + $inlinePath = Join-Path $OutputDir 'inline-findings.json' + $inline = @() + foreach ($r in $reverts) { + foreach ($rl in @($r.RevertedLines)) { + $prUrl = "https://github.com/$Repo/pull/$($r.RecentPR)" + $body = "● **Regression risk** — this line was added by [#$($r.RecentPR)]($prUrl) to fix $($r.FixedIssues). Removing it may re-introduce the original bug. Please confirm this removal is intentional and that the previously-fixed issue is covered by another mechanism." + $inline += @{ + path = $r.File + line = $rl.Line + body = $body + side = 'LEFT' + } + } + } + ($inline | ConvertTo-Json -Depth 4) | Set-Content $inlinePath -Encoding UTF8 + Write-Host "" + Write-Host "📝 Wrote $($inline.Count) inline finding(s) to $inlinePath" -ForegroundColor DarkGray + } + + Write-Host "" + Write-Host "📁 Outputs written to: $OutputDir" -ForegroundColor DarkGray +} + +exit 0 diff --git a/.github/scripts/Fix-MilestoneDrift.Tests.ps1 b/.github/scripts/Fix-MilestoneDrift.Tests.ps1 new file mode 100644 index 000000000000..9a3006876769 --- /dev/null +++ b/.github/scripts/Fix-MilestoneDrift.Tests.ps1 @@ -0,0 +1,1639 @@ +#!/usr/bin/env pwsh +#Requires -Modules Pester +<# +.SYNOPSIS + Pester tests for Fix-MilestoneDrift.ps1. + Most tests cover the pure functions (milestone mapping, matching, linked-issue + extraction) and never touch GitHub or Git. One block — 'Get-RefinedReleaseMilestone + — git integration (unmocked)' — builds a disposable LOCAL git repo in a temp dir to + exercise the real `git tag -l` / `git merge-base --is-ancestor` plumbing end-to-end; + it still never touches GitHub (no `gh`, no network) and is skipped if git is absent. + +.EXAMPLE + Invoke-Pester ./Fix-MilestoneDrift.Tests.ps1 + Invoke-Pester ./Fix-MilestoneDrift.Tests.ps1 -Output Detailed + +.NOTES + === LIVE VALIDATION GUIDE === + + The following functions require git and GitHub API access and cannot be unit tested + with Pester alone. When modifying these functions, run the dry-run commands below + to validate correctness before merging. + + Functions requiring live validation: + - Invoke-AnalyzeSinglePr (version detection, release branch lookup, fallback logic) + - Invoke-AnalyzeRelease (tag-based batch analysis) + - Find-ReleaseBranchForCommit (git ancestry checks against release branches) + - Get-VersionFromGitRef (reads Versions.props from git refs, fetches missing commits) + - Get-MainBranchForVersion (reads Versions.props from origin/main) + + Dry-run validation commands (run from repo root with gh CLI authenticated): + + # 1. PR merged to inflight/current — should read from origin/main (all branches feed into main) + pwsh -File .github/scripts/Fix-MilestoneDrift.ps1 -PrNumber 34228 -RepoPath . -Verbose + # Expected: "Version from Versions.props on origin/main: 10.0.70", milestone = .NET 10 SR7 + + # 2. PR merged to main, already on a release branch — should use release branch + pwsh -File .github/scripts/Fix-MilestoneDrift.ps1 -PrNumber 34620 -RepoPath . -Verbose + # Expected: "Found in release branch: release/10.0.1xx-sr6 → .NET 10 SR6" + + # 3. PR merged to net11.0 — should read from origin/net11.0, not origin/main + pwsh -File .github/scripts/Fix-MilestoneDrift.ps1 -PrNumber 34969 -RepoPath . -Verbose + # Expected: reads from origin/net11.0, milestone is .NET 11.0-preview{N} + + # 4. PR merged to net11.0, on a preview release branch — should use release branch + pwsh -File .github/scripts/Fix-MilestoneDrift.ps1 -PrNumber 30132 -RepoPath . -Verbose + # Expected: "Found in release branch: release/11.0.1xx-preview3 → .NET 11.0-preview3" + + # 5. PR merged to a release branch directly — should read from that branch + pwsh -File .github/scripts/Fix-MilestoneDrift.ps1 -PrNumber 35016 -RepoPath . -Verbose + # Expected: reads from origin/release/10.0.1xx-sr6, milestone is .NET 10 SR6 + + # 6. Tag-based reconciliation for a preview release + pwsh -File .github/scripts/Fix-MilestoneDrift.ps1 -Tag '11.0.0-preview.3.26203.7' -RepoPath . -Verbose + # Expected: finds preview2 as previous tag, scans ~234 PRs, skips ~180 merge-ups, checks ~53 + + # 7. Tag-based reconciliation for a stable release + pwsh -File .github/scripts/Fix-MilestoneDrift.ps1 -Tag 10.0.50 -RepoPath . -Output /dev/null -Verbose + # Expected: finds 10.0.41 as previous tag, scans ~78 PRs, all .NET 10 + + # 8. URL-form linked-issue discovery — covers the `Get-LinkedIssues` parser branch + # that extracts `Fixes https://github.com/dotnet/maui/issues/N` (the `#N` shorthand + # is the same-repo path; the URL form is the cross-origin form that an external + # contributor or copy-pasted-from-browser link will use). + pwsh -File .github/scripts/Fix-MilestoneDrift.ps1 -PrNumber 35662 -RepoPath . -Verbose + # Expected: report says `Issues checked: 1` (issue #35615 was found via the URL form). + # If the URL branch ever regresses, `Issues checked` will drop to 0 instead. + # Note: this dry-run does NOT exercise Test-MilestoneValidForIssue because PR 35662 + # and issue #35615 share a milestone (.NET 10 SR9), so Test-AndRecordCorrection + # short-circuits before calling the validator. The Pester test at the + # `'matches a fix verb that uses the full URL form (issues/N)'` It-block covers + # the validator's URL-form OR query directly. + + Key things to verify after changes: + - inflight/* and darc/* PRs read from origin/main (they feed into main) + - net11.0 PRs read from origin/net11.0 (never from origin/main) + - PRs on release branches get the milestone from the branch name (not Versions.props) + - Preview tags in tag mode find the correct previous tag (preview2 → preview3, not full history) + - Rebased/cherry-picked PRs are found via commit message grep when ancestry fails +#> + +BeforeAll { + . "$PSScriptRoot/Fix-MilestoneDrift.ps1" +} + +Describe 'Resolve-MergedAfterCutoff' { + It 'defaults to 2026-01-01 UTC when value is ""' -ForEach @( + @{ Value = $null } + @{ Value = '' } + @{ Value = ' ' } + ) { + $result = Resolve-MergedAfterCutoff $Value + $result | Should -Be ([datetime]::new(2026, 1, 1, 0, 0, 0, [System.DateTimeKind]::Utc)) + $result.Kind | Should -Be ([System.DateTimeKind]::Utc) + } + + It 'parses a date-only value "" as UTC midnight' -ForEach @( + @{ Value = '2025-01-01'; Year = 2025; Month = 1; Day = 1 } + @{ Value = '2024-06-15'; Year = 2024; Month = 6; Day = 15 } + @{ Value = '2020-12-31'; Year = 2020; Month = 12; Day = 31 } + ) { + $result = Resolve-MergedAfterCutoff $Value + $result.Year | Should -Be $Year + $result.Month | Should -Be $Month + $result.Day | Should -Be $Day + $result.Hour | Should -Be 0 + $result.Kind | Should -Be ([System.DateTimeKind]::Utc) + } + + It 'parses an ISO-8601 value with explicit UTC offset' { + $result = Resolve-MergedAfterCutoff '2025-06-01T12:30:00Z' + $result.Kind | Should -Be ([System.DateTimeKind]::Utc) + $result | Should -Be ([datetime]::new(2025, 6, 1, 12, 30, 0, [System.DateTimeKind]::Utc)) + } + + It 'normalizes a non-UTC offset to UTC' { + # 2025-06-01T00:00:00+05:00 == 2025-05-31T19:00:00Z + $result = Resolve-MergedAfterCutoff '2025-06-01T00:00:00+05:00' + $result.Kind | Should -Be ([System.DateTimeKind]::Utc) + $result | Should -Be ([datetime]::new(2025, 5, 31, 19, 0, 0, [System.DateTimeKind]::Utc)) + } + + It 'throws a clear error for unparseable value ""' -ForEach @( + @{ Value = 'garbage' } + @{ Value = 'not-a-date' } + @{ Value = '2025-13-99' } + @{ Value = '13/13/2025' } + ) { + { Resolve-MergedAfterCutoff $Value } | Should -Throw "*Invalid -MergedAfter value*" + } +} + +Describe 'Get-PrInfo — merged-after cutoff enforcement' { + BeforeAll { + # Build a GitHub-pulls-API-shaped object (ConvertFrom-Json style) for the mock. + function New-FakePr { + param([string]$MergedAt, [int]$Number = 42) + [pscustomobject]@{ + title = "PR $Number" + html_url = "https://github.com/dotnet/maui/pull/$Number" + body = '' + merged_at = $MergedAt + milestone = $null + base = [pscustomobject]@{ ref = 'net11.0' } + merge_commit_sha = 'deadbeef' + } + } + } + + AfterAll { + # Restore the default cutoff so later Describes are unaffected. + $script:MergedAfterCutoff = Resolve-MergedAfterCutoff '' + } + + It 'skips a PR merged before the cutoff (returns a pre-cutoff sentinel, not $null)' { + $script:MergedAfterCutoff = Resolve-MergedAfterCutoff '' # 2026-01-01 + Mock Invoke-GhApi { New-FakePr -MergedAt '2025-05-01T00:00:00Z' -Number 100 } + $result = Get-PrInfo 100 + $result | Should -BeOfType [hashtable] + $result.SkippedPreCutoff | Should -BeTrue + $result.Number | Should -Be 100 + } + + It 'includes a PR merged on/after the cutoff (returns the object, no skip sentinel)' { + $script:MergedAfterCutoff = Resolve-MergedAfterCutoff '' # 2026-01-01 + Mock Invoke-GhApi { New-FakePr -MergedAt '2026-03-01T00:00:00Z' -Number 101 } + $pr = Get-PrInfo 101 + $pr | Should -Not -BeNullOrEmpty + $pr.Number | Should -Be 101 + $pr.ContainsKey('SkippedPreCutoff') | Should -BeFalse + } + + It 'includes a PR merged exactly at the cutoff boundary (strict less-than)' { + $script:MergedAfterCutoff = Resolve-MergedAfterCutoff '' # 2026-01-01T00:00:00Z + Mock Invoke-GhApi { New-FakePr -MergedAt '2026-01-01T00:00:00Z' -Number 102 } + Get-PrInfo 102 | Should -Not -BeNullOrEmpty + } + + It 'a lowered cutoff lets an older PR through (the configurable use case)' { + # Same 2025 PR that the default cutoff skips is now processed. + $script:MergedAfterCutoff = Resolve-MergedAfterCutoff '2024-01-01' + Mock Invoke-GhApi { New-FakePr -MergedAt '2025-05-01T00:00:00Z' -Number 103 } + $pr = Get-PrInfo 103 + $pr | Should -Not -BeNullOrEmpty + $pr.Number | Should -Be 103 + } + + It 'a raised cutoff skips a PR that the default would include' { + $script:MergedAfterCutoff = Resolve-MergedAfterCutoff '2026-06-01' + Mock Invoke-GhApi { New-FakePr -MergedAt '2026-03-01T00:00:00Z' -Number 104 } + $result = Get-PrInfo 104 + $result.SkippedPreCutoff | Should -BeTrue + $result.Number | Should -Be 104 + } + + It 'never skips an unmerged PR (no merged_at) regardless of cutoff' { + $script:MergedAfterCutoff = Resolve-MergedAfterCutoff '' + Mock Invoke-GhApi { New-FakePr -MergedAt $null -Number 105 } + Get-PrInfo 105 | Should -Not -BeNullOrEmpty + } +} + +Describe 'Invoke-AnalyzeRelease — pre-cutoff skips are accounted separately from errors' { + BeforeEach { + # Minimal mocks so Invoke-AnalyzeRelease reaches the PR loop without touching git/gh. + Mock ConvertTo-Milestone { '.NET 10 SR8' } + Mock Get-AllTags { @('10.0.70', '10.0.80') } + Mock Initialize-MilestoneValidationContext { } + Mock Get-MainBranchForVersion { 'net10.0' } + Mock Get-AllMilestones { @{ '.NET 10 SR8' = 999 } } + Mock Find-MatchingMilestone { @{ Number = 999; Title = '.NET 10 SR8' } } + Mock Get-PrNumbersBetweenTags { @(100, 101) } + # Defensive: only reached for real (non-skipped, non-null) PRs — never hit in these tests. + Mock Test-PrBelongsToVersion { $true } + Mock Test-AndRecordCorrection { } + Mock Get-LinkedIssues { @() } + } + + It 'counts an all-pre-cutoff cohort as skipped, not as errors (no spurious failure)' { + # Regression: a cohort whose PRs all predate the cutoff must NOT be reported as + # "0 PRs checked, N errors" (which makes the top-level script throw a red run). + Mock Get-PrInfo { + param([int]$PrNum) + return @{ SkippedPreCutoff = $true; Number = $PrNum } + } + $report = Invoke-AnalyzeRelease '10.0.80' '10.0.70' '.' + $report.PrsSkippedPreCutoff | Should -Be 2 + $report.PrsChecked | Should -Be 0 + $report.Errors.Count | Should -Be 0 # the top-level throw guard keys off Errors.Count + } + + It 'still records a genuine fetch failure as an error, distinct from a pre-cutoff skip' { + Mock Get-PrInfo { + param([int]$PrNum) + if ($PrNum -eq 101) { return $null } # real fetch failure + return @{ SkippedPreCutoff = $true; Number = $PrNum } # pre-cutoff skip + } + $report = Invoke-AnalyzeRelease '10.0.80' '10.0.70' '.' + $report.PrsSkippedPreCutoff | Should -Be 1 + $report.Errors.Count | Should -Be 1 + $report.Errors[0] | Should -BeLike '*Failed to fetch PR #101*' + } +} + +Describe 'ConvertTo-Milestone' { + It 'maps GA tag "" to ""' -ForEach @( + @{ Tag = '10.0.0'; Expected = '.NET 10.0 GA' } + @{ Tag = '9.0.0'; Expected = '.NET 9.0 GA' } + ) { + ConvertTo-Milestone $Tag | Should -Be $Expected + } + + It 'maps SR tag "" to ""' -ForEach @( + @{ Tag = '10.0.10'; Expected = '.NET 10 SR1' } + @{ Tag = '10.0.11'; Expected = '.NET 10 SR1.1' } + @{ Tag = '10.0.20'; Expected = '.NET 10 SR2' } + @{ Tag = '10.0.31'; Expected = '.NET 10 SR3.1' } + @{ Tag = '10.0.40'; Expected = '.NET 10 SR4' } + @{ Tag = '10.0.41'; Expected = '.NET 10 SR4.1' } + @{ Tag = '10.0.50'; Expected = '.NET 10 SR5' } + @{ Tag = '9.0.82'; Expected = '.NET 9 SR8.2' } + @{ Tag = '9.0.90'; Expected = '.NET 9 SR9' } + @{ Tag = '10.0.100'; Expected = '.NET 10 SR10' } + @{ Tag = '10.0.101'; Expected = '.NET 10 SR10.1' } + ) { + ConvertTo-Milestone $Tag | Should -Be $Expected + } + + It 'maps early patch "" to SR1' -ForEach @( + @{ Tag = '10.0.1' } + @{ Tag = '10.0.5' } + @{ Tag = '10.0.9' } + ) { + ConvertTo-Milestone $Tag | Should -Be '.NET 10.0 SR1' + } + + It 'returns $null for non-SR tags' -ForEach @( + @{ Tag = '10.0.0-preview.7.25406.3' } + @{ Tag = 'not-a-tag' } + @{ Tag = '' } + ) { + ConvertTo-Milestone $Tag | Should -BeNullOrEmpty + } + + It 'maps preview "" with label "