diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index cfd57a994eaf..404f109e00e5 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -42,7 +42,9 @@ 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-preview2 - 11.0.0-preview.1 + - 10.0.50 - 10.0.40 - 10.0.30 - 10.0.20 @@ -162,7 +164,9 @@ body: - 10.0.20 - 10.0.30 - 10.0.40 - - 11.0.0-preview.1 + - 10.0.50 + - 11.0.0-preview.1 + - 11.0.0-preview.2 validations: required: true - type: dropdown diff --git a/.github/README-AI.md b/.github/README-AI.md index c703358dfade..9eeb1f0532ca 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 4-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,9 +146,9 @@ 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 4-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. **Gate** - Verify tests exist and catch the issue (mandatory checkpoint) @@ -199,9 +199,11 @@ Agents work with **time budgets as estimates for planning**, not hard deadlines: ## File Structure -### Agent Definitions -- **`agents/pr.md`** - PR workflow phases 1-2 (Pre-Flight, Gate) -- **`agents/pr/post-gate.md`** - PR workflow phases 3-4 (Fix, Report) +### 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.md`** - Sandbox agent for testing and experimentation - **`agents/write-tests-agent.md`** - Test writing agent (dispatches to skills like write-ui-tests) @@ -209,8 +211,6 @@ Agents work with **time budgets as estimates for planning**, not hard deadlines: Agent files in the `.github/agents/` directory: -- **`agents/pr.md`** - PR workflow phases 1-2 (Pre-Flight, Gate) -- **`agents/pr/post-gate.md`** - PR workflow phases 3-4 (Fix, Report) - **`agents/sandbox-agent.md`** - Sandbox app testing and experimentation - **`agents/write-tests-agent.md`** - Test writing (invokes skills like write-ui-tests) @@ -250,12 +250,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 4-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 @@ -364,8 +364,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) +- 4 agent files (pr-review skill.md, sandbox-agent.md, write-tests-agent.md) +- 5 skills (pr-review, try-fix, verify-tests-fail-without-fix, write-ui-tests, write-xaml-tests, azdo-build-investigator) + 3 phase docs (pr-preflight, pr-gate, pr-report) - All validated and consistent with consolidated structure **Automation**: @@ -383,4 +383,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 4-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/agent-pr-session/pr-31487.md b/.github/agent-pr-session/pr-31487.md new file mode 100644 index 000000000000..b15821b1c3a2 --- /dev/null +++ b/.github/agent-pr-session/pr-31487.md @@ -0,0 +1,261 @@ +# PR Review: #31487 - [Android] Fixed duplicate title icon when setting TitleIconImageSource Multiple times + +**Date:** 2026-01-08 | **Issue:** [#31445](https://github.com/dotnet/maui/issues/31445) | **PR:** [#31487](https://github.com/dotnet/maui/pull/31487) + +## ✅ Final Recommendation: APPROVE + +| Phase | Status | +|-------|--------| +| Pre-Flight | ✅ COMPLETE | +| 🧪 Tests | ✅ COMPLETE | +| 🚦 Gate | ✅ PASSED | +| 🔧 Fix | ✅ COMPLETE | +| 📋 Report | ✅ COMPLETE | + +--- + +
+📋 Issue Summary + +On Android, calling `NavigationPage.SetTitleIconImageSource(page, "image.png")` more than once for the same page results in the icon being rendered multiple times in the navigation bar. + +**Steps to Reproduce:** +1. Launch app on Android +2. Tap "Set TitleIconImageSource" once: icon appears +3. Tap it again: a second identical icon appears + +**Expected:** Single toolbar icon regardless of how many times SetTitleIconImageSource is called. + +**Actual:** Each repeated call adds an additional duplicate icon. + +**Platforms Affected:** +- [ ] iOS +- [x] Android +- [ ] Windows +- [ ] MacCatalyst + +**Version:** 9.0.100 SR10 + +
+ +
+📁 Files Changed + +| File | Type | Changes | +|------|------|---------| +| `src/Controls/src/Core/Platform/Android/Extensions/ToolbarExtensions.cs` | Fix | +17/-6 | +| `src/Controls/tests/TestCases.HostApp/Issues/Issue31445.cs` | Test | +38 | +| `src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue31445.cs` | Test | +23 | +| `snapshots/android/Issue31445DuplicateTitleIconDoesNotAppear.png` | Snapshot | binary | +| `snapshots/mac/Issue31445DuplicateTitleIconDoesNotAppear.png` | Snapshot | binary | +| `snapshots/windows/Issue31445DuplicateTitleIconDoesNotAppear.png` | Snapshot | binary | +| `snapshots/ios/Issue31445DuplicateTitleIconDoesNotAppear.png` | Snapshot | binary | + +
+ +
+💬 PR Discussion Summary + +**Key Comments:** +- Issue verified by LogishaSelvarajSF4525 on MAUI 9.0.0 & 9.0.100 +- PR triggered UI tests by jsuarezruiz +- PureWeen requested rebase + +**Reviewer Feedback:** +- Copilot review: Suggested testing with different image sources or rapid succession to validate fix better + +**Disagreements to Investigate:** +| File:Line | Reviewer Says | Author Says | Status | +|-----------|---------------|-------------|--------| +| Issue31445.cs:31 | Test with different images or rapid calls | N/A | ⚠️ INVESTIGATE | + +**Author Uncertainty:** +- None noted + +
+ +
+🧪 Tests + +**Status**: ✅ COMPLETE + +- [x] PR includes UI tests +- [x] Tests reproduce the issue +- [x] Tests follow naming convention (`Issue31445`) + +**Test Files:** +- HostApp: `src/Controls/tests/TestCases.HostApp/Issues/Issue31445.cs` +- NUnit: `src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue31445.cs` + +**Test Behavior:** +- Uses snapshot verification (`VerifyScreenshot()`) +- Navigates to test page, taps button to trigger duplicate icon scenario +- Verified to compile successfully + +
+ +
+🚦 Gate - Test Verification + +**Status**: ✅ PASSED + +- [x] Tests FAIL without fix (bug reproduced - duplicate icons appeared) +- [x] Tests PASS with fix (single icon as expected) + +**Result:** PASSED ✅ + +**Verification Details:** +- Platform: Android (emulator-5554) +- Without fix: Test FAILED (screenshot mismatch - duplicate icons) +- With fix: Test PASSED (single icon verified) + +
+ +
+🔧 Fix Candidates + +**Status**: ✅ COMPLETE + +| # | Source | Approach | Test Result | Files Changed | Model | Notes | +|---|--------|----------|-------------|---------------|-------|-------| +| 1 | try-fix | Check for existing icon view at position 0, reuse if exists, only create new if needed | ✅ PASS | `ToolbarExtensions.cs` (+7) | Opus 4.5 | Works! Independently arrived at same solution logic as PR | +| 2 | try-fix | Dedupe defensively by scanning all toolbar children, keep first `ToolbarTitleIconImageView`, remove extras; then reuse/create | ✅ PASS | `ToolbarExtensions.cs` (+22/-5) | GPT 5.2 | More robust if child ordering changes or duplicates already exist | +| 3 | try-fix | Use `FindViewWithTag` to uniquely identify/retrieve the MAUI title icon | ✅ PASS | `ToolbarExtensions.cs` (+20/-6) | Gemini 2.0 Flash | Explicit identification; avoids index assumptions and iteration; most robust against external view insertions | +| PR | PR #31487 | Check for existing ToolbarTitleIconImageView before adding new one | ✅ PASS (Gate) | `ToolbarExtensions.cs` (+17/-6) | Author | Original PR - validated by Gate | + +**Exhausted:** Yes (3 passing alternatives found) + +**Selected Fix:** PR's fix - It’s simplest and sufficient. +- #3 (Tag) is the most "correct" for robustness but adds Tag management overhead. +- #2 (Dedupe) is good for cleanup. +- PR/#1 (Index 0) are standard for this codebase's patterns. + +**Comparison Notes:** +- PR/try-fix #1 rely on `GetChildAt(0)` being the title icon view when present +- try-fix #2 is more defensive: it collapses existing duplicates regardless of child index and then reuses/creates as needed +- try-fix #3 uses explicit tagging: precise but introduces new state (Tag) to manage + +
+ +--- + +**Next Step:** Propose Alternative Fix #2 (Dedupe & Scan) to Author for Discussion + +--- + +## 💬 Draft Comment for Author + +Hi @PureWeen, + +Reviewing the fix in this PR, it works correctly for the reported issue and tests pass. + +I explored a couple of alternative approaches and found one that might offer slightly better robustness against edge cases, which I wanted to run by you: + +**Alternative: Dedupe & Scan** +Instead of just checking index 0, we could scan all children of the toolbar to find any `ToolbarTitleIconImageView` instances. + +```csharp +// Scan all children to find existing title icons +ToolbarTitleIconImageView? titleIcon = null; +for (int i = 0; i < nativeToolbar.ChildCount; i++) +{ + var child = nativeToolbar.GetChildAt(i); + if (child is ToolbarTitleIconImageView icon) + { + if (titleIcon == null) + titleIcon = icon; // Keep the first one found + else + nativeToolbar.RemoveView(icon); // Remove any extras (self-healing) + } +} +``` + +**Why consider this?** +1. **Robustness against Injection:** If another library inserts a view at index 0 (e.g., search bar), the current PR fix (checking only index 0) would fail to see the existing icon and create a duplicate. +2. **Self-Healing:** If the toolbar is already in a bad state (multiple icons from previous bugs), this approach cleans them up. + +**Trade-off:** +It involves a loop, so O(N) instead of O(1), but for a toolbar with very few items, this is negligible. + +Do you think the added robustness is worth the change, or should we stick to the simpler Index 0 check (current PR) which matches the existing removal logic? + +--- + +## 📋 Final Report + +### Summary + +PR #31487 correctly fixes the duplicate title icon issue on Android. The fix checks for an existing `ToolbarTitleIconImageView` at position 0 before creating a new one, preventing duplicate icons when `SetTitleIconImageSource` is called multiple times. + +### Root Cause + +The original `UpdateTitleIcon` method always created a new `ToolbarTitleIconImageView` and added it to position 0, without checking if one already existed. This caused duplicate icons when the method was called repeatedly. + +### Validation + +| Check | Result | +|-------|--------| +| Tests reproduce bug | ✅ Test fails without fix (duplicate icons) | +| Tests pass with fix | ✅ Test passes with fix (single icon) | +| Independent fix analysis | ✅ try-fix arrived at same solution | +| Code quality | ✅ Clean, minimal change | + +### Regression Analysis + +
+📜 Git History Analysis + +**Original Implementation:** `e2f3aaa222` (Oct 2021) by Shane Neuville +- Part of "[Android] ToolbarHandler and fixes for various page nesting scenarios (#2781)" +- The bug has existed since the original implementation - it was never designed to handle repeated calls + +**Key Finding:** The original code had a check for removing an existing icon when source is null/empty: +```csharp +if (nativeToolbar.GetChildAt(0) is ToolbarTitleIconImageView existingImageView) + nativeToolbar.RemoveView(existingImageView); +``` +But this check was **only in the removal path**, not in the creation path. The fix extends this pattern to also check before adding. + +**Related Toolbar Issues in This File:** +| Commit | Issue | Description | +|--------|-------|-------------| +| `a93e88c3de` | #7823 | Fix toolbar item icon not removed when navigating | +| `c04b7d79cc` | #19673 | Fixed android toolbar icon change | +| `158ed8b4f1` | #28767 | Removing outdated menu items after activity switch | + +**Pattern:** Multiple fixes in this file address issues where Android toolbar state isn't properly cleaned up or reused. This PR follows the same pattern. + +
+ +
+🔄 Platform Comparison + +| Platform | TitleIcon Implementation | Duplicate Prevention | +|----------|-------------------------|---------------------| +| **Android** | Creates `ToolbarTitleIconImageView`, adds to position 0 | ❌ Was missing (now fixed by PR) | +| **Windows** | Sets `TitleIconImageSource` property directly | ✅ Property-based, no duplicates possible | +| **iOS** | Uses `NavigationRenderer` with property binding | ✅ Property-based approach | + +**Why Android was vulnerable:** Android uses a view-based approach (adding/removing child views) while other platforms use property-based approaches. View management requires explicit duplicate checks. + +
+ +
+⚠️ Risk Assessment + +**Regression Risk: LOW** + +1. **Minimal change** - Only modifies the creation logic, doesn't change removal +2. **Consistent pattern** - Uses same `GetChildAt(0)` check that already existed for removal +3. **Well-tested** - UI test verifies the specific scenario +4. **No side effects** - Reusing existing view is safe; `SetImageDrawable` handles updates + +**Potential Edge Cases (from Copilot review suggestion):** +- Setting different image sources rapidly → Should work fine, image is updated on existing view +- Setting same source multiple times → Explicitly tested, works correctly + +
+ +### Recommendation + +**✅ APPROVE** - The PR's approach is correct and validated by independent analysis. The fix is minimal, focused, and addresses the root cause. diff --git a/.github/agents/learn-from-pr.md b/.github/agents/learn-from-pr.md index ce0a506c3af3..759d1db88328 100644 --- a/.github/agents/learn-from-pr.md +++ b/.github/agents/learn-from-pr.md @@ -89,7 +89,6 @@ Present a summary: | 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 | diff --git a/.github/agents/pr.md b/.github/agents/pr.md deleted file mode 100644 index d5c55dbc80de..000000000000 --- a/.github/agents/pr.md +++ /dev/null @@ -1,463 +0,0 @@ ---- -name: pr -description: Sequential 4-phase workflow for GitHub issues - Pre-Flight, 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-2** (Pre-Flight → Gate). - -After Gate passes, read `.github/agents/pr/post-gate.md` for **Phases 3-4**. - -``` -┌─────────────────────────────────────────┐ ┌─────────────────────────────────────────────┐ -│ THIS FILE: pr.md │ │ pr/post-gate.md │ -│ │ │ │ -│ 1. Pre-Flight → 2. Gate │ ──► │ 3. Fix → 4. 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 3 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 3: 🔧 Fix | -| Look at implementation code | That's understanding the bug | Phase 3: 🔧 Fix | -| Design or implement fixes | That's solution design | Phase 3: 🔧 Fix | -| Form opinions on correct approach | That's analysis | Phase 3: 🔧 Fix | -| Run tests | That's verification | Phase 2: 🚦 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 | -| 🚦 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] - -
- -
-🚦 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 3. 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 3-4. -``` - -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 3-4 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 🚦 Gate 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 - ---- - -## 🚦 GATE: Verify Tests Catch the Issue (Phase 2) - -> **SCOPE**: Verify tests exist and correctly detect the fix (for PRs) or reproduce the bug (for issues). - -**⛔ This phase MUST pass before continuing. If it fails, stop and fix the tests.** - -**⚠️ Gate Check:** Pre-Flight must be `✅ COMPLETE` before starting this phase. - -### Step 1: Check if Tests 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** → Proceed to verification. - -**If NO tests exist** → Let the user know that tests are missing. They can use the `write-tests-agent` to help create them. - -### Step 2: Select Platform - -**🚨 CRITICAL: Choose a platform that is BOTH affected by the bug AND available on the current host.** - -**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 - -**Match to available platforms on current host:** - -| Host OS | Available Platforms | -|---------|---------------------| -| Windows | Android, Windows | -| macOS | Android, iOS, MacCatalyst | - -**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. - -### Step 3: Run Verification - -**🚨 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. Let the user know the tests need to be fixed. They can use the `write-tests-agent` for help. - -### Complete 🚦 Gate - -**🚨 MANDATORY: Update state file** - -**Update state file**: -1. Fill in **Result**: `PASSED ✅` -2. Change 🚦 Gate status to `✅ PASSED` -3. Proceed to Phase 3 - -**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 3-4. - -**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 3 -- ❌ **Looking at implementation code during Pre-Flight** - Just gather issue/PR context -- ❌ **Forming opinions on the fix during Pre-Flight** - That's Phase 3 -- ❌ **Running tests during Pre-Flight** - That's Phase 2 (Gate) -- ❌ **Not creating state file first** - ALWAYS create state file before gathering context -- ❌ **Skipping to Phase 3** - 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 6e9836d7d9fd..000000000000 --- a/.github/agents/pr/PLAN-TEMPLATE.md +++ /dev/null @@ -1,111 +0,0 @@ -# PR Review Plan Template - -**Reusable checklist** for the 4-phase PR Agent workflow. - -**Source documents:** -- `.github/agents/pr.md` - Phases 1-2 (Pre-Flight, Gate) -- `.github/agents/pr/post-gate.md` - Phases 3-4 (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: Gate ⛔ -**🚨 Cannot continue if Gate fails** - -- [ ] Check if tests exist (if not, let the user know and suggest using `write-tests-agent`) -- [ ] 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 3: Fix 🔧 -*(Only if Gate ✅ PASSED)* - -**Round 1: Run try-fix with each model (SEQUENTIAL)** -- [ ] claude-sonnet-4.6 -- [ ] claude-opus-4.6 -- [ ] gpt-5.2 -- [ ] gpt-5.3-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 6 say "NO NEW IDEAS" (max 3 rounds) - -**Completion:** -- [ ] Cross-Pollination table has all 6 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 4: Report 📋 -*(Only if Phases 1-3 complete)* - -- [ ] Run `pr-finalize` skill -- [ ] Generate review: root cause, candidates, recommendation -- [ ] Post AI Summary comment (PR phases + try-fix): - ```bash - pwsh .github/skills/ai-summary-comment/scripts/post-ai-summary-comment.ps1 -PRNumber XXXXX -SkipValidation - pwsh .github/skills/ai-summary-comment/scripts/post-try-fix-comment.ps1 -IssueNumber XXXXX - ``` -- [ ] Post PR Finalization comment (separate): - ```bash - pwsh .github/skills/ai-summary-comment/scripts/post-pr-finalize-comment.ps1 -PRNumber XXXXX -SummaryFile CustomAgentLogsTmp/PRState/pr-XXXXX.md - ``` -- [ ] Update state file: Report → ✅ COMPLETE -- [ ] Save final state file - ---- - -## Quick Reference - -| Phase | Key Action | Blocker Response | -|-------|------------|------------------| -| Pre-Flight | Create state file | 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 1347d92e659a..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.6` | -| 2 | `claude-opus-4.6` | -| 3 | `gpt-5.2` | -| 4 | `gpt-5.3-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 e5a149d070fb..000000000000 --- a/.github/agents/pr/post-gate.md +++ /dev/null @@ -1,368 +0,0 @@ -# PR Agent: Post-Gate Phases (3-4) - -**⚠️ 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-2 first. - ---- - -## Workflow Overview - -| Phase | Name | What Happens | -|-------|------|--------------| -| 3 | **Fix** | Invoke `try-fix` skill repeatedly to explore independent alternatives, then compare with PR's fix | -| 4 | **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. - -### 🚨 CRITICAL: Stop on Environment Blockers (Applies to Phase 3) - -The same "Stop on Environment Blockers" rule from `pr.md` applies here. If try-fix cannot run due to: -- Missing Appium drivers -- Device/emulator not available -- WinAppDriver not installed -- Platform tools missing - -**STOP and ask the user** before continuing. Do NOT mark try-fix attempts as "BLOCKED" and continue. Either fix the environment issue or get explicit user permission to skip. - ---- - -## 🔧 FIX: Explore and Select Fix (Phase 3) - -> **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 3 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 3 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 **6 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.** - -**🧹 MANDATORY: Clean up between attempts.** After each try-fix completes (pass or fail), run these commands before starting the next attempt: - -```bash -# 1. Restore any baseline state from the previous attempt (safe no-op if none exists) -pwsh .github/scripts/EstablishBrokenBaseline.ps1 -Restore - -# 2. Restore all tracked files to HEAD (the merged PR state) -# This catches any files the previous attempt modified but didn't restore -git checkout HEAD -- . - -# 3. Remove untracked files added by the previous attempt -# git checkout restores tracked files but does NOT remove new untracked files -git clean -fd --exclude=CustomAgentLogsTmp/ -``` - -**Why this is required:** Each try-fix attempt modifies source files. If an attempt fails mid-way (build error, timeout, model error), it may not run its own cleanup step. Without explicit cleanup, the next attempt starts with a dirty working tree, which can cause missing files, corrupt state, or misleading test results. Use `HEAD` (not just `-- .`) to also restore deleted files. - -#### 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 6 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.6 | NO NEW IDEAS | - | claude-opus-4.6 | NO NEW IDEAS | - | gpt-5.2 | NO NEW IDEAS | - | gpt-5.3-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 4) - -> **SCOPE**: Deliver the final result - either a PR review or a new PR. - -**⚠️ Gate Check:** Verify ALL phases 1-3 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 -- ❌ **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 -- ❌ **Skipping cleanup between attempts** - ALWAYS run `-Restore` + `git checkout HEAD -- .` + `git clean -fd --exclude=CustomAgentLogsTmp/` between try-fix attempts (see Step 1) - ---- - -## Common Errors and Recovery - -### skill(try-fix) fails with "ENOENT: no such file or directory" - -**Symptom:** `skill(try-fix) Failed to read skill file: Error: ENOENT: no such file or directory, open '.../.github/skills/try-fix/SKILL.md'` - -**Root cause:** A previous try-fix attempt failed mid-way and left the working tree in a dirty state. Files may have been modified or deleted by `EstablishBrokenBaseline.ps1` without being restored. - -**Fix:** Run cleanup before retrying: -```bash -pwsh .github/scripts/EstablishBrokenBaseline.ps1 -Restore -git checkout HEAD -- . -git clean -fd --exclude=CustomAgentLogsTmp/ -``` - -Then retry the try-fix attempt. The skill file should now be accessible. - -**Prevention:** Always run the cleanup commands between try-fix attempts (see Step 1). - -### try-fix attempt starts with dirty working tree - -**Symptom:** `git status` shows modified files before the attempt starts, or the build fails with unexpected errors from files the attempt didn't touch. - -**Root cause:** Previous attempt didn't restore its changes (crashed, timed out, or model didn't follow Step 8 restore instructions). - -**Fix:** Same as above — run `-Restore` + `git checkout HEAD -- .` + `git clean -fd --exclude=CustomAgentLogsTmp/` to reset to the merged PR state. - -### Build errors unrelated to the fix being attempted - -**Symptom:** Build fails with errors in files the try-fix attempt didn't modify (e.g., XAML parse errors, unrelated compilation failures). - -**Root cause:** Often caused by dirty working tree from a previous attempt. Can also be transient environment issues. - -**Fix:** -1. Run cleanup: `pwsh .github/scripts/EstablishBrokenBaseline.ps1 -Restore && git checkout HEAD -- . && git clean -fd --exclude=CustomAgentLogsTmp/` -2. Retry the attempt -3. If it fails again with the same unrelated error, treat this as an environment/worktree blocker: STOP the try-fix workflow, do NOT continue with the next model, and ask the user to investigate (see "Stop on Environment Blockers"). diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index aaa22df4882e..a61c27e93e5d 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -138,6 +138,26 @@ When working with public API changes: - **Use `dotnet format analyzers`** if having trouble - **If files are incorrect**: Revert all changes, then add only the necessary new API entries +**🚨 CRITICAL: `#nullable enable` must be line 1** + +Every `PublicAPI.Unshipped.txt` file starts with `#nullable enable` (often BOM-prefixed: `#nullable enable`) on the **first line**. If this line is moved or removed, the analyzer treats it as a declared API symbol and emits **RS0017** errors. + +**Never sort these files with plain `sort`** — the BOM bytes (`0xEF 0xBB 0xBF`) sort after ASCII characters under `LC_ALL=C`, pushing `#nullable enable` to the bottom of the file. + +When resolving merge conflicts or adding entries, use this safe pattern that preserves line 1: +```bash +for f in $(git diff --name-only --diff-filter=U | grep "PublicAPI.Unshipped.txt"); do + # Extract and preserve the #nullable enable line (with or without BOM) + HEADER=$(head -1 "$f" | grep -o '.*#nullable enable' || echo '#nullable enable') + # Strip conflict markers, remove all #nullable lines, sort+dedup the API entries + grep -v '^<<<<<<\|^======\|^>>>>>>\|#nullable enable' "$f" | LC_ALL=C sort -u | sed '/^$/d' > /tmp/api_fix.txt + # Reassemble: header first, then sorted entries + printf '%s\n' "$HEADER" > "$f" + cat /tmp/api_fix.txt >> "$f" + git add "$f" +done +``` + ### Branching - `main` - For bug fixes without API changes - `net10.0` - For new features and API changes @@ -181,10 +201,17 @@ git commit -m "Fix: Description of the change" 2. Exception: If the user's instructions explicitly include pushing, proceed without asking. ### Documentation + - Update XML documentation for public APIs - Follow existing code documentation patterns - Update relevant docs in `docs/` folder when needed +**Platform-Specific Documentation:** +- `.github/instructions/safe-area-ios.instructions.md` - Safe area investigation (iOS/macCatalyst) +- `.github/instructions/uitests.instructions.md` - UI test guidelines (includes safe area testing section) +- `.github/instructions/android.instructions.md` - Android handler implementation +- `.github/instructions/xaml-unittests.instructions.md` - XAML unit test guidelines + ### Opening PRs All PRs are required to have this at the top of the description: @@ -215,24 +242,18 @@ The repository includes specialized custom agents and reusable skills for specif ### Available Custom Agents -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" - - **Do NOT use for**: Just running tests manually → Use `sandbox-agent` - -2. **write-tests-agent** - Agent for writing tests. Determines test type (UI vs XAML) and invokes the appropriate skill (`write-ui-tests`, `write-xaml-tests`) +1. **write-tests-agent** - Agent for writing tests. Determines test type (UI vs XAML) and invokes the appropriate skill (`write-ui-tests`, `write-xaml-tests`) - **Use when**: Creating new tests for issues or PRs - **Capabilities**: Test type determination (UI and XAML), skill invocation, test verification - **Trigger phrases**: "write tests for #XXXXX", "create tests", "add test coverage" -3. **sandbox-agent** - Specialized agent for working with the Sandbox app for testing, validation, and experimentation +2. **sandbox-agent** - Specialized agent for working with the Sandbox app for testing, validation, and experimentation - **Use when**: User wants to manually test PR functionality or reproduce issues - **Capabilities**: Sandbox app setup, Appium-based manual testing, PR functional validation - **Trigger phrases**: "test this PR", "validate PR #XXXXX in Sandbox", "reproduce issue #XXXXX", "try out in Sandbox" - - **Do NOT use for**: Code review (use pr agent), writing automated tests (use write-tests-agent) + - **Do NOT use for**: Code review (use `pr-review` skill), writing automated tests (use write-tests-agent) -4. **learn-from-pr** - Extracts lessons from PRs and applies improvements to the repository +3. **learn-from-pr** - Extracts lessons from PRs and applies improvements to the repository - **Use when**: After complex PR, want to improve instruction files/skills based on lessons learned - **Capabilities**: Analyzes PR, identifies failure modes, applies improvements to instruction files, skills, code comments - **Trigger phrases**: "learn from PR #XXXXX and apply improvements", "improve repo based on what we learned", "update skills based on PR" @@ -245,73 +266,90 @@ Skills are modular capabilities that can be invoked directly or used by agents. #### 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 — follows phase instructions: pr-preflight, pr-gate, try-fix, pr-report + - **Trigger phrases**: "review PR #XXXXX", "work on PR #XXXXX", "fix issue #XXXXX", "continue PR #XXXXX" + - **Capabilities**: Multi-model fix exploration, test verification, 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-gate.md` — Verify tests FAIL without fix, PASS with fix + - `pr-report.md` — Final recommendation + - **Phase skill**: `try-fix` — Multi-model fix exploration + +3. **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` -2. **find-reviewable-pr** (`.github/skills/find-reviewable-pr/SKILL.md`) +4. **find-reviewable-pr** (`.github/skills/find-reviewable-pr/SKILL.md`) - **Purpose**: Finds open PRs in dotnet/maui and dotnet/docs-maui that need review - **Trigger phrases**: "find PRs to review", "show milestoned PRs", "find partner PRs" - **Scripts**: `query-reviewable-prs.ps1` - **Categories**: P/0, milestoned, partner, community, recent, docs-maui -3. **pr-finalize** (`.github/skills/pr-finalize/SKILL.md`) +5. **pr-finalize** (`.github/skills/pr-finalize/SKILL.md`) - **Purpose**: Verifies PR title and description match actual implementation, AND performs code review for best practices before merge. - **Trigger phrases**: "finalize PR #XXXXX", "check PR description for #XXXXX", "review commit message" - **Used by**: Before merging any PR, when description may be stale - **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`) +6. **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`) +7. **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`) +8. **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`) +9. **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 - **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" +10. **azdo-build-investigator** (`.github/skills/azdo-build-investigator/SKILL.md`) + - **Purpose**: Investigates CI failures for PRs — build errors, Helix test logs, and binlog analysis. Delegates to the `ci-analysis` skill from the dotnet/arcade-skills plugin. + - **Trigger phrases**: "check build for PR #XXXXX", "why did PR build fail", "get build status", "what's failing on PR", "Helix failures" - **Used by**: When investigating CI failures -8. **run-integration-tests** (`.github/skills/run-integration-tests/SKILL.md`) +11. **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 -#### Internal Skills (Used by Agents) +12. **evaluate-pr-tests** (`.github/skills/evaluate-pr-tests/SKILL.md`) + - **Purpose**: Evaluates tests added in a PR for coverage, quality, edge cases, and test type appropriateness + - **Trigger phrases**: "evaluate tests in PR #XXXXX", "review test quality", "are these tests good enough", "check test coverage" + - **Scripts**: `Gather-TestContext.ps1` + - **Checks**: Fix coverage, edge case gaps, test type preference (unit > device > UI), conventions, flakiness risk, duplicate coverage, platform scope, assertion quality + +#### Internal Skills (Used by Skills/Agents) -9. **try-fix** (`.github/skills/try-fix/SKILL.md`) +12. **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 + - **Used by**: `pr-review` skill Phase 3 (Try-Fix phase) - rarely invoked directly by users - **Behavior**: Reads prior attempts to learn from failures. Max 5 attempts per session. - **Output**: Updates session markdown with attempt results and failure analysis -### Using Custom Agents +### Using Custom Agents and Skills -**Delegation Policy**: When user request matches agent trigger phrases, **ALWAYS delegate to the appropriate agent immediately**. Do not ask for permission or explain alternatives unless the request is ambiguous. +**Delegation Policy**: When user request matches skill/agent trigger phrases, **ALWAYS invoke the appropriate skill or delegate to the agent immediately**. Do not ask for permission or explain alternatives unless the request is ambiguous. **Examples of correct delegation**: -- User: "Review PR #12345" → Immediately invoke **pr** agent +- User: "Review PR #12345" → Immediately invoke **pr-review** skill - User: "Test this PR" → Immediately invoke **sandbox-agent** -- User: "Fix issue #67890" (no PR exists) → Suggest using `/delegate` command +- User: "Fix issue #67890" → Immediately invoke **pr-review** skill - User: "Write tests for issue #12345" → Immediately invoke **write-tests-agent** **When NOT to delegate**: 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..2a43521e4c14 --- /dev/null +++ b/.github/docs/agent-labels.md @@ -0,0 +1,171 @@ +# 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 Label + +Applied by MAUI maintainers, not by automation. + +| 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 | + +--- + +## 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: PR Finalize (optional) +├── Phase 3: Post Comments (optional) +└── Phase 4: 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 +``` + +Labels are applied exclusively from `Review-PR.ps1` Phase 4. No other script applies agent labels. This single-source design avoids label conflicts and simplifies debugging. + +### 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/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 | +| `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/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/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/pr-review/pr-gate.md b/.github/pr-review/pr-gate.md new file mode 100644 index 000000000000..817496178333 --- /dev/null +++ b/.github/pr-review/pr-gate.md @@ -0,0 +1,96 @@ +# PR Gate — Test Verification + +> **⛔ This phase MUST pass before continuing to Try-Fix. If it fails, stop and inform user.** + +> 🚨 Gate verification MUST run via task agent — never inline. + +--- + +## 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. **Check if tests exist:** + ```bash + gh pr view XXXXX --json files --jq '.files[].path' | grep -E "TestCases\.(HostApp|Shared\.Tests)" + ``` + If NO tests exist → inform user, suggest `write-tests-agent`. Gate is ⚠️ SKIPPED. + +2. **Select platform** — must be affected by bug AND available on host (see Platform Selection above). + +3. **Run verification via task agent** (MUST use task agent — never inline): + ``` + Invoke the `task` agent with this prompt: + + "Invoke the verify-tests-fail-without-fix skill for this PR: + - Platform: {platform} + - 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. + +--- + +## Expected Result + +``` +╔═══════════════════════════════════════════════════════════╗ +║ VERIFICATION PASSED ✅ ║ +╠═══════════════════════════════════════════════════════════╣ +║ - FAIL without fix (as expected) ║ +║ - PASS with fix (as expected) ║ +╚═══════════════════════════════════════════════════════════╝ +``` + +--- + +## 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 + +```bash +mkdir -p CustomAgentLogsTmp/PRState/{PRNumber}/PRAgent/gate +``` + +Write `content.md`: +```markdown +### Gate Result: {✅ PASSED / ❌ FAILED / ⚠️ SKIPPED} + +**Platform:** {platform} +**Mode:** Full Verification + +- Tests FAIL without fix: {✅/❌} +- Tests PASS with fix: {✅/❌} +``` + +--- + +## Common Mistakes + +- ❌ Running inline — MUST use task agent +- ❌ Using `BuildAndRunHostApp.ps1` — that runs ONE direction; the skill does TWO +- ❌ Claiming results from a single test run — script does TWO runs automatically diff --git a/.github/pr-review/pr-preflight.md b/.github/pr-review/pr-preflight.md new file mode 100644 index 000000000000..0c5ae3f9b0b3 --- /dev/null +++ b/.github/pr-review/pr-preflight.md @@ -0,0 +1,67 @@ +# PR Pre-Flight — Context Gathering + +> **SCOPE:** Document only. No code analysis. No fix opinions. No running tests. + +--- + +## Steps + +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) + +```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. + +--- + +## Output File + +```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} + +### Fix Candidates +| # | Source | Approach | Test Result | Files Changed | Notes | +|---|--------|----------|-------------|---------------|-------| +| PR | PR #XXXXX | {approach} | ⏳ PENDING (Gate) | `file.cs` | Original PR | +``` + +--- + +## Common Mistakes + +- ❌ Researching root cause — save for Try-Fix phase +- ❌ Looking at implementation code — just gather context +- ❌ Running tests — that's the Gate phase diff --git a/.github/pr-review/pr-report.md b/.github/pr-review/pr-report.md new file mode 100644 index 000000000000..ffe233ad1172 --- /dev/null +++ b/.github/pr-review/pr-report.md @@ -0,0 +1,86 @@ +# 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. + +--- + +## Prerequisites + +- Phases 1-3 (Pre-Flight, Gate, Try-Fix) must be complete before starting + +--- + +## Steps + +1. **Determine recommendation:** + + | Condition | Recommendation | + |-----------|----------------| + | PR's fix selected and Gate passed | `✅ APPROVE` | + | Alternative fix found via Try-Fix | `⚠️ REQUEST CHANGES` — suggest alternative | + | Gate failed | `⚠️ REQUEST CHANGES` — fix doesn't work | + +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} | +| Gate | ✅ PASSED | {platform} | +| Try-Fix | ✅ COMPLETE | {N} attempts, {M} passing | +| Report | ✅ COMPLETE | | + +### Summary +{Brief summary of the review} + +### Root Cause +{Root cause analysis} + +### Fix Quality +{Assessment of the fix} +``` + +--- + +## 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/BuildAndRunHostApp.ps1 b/.github/scripts/BuildAndRunHostApp.ps1 index c6103609de46..b349753980eb 100644 --- a/.github/scripts/BuildAndRunHostApp.ps1 +++ b/.github/scripts/BuildAndRunHostApp.ps1 @@ -232,6 +232,28 @@ if ($Category) { if ($Platform -eq "android") { Write-Info "Clearing Android logcat buffer before test..." & adb -s $DeviceUdid logcat -c + + # Dismiss any ANR dialogs that may have appeared during build/deploy. + # The emulator can sit idle during long builds, causing SystemUI ANR. + Write-Info "Dismissing any system dialogs before test..." + & adb -s $DeviceUdid shell am broadcast -a android.intent.action.CLOSE_SYSTEM_DIALOGS 2>$null + & adb -s $DeviceUdid shell input keyevent KEYCODE_ENTER 2>$null + & adb -s $DeviceUdid shell input keyevent KEYCODE_BACK 2>$null + Start-Sleep -Seconds 1 + & adb -s $DeviceUdid shell input keyevent KEYCODE_WAKEUP 2>$null + & adb -s $DeviceUdid shell input keyevent KEYCODE_MENU 2>$null + Start-Sleep -Seconds 1 + + # Check for lingering ANR dialogs via window dump + $windowDump = & adb -s $DeviceUdid shell dumpsys window 2>$null | Select-String "Application Not Responding|ANR" + if ($windowDump) { + Write-Warn "ANR dialog detected — force-dismissing..." + & 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 + Start-Sleep -Seconds 1 + } } # Capture test start time for iOS logs @@ -260,7 +282,9 @@ 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-Warn "MacCatalyst app not found at: $appPath" Write-Warn "Test may use wrong app bundle if another version is registered" @@ -277,6 +301,11 @@ Write-Host "" $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)" + try { # Run dotnet test and capture output $testOutput = & dotnet test $TestProject --filter $effectiveFilter --logger "console;verbosity=detailed" 2>&1 @@ -311,6 +340,37 @@ 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/net10.0"), + (Join-Path $RepoRoot "artifacts/bin/Controls.TestCases.iOS.Tests/Debug/net10.0"), + (Join-Path $RepoRoot "artifacts/bin/Controls.TestCases.Mac.Tests/Debug/net10.0") +) + +$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..." diff --git a/.github/scripts/EstablishBrokenBaseline.ps1 b/.github/scripts/EstablishBrokenBaseline.ps1 index 70a2f8a1c19b..2a6d57506ec2 100644 --- a/.github/scripts/EstablishBrokenBaseline.ps1 +++ b/.github/scripts/EstablishBrokenBaseline.ps1 @@ -335,11 +335,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/Review-PR.ps1 b/.github/scripts/Review-PR.ps1 index 1d8749d98aa6..e8547572e751 100644 --- a/.github/scripts/Review-PR.ps1 +++ b/.github/scripts/Review-PR.ps1 @@ -1,64 +1,40 @@ <# .SYNOPSIS - Runs a PR review using Copilot CLI and the PR Agent workflow. + Runs a PR review using Copilot CLI with skill-based prompts. .DESCRIPTION - This script invokes Copilot CLI to perform a comprehensive 4-phase PR review: + Orchestrates a 5-step PR review by invoking Copilot CLI with skill prompts: - Phase 1: Pre-Flight - Context gathering - Phase 2: Gate - Verify tests catch the bug - Phase 3: Fix - Multi-model exploration of alternatives - Phase 4: Report - Final recommendation - - The script: - - Validates prerequisites (gh CLI, PR exists) - - Validates current branch is not protected (main, release/*, net*.0) - - Merges the PR into the current branch (for isolated testing) - - Creates the state directory - - Invokes Copilot CLI with the pr agent + Step 0: Branch setup - Create review branch from main, merge PR squashed + Step 1: pr-review skill - 4-phase review (Pre-Flight, Gate, Try-Fix, Report) + Step 2: pr-finalize skill - Verify PR title/description match implementation + Step 3: Post AI Summary - Directly runs posting scripts (review + finalize) + Step 4: Apply labels - Apply agent labels based on review results + + By default, the script checks out main and creates a review branch from it. + If squash-merge conflicts, the script posts a comment on the PR and exits. + Use -UseCurrentBranch to create the review branch from the current branch instead. .PARAMETER PRNumber - The GitHub PR number to review (e.g., 33687) + The GitHub PR number to review .PARAMETER Platform - The platform to use for testing. Default is 'android'. - Valid values: android, ios, windows, maccatalyst - -.PARAMETER SkipMerge - If specified, skips merging the PR into the current branch (useful if already merged) + Platform for testing. Valid: android, ios, windows, maccatalyst -.PARAMETER Interactive - If specified, starts Copilot in interactive mode with the prompt. - Default is non-interactive mode (exits after completion). +.PARAMETER UseCurrentBranch + Create the review branch from the current branch instead of main. + By default, the script checks out main before creating the review branch. .PARAMETER DryRun - If specified, shows what would be done without making changes + Show what would be done without making changes -.PARAMETER RunFinalize - If specified, runs the pr-finalize skill after the PR agent completes - to verify PR title/description match the implementation. - -.PARAMETER PostSummaryComment - If specified, runs the ai-summary-comment skill after all other phases complete - to post a combined summary comment on the PR from all phases. +.PARAMETER LogFile + Capture all output via Start-Transcript .EXAMPLE .\Review-PR.ps1 -PRNumber 33687 - Reviews PR #33687 in non-interactive mode (default) using auto-detected platform - -.EXAMPLE - .\Review-PR.ps1 -PRNumber 33687 -Platform ios -SkipMerge - Reviews PR #33687 on iOS without merging (assumes already merged) - -.EXAMPLE - .\Review-PR.ps1 -PRNumber 33687 -Interactive - Reviews PR #33687 in interactive mode (stays open for follow-up questions) - -.NOTES - Prerequisites: - - GitHub CLI (gh) installed and authenticated - - Copilot CLI (copilot) installed - - For testing: Appropriate platform tools (Appium, emulators, etc.) + .\Review-PR.ps1 -PRNumber 33687 -Platform ios + .\Review-PR.ps1 -PRNumber 33687 -UseCurrentBranch #> [CmdletBinding()] @@ -68,30 +44,20 @@ param( [Parameter(Mandatory = $false)] [ValidateSet('android', 'ios', 'windows', 'maccatalyst')] - [string]$Platform, # Optional - agent will determine appropriate platform if not specified + [string]$Platform, [Parameter(Mandatory = $false)] - [switch]$SkipMerge, - - [Parameter(Mandatory = $false)] - [switch]$Interactive, + [switch]$UseCurrentBranch, [Parameter(Mandatory = $false)] [switch]$DryRun, [Parameter(Mandatory = $false)] - [switch]$PostSummaryComment, - - [Parameter(Mandatory = $false)] - [switch]$RunFinalize, - - [Parameter(Mandatory = $false)] - [string]$LogFile # If provided, captures all output via Start-Transcript + [string]$LogFile ) $ErrorActionPreference = 'Stop' -# Start transcript logging if LogFile specified (replaces external tee pipe) if ($LogFile) { $logDir = Split-Path $LogFile -Parent if ($logDir -and -not (Test-Path $logDir)) { @@ -100,13 +66,10 @@ if ($LogFile) { Start-Transcript -Path $LogFile -Force | Out-Null } -# Get repository root $RepoRoot = git rev-parse --show-toplevel 2>$null -if (-not $RepoRoot) { - Write-Error "Not in a git repository" - exit 1 -} +if (-not $RepoRoot) { Write-Error "Not in a git repository"; exit 1 } +# ─── Banner ─────────────────────────────────────────────────────────────────── Write-Host "" Write-Host "╔═══════════════════════════════════════════════════════════╗" -ForegroundColor Cyan Write-Host "║ PR Review with Copilot CLI ║" -ForegroundColor Cyan @@ -120,402 +83,478 @@ if ($Platform) { Write-Host "╚═══════════════════════════════════════════════════════════╝" -ForegroundColor Cyan Write-Host "" -# Step 1: Verify prerequisites +# ─── Prerequisites ──────────────────────────────────────────────────────────── Write-Host "📋 Checking prerequisites..." -ForegroundColor Yellow -# Check GitHub CLI $ghVersion = gh --version 2>$null | Select-Object -First 1 -if (-not $ghVersion) { - Write-Error "GitHub CLI (gh) is not installed. Install from: https://cli.github.com/" - exit 1 -} +if (-not $ghVersion) { Write-Error "GitHub CLI (gh) not installed"; exit 1 } Write-Host " ✅ GitHub CLI: $ghVersion" -ForegroundColor Green -# Check Copilot CLI -$copilotVersion = copilot --version 2>$null -if (-not $copilotVersion) { - Write-Error "Copilot CLI is not installed. Install with: npm install -g @github/copilot" - exit 1 -} +$copilotCmd = Get-Command copilot -ErrorAction SilentlyContinue +if (-not $copilotCmd) { Write-Error "Copilot CLI not installed"; exit 1 } +$copilotVersion = (& copilot --version 2>&1 | Out-String).Trim() +if (-not $copilotVersion) { $copilotVersion = $copilotCmd.Source } Write-Host " ✅ Copilot CLI: $copilotVersion" -ForegroundColor Green -# Check PR exists -Write-Host " 🔍 Verifying PR #$PRNumber exists..." -ForegroundColor Gray -$prInfo = gh pr view $PRNumber --json title,state,url 2>$null | ConvertFrom-Json -if (-not $prInfo) { - Write-Error "PR #$PRNumber not found or not accessible" - exit 1 -} +$prInfo = gh pr view $PRNumber --json title,state 2>$null | ConvertFrom-Json +if (-not $prInfo) { Write-Error "PR #$PRNumber not found"; exit 1 } Write-Host " ✅ PR: $($prInfo.title)" -ForegroundColor Green -Write-Host " ✅ State: $($prInfo.state)" -ForegroundColor Green - -# Step 2: Validate current branch and merge PR -Write-Host "" -$currentBranch = git branch --show-current -Write-Host "📍 Current branch: $currentBranch" -ForegroundColor Yellow - -# Check if on a protected branch (main, release/*, net*.0) -$protectedBranches = @('main', 'master') -$isProtected = $protectedBranches -contains $currentBranch -or - $currentBranch -match '^release/' -or - $currentBranch -match '^net\d+\.0$' - -if ($isProtected) { - Write-Host "" - Write-Host "╔═══════════════════════════════════════════════════════════╗" -ForegroundColor Red - Write-Host "║ ERROR: Cannot run on protected branch! ║" -ForegroundColor Red - Write-Host "╠═══════════════════════════════════════════════════════════╣" -ForegroundColor Red - Write-Host "║ Current branch: $currentBranch" -ForegroundColor Red - Write-Host "║ ║" -ForegroundColor Red - Write-Host "║ This script merges the PR into the current branch. ║" -ForegroundColor Red - Write-Host "║ Protected branches: main, release/*, net*.0 ║" -ForegroundColor Red - Write-Host "║ ║" -ForegroundColor Red - Write-Host "║ Please checkout a working branch first: ║" -ForegroundColor Red - Write-Host "║ git checkout -b pr-review-$PRNumber ║" -ForegroundColor Red - Write-Host "╚═══════════════════════════════════════════════════════════╝" -ForegroundColor Red - Write-Host "" - exit 1 -} - -Write-Host " ✅ Branch '$currentBranch' is not protected" -ForegroundColor Green - -# Merge the PR into the current branch (unless skipped) -if (-not $SkipMerge) { - Write-Host "" - Write-Host "🔀 Merging PR #$PRNumber into current branch..." -ForegroundColor Yellow - - if ($DryRun) { - Write-Host " [DRY RUN] Would fetch and merge PR #$PRNumber" -ForegroundColor Magenta - } else { - # Fetch the PR ref and merge it - Write-Host " 📥 Fetching PR #$PRNumber..." -ForegroundColor Gray - git fetch origin pull/$PRNumber/head:temp-pr-$PRNumber 2>$null - if ($LASTEXITCODE -ne 0) { - # Try fetching from the PR's head repository (for fork PRs) - $prDetails = gh pr view $PRNumber --json headRepositoryOwner,headRefName 2>$null | ConvertFrom-Json - if ($prDetails) { - $forkOwner = $prDetails.headRepositoryOwner.login - $headRef = $prDetails.headRefName - Write-Host " 📥 PR is from fork: $forkOwner, fetching..." -ForegroundColor Gray - git fetch "https://github.com/$forkOwner/maui.git" "${headRef}:temp-pr-$PRNumber" - if ($LASTEXITCODE -ne 0) { - Write-Error "Failed to fetch PR #$PRNumber from fork" - exit 1 - } - } else { - Write-Error "Failed to fetch PR #$PRNumber" - exit 1 - } - } - - Write-Host " 🔀 Merging into '$currentBranch'..." -ForegroundColor Gray - git merge "temp-pr-$PRNumber" --no-edit - if ($LASTEXITCODE -ne 0) { - Write-Host "" - Write-Host "⚠️ Merge conflict detected!" -ForegroundColor Red - Write-Host " Please resolve conflicts manually and re-run the script with -SkipMerge" -ForegroundColor Yellow - git merge --abort 2>$null - git branch -D "temp-pr-$PRNumber" 2>$null - exit 1 - } - - # Clean up temp branch - git branch -D "temp-pr-$PRNumber" 2>$null - - Write-Host " ✅ PR #$PRNumber merged into '$currentBranch'" -ForegroundColor Green - } -} else { - Write-Host "" - Write-Host "⏭️ Skipping merge (assuming PR is already merged)" -ForegroundColor Yellow -} -# Step 3: Ensure state directory exists -$stateDir = Join-Path $RepoRoot "CustomAgentLogsTmp/PRState" -if (-not (Test-Path $stateDir)) { - New-Item -ItemType Directory -Path $stateDir -Force | Out-Null - Write-Host " 📁 Created state directory: $stateDir" -ForegroundColor Gray -} - -# Step 4: Build the prompt for Copilot CLI -$planTemplatePath = ".github/agents/pr/PLAN-TEMPLATE.md" - -# Build platform instruction +# ─── Shared prompt rules ───────────────────────────────────────────────────── $platformInstruction = if ($Platform) { "**Platform for testing:** $Platform" } else { - "**Platform for testing:** Determine the appropriate platform(s) based on the PR's affected code paths and the current host OS." + "**Platform for testing:** Determine from PR's affected code paths and current host OS." } -$prompt = @" -Review PR #$PRNumber using the pr agent workflow. +$autonomousRules = @" -$platformInstruction - -🚨 **CRITICAL - NEVER MODIFY GIT STATE:** -- NEVER run ``git checkout``, ``git switch``, ``git fetch``, ``git stash``, or ``git reset`` -- NEVER run ``git push`` - you do NOT have permission to push anything -- You are ALWAYS on the correct branch already - the script handles this -- If the state file says "wrong branch", that's stale state - delete it and start fresh -- If you think you need to switch branches or push changes, you are WRONG - ask the user instead - -**Instructions:** -1. Read the plan template at ``$planTemplatePath`` for the 4-phase workflow -2. Read ``.github/agents/pr.md`` for Phases 1-2 instructions -3. Follow ALL critical rules, especially: - - STOP on environment blockers and ask before continuing - - Use task agent for Gate verification - - Run multi-model try-fix in Phase 3 - -**Start with Phase 1: Pre-Flight** -- Create state file: CustomAgentLogsTmp/PRState/pr-$PRNumber.md -- Gather context from PR #$PRNumber -- Proceed through all 4 phases - -Begin the review now. +🚨 **AUTONOMOUS EXECUTION:** +- There is NO human operator - NEVER stop and ask for input +- On environment blockers: skip the blocked phase and continue +- Always prefer CONTINUING with partial results over STOPPING "@ +# ═════════════════════════════════════════════════════════════════════════════ +# STEP 0: Branch Setup (Create Review Branch & Cherry-Pick PR) +# ═════════════════════════════════════════════════════════════════════════════ + Write-Host "" -Write-Host "═══════════════════════════════════════════════════════════" -ForegroundColor DarkGray -Write-Host "" +Write-Host "╔═══════════════════════════════════════════════════════════╗" -ForegroundColor Yellow +Write-Host "║ STEP 0: BRANCH SETUP ║" -ForegroundColor Yellow +Write-Host "╚═══════════════════════════════════════════════════════════╝" -ForegroundColor Yellow + +$reviewBranch = "pr-review-$PRNumber" if ($DryRun) { - Write-Host "[DRY RUN] Would invoke Copilot CLI with:" -ForegroundColor Magenta - Write-Host "" - Write-Host " Agent: pr" -ForegroundColor Gray - Write-Host " Mode: $(if ($Interactive) { 'Interactive (-i)' } else { 'Non-interactive (-p)' })" -ForegroundColor Gray - Write-Host " PR: #$PRNumber" -ForegroundColor Gray - Write-Host " Platform: $(if ($Platform) { $Platform } else { '(agent will determine)' })" -ForegroundColor Gray - Write-Host "" - Write-Host "Prompt:" -ForegroundColor Gray - Write-Host $prompt -ForegroundColor DarkGray - Write-Host "" - Write-Host "To run for real, remove the -DryRun flag" -ForegroundColor Yellow + if ($UseCurrentBranch) { + Write-Host "[DRY RUN] Would create review branch '$reviewBranch' from current branch" -ForegroundColor Magenta + } else { + Write-Host "[DRY RUN] Would checkout main, then create review branch '$reviewBranch'" -ForegroundColor Magenta + } + Write-Host "[DRY RUN] Would squash-merge PR #$PRNumber (stops on conflicts)" -ForegroundColor Magenta } else { - Write-Host "╔═══════════════════════════════════════════════════════════╗" -ForegroundColor Green - Write-Host "║ PHASE 1: PR AGENT REVIEW ║" -ForegroundColor Green - Write-Host "╚═══════════════════════════════════════════════════════════╝" -ForegroundColor Green - Write-Host "" - Write-Host "PR Review Context:" -ForegroundColor Cyan - Write-Host " PR_NUMBER: $PRNumber" -ForegroundColor White - Write-Host " PLATFORM: $(if ($Platform) { $Platform } else { '(agent will determine)' })" -ForegroundColor White - Write-Host " STATE_FILE: CustomAgentLogsTmp/PRState/pr-$PRNumber.md" -ForegroundColor White - Write-Host " PLAN_TEMPLATE: $planTemplatePath" -ForegroundColor White - Write-Host " CURRENT_BRANCH: $(git branch --show-current)" -ForegroundColor White - Write-Host " PR_TITLE: $($prInfo.title)" -ForegroundColor White - Write-Host " MODE: $(if ($Interactive) { 'Interactive' } else { 'Non-interactive' })" -ForegroundColor White - Write-Host "" - Write-Host "Workflow:" -ForegroundColor Cyan - Write-Host " 1. PR Agent Review (this phase)" -ForegroundColor White - if ($RunFinalize) { - Write-Host " 2. pr-finalize skill (queued)" -ForegroundColor White + # In CI pipelines, prior steps (Build MSBuild Tasks, Install Appium) may leave + # modified tracked files (e.g. HybridWebView.js) or untracked dirs (e.g. .appium/). + # Clean them so the dirty-tree check doesn't fail on build artifacts. + if ($env:TF_BUILD) { + git checkout -- . 2>$null + git clean -fd -e CustomAgentLogsTmp/ 2>$null } - if ($PostSummaryComment) { - $phase3Label = "3. Post comments: agent summary" - if ($RunFinalize) { - $phase3Label += " + finalize" - } - Write-Host " $phase3Label (queued)" -ForegroundColor White + + # Check for dirty working tree + $dirtyFiles = git status --porcelain 2>$null + if ($dirtyFiles) { + Write-Error "Working tree is dirty. Please commit or stash changes before running review.`n$dirtyFiles" + exit 1 } - Write-Host "" - Write-Host "─────────────────────────────────────────────────────────────" -ForegroundColor DarkGray - Write-Host "" - - # Build the copilot command arguments - $copilotArgs = @( - "--agent", "pr", - "--stream", "on" # Enable streaming for real-time output - ) - - # NOTE: --deny-tool does NOT work with --allow-all (allow-all takes precedence) - # Branch switching prevention relies on agent instructions in pr.md only - - # Create log directory for this PR - $prLogDir = Join-Path $RepoRoot "CustomAgentLogsTmp/PRState/$PRNumber/copilot-logs" - if (-not (Test-Path $prLogDir)) { - New-Item -ItemType Directory -Path $prLogDir -Force | Out-Null + + # Delete leftover review branch from a previous run (if it exists) + $existingBranch = git branch --list $reviewBranch 2>$null + if ($existingBranch) { + Write-Host " ⚠️ Removing leftover branch '$reviewBranch' from previous run" -ForegroundColor Yellow + git branch -D $reviewBranch 2>$null } - - # Add logging options - $copilotArgs += @("--log-dir", $prLogDir, "--log-level", "info") - - if ($Interactive) { - # Interactive mode: -i to start with prompt - $copilotArgs += @("-i", $prompt) - } else { - # Non-interactive mode (default): -p with --allow-all - # Also save session to markdown for review - $sessionFile = Join-Path $prLogDir "session-$(Get-Date -Format 'yyyyMMdd-HHmmss').md" - $copilotArgs += @("-p", $prompt, "--allow-all", "--share", $sessionFile) + + # Auto-detect CI environment — in CI, always use current branch + $isCI = $env:CI -or $env:TF_BUILD -or $env:GITHUB_ACTIONS -or $env:BUILD_BUILDID + if ($isCI -and -not $UseCurrentBranch) { + Write-Host " 🤖 CI environment detected — using current branch instead of main" -ForegroundColor Cyan + $UseCurrentBranch = $true } - - Write-Host "🚀 Starting Copilot CLI..." -ForegroundColor Yellow - Write-Host "" - - # Invoke Copilot CLI - & copilot @copilotArgs - - $exitCode = $LASTEXITCODE - - Write-Host "" - Write-Host "═══════════════════════════════════════════════════════════" -ForegroundColor DarkGray - if ($exitCode -eq 0) { - Write-Host "✅ Copilot CLI completed successfully" -ForegroundColor Green + + # Capture original branch so error paths can restore it (not `git checkout -` which is unreliable) + $originalBranch = git branch --show-current 2>$null + if (-not $originalBranch) { $originalBranch = git rev-parse HEAD 2>$null } + + if (-not $UseCurrentBranch) { + # Default: checkout main first + Write-Host " 📌 Checking out main branch..." -ForegroundColor Cyan + git checkout main 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { Write-Error "Failed to checkout main"; exit 1 } + $pullOutput = git pull origin main --ff-only 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Host " ⚠️ git pull failed (non-fatal, continuing with local main): $pullOutput" -ForegroundColor Yellow + } + $baseSha = git rev-parse --short HEAD 2>$null + Write-Host " 📌 Review base: main @ $baseSha" -ForegroundColor Cyan } else { - Write-Host "⚠️ Copilot CLI exited with code: $exitCode" -ForegroundColor Yellow + $currentBranch = git branch --show-current 2>$null + if (-not $currentBranch) { $currentBranch = "(detached HEAD)" } + Write-Host " 📌 Using current branch: $currentBranch" -ForegroundColor Cyan } - - # Post-completion skills (only run if main agent completed successfully) - if ($exitCode -eq 0) { - - # Restore tracked files to clean state before running post-completion skills. - # Phase 1 (PR Agent) may have left the working tree dirty from try-fix attempts, - # which can cause skill files to be missing or modified in subsequent phases. - # NOTE: State files in CustomAgentLogsTmp/ are .gitignore'd and untracked, - # so this won't touch them. Using HEAD to also restore deleted files. - Write-Host "" - Write-Host "🧹 Restoring working tree to clean state between phases..." -ForegroundColor Yellow - git status --porcelain 2>$null | Set-Content "CustomAgentLogsTmp/PRState/phase1-exit-git-status.log" -ErrorAction SilentlyContinue - git checkout HEAD -- . 2>&1 | Out-Null - Write-Host " ✅ Working tree restored" -ForegroundColor Green - - # Phase 2: Run pr-finalize skill if requested - if ($RunFinalize) { - Write-Host "" - Write-Host "╔═══════════════════════════════════════════════════════════╗" -ForegroundColor Magenta - Write-Host "║ PHASE 2: PR-FINALIZE SKILL ║" -ForegroundColor Magenta - Write-Host "╚═══════════════════════════════════════════════════════════╝" -ForegroundColor Magenta - Write-Host "" - - # Ensure output directory exists for finalize results - $finalizeDir = Join-Path $RepoRoot "CustomAgentLogsTmp/PRState/$PRNumber/pr-finalize" - if (-not (Test-Path $finalizeDir)) { - New-Item -ItemType Directory -Path $finalizeDir -Force | Out-Null - } - - $finalizePrompt = "Run the pr-finalize skill for PR #$PRNumber. Verify the PR title and description match the actual implementation. Do NOT post a comment. Write your findings to CustomAgentLogsTmp/PRState/$PRNumber/pr-finalize/pr-finalize-summary.md (NOT the main state file pr-$PRNumber.md which contains phase data that must not be overwritten). If you recommend a new description, also write it to CustomAgentLogsTmp/PRState/$PRNumber/pr-finalize/recommended-description.md. If you have code review findings, also write them to CustomAgentLogsTmp/PRState/$PRNumber/pr-finalize/code-review.md." - - $finalizeArgs = @( - "-p", $finalizePrompt, - "--allow-all", - "--stream", "on" - ) - - Write-Host "🔍 Running pr-finalize..." -ForegroundColor Yellow - & copilot @finalizeArgs - - $finalizeExit = $LASTEXITCODE - if ($finalizeExit -eq 0) { - Write-Host "✅ pr-finalize completed" -ForegroundColor Green - } else { - Write-Host "⚠️ pr-finalize exited with code: $finalizeExit" -ForegroundColor Yellow - } + + # Create review branch + Write-Host " 🔀 Creating review branch: $reviewBranch" -ForegroundColor Cyan + git checkout -b $reviewBranch 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { Write-Error "Failed to create branch '$reviewBranch'"; exit 1 } + + # Fetch PR commits + Write-Host " 📥 Fetching PR #$PRNumber..." -ForegroundColor Cyan + $tempBranch = "temp-pr-$PRNumber" + + # Clean up any leftover temp branch + git branch -D $tempBranch 2>$null | Out-Null + + # Try fetching from origin (same-repo PRs) + git fetch origin "pull/$PRNumber/head:$tempBranch" 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { + # Fork PR — get fork info + Write-Host " 📥 Fetching from fork..." -ForegroundColor Cyan + $forkInfo = gh pr view $PRNumber --json headRepositoryOwner,headRefName,headRepository 2>$null | ConvertFrom-Json + if (-not $forkInfo -or -not $forkInfo.headRepositoryOwner) { + Write-Error "Failed to fetch PR #$PRNumber (not found on origin or fork)" + git checkout $originalBranch 2>$null + exit 1 + } + $forkUrl = "https://github.com/$($forkInfo.headRepositoryOwner.login)/$($forkInfo.headRepository.name).git" + $fetchOutput = git fetch $forkUrl "$($forkInfo.headRefName):$tempBranch" 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Error "Failed to fetch from fork: $forkUrl`n$fetchOutput" + git checkout $originalBranch 2>$null + exit 1 } - - # Phase 3: Post comments if requested - # Runs scripts directly instead of via Copilot CLI to avoid: - # - LLM creating its own broken version if skill files are missing - # - Dirty tree from Phase 2 corrupting script files - if ($PostSummaryComment) { - Write-Host "" - Write-Host "╔═══════════════════════════════════════════════════════════╗" -ForegroundColor Magenta - Write-Host "║ PHASE 3: POST COMMENTS ║" -ForegroundColor Magenta - Write-Host "╚═══════════════════════════════════════════════════════════╝" -ForegroundColor Magenta - Write-Host "" - - # Restore tracked files (including deleted ones) to clean state. - Write-Host "🧹 Restoring working tree to clean state..." -ForegroundColor Yellow - git status --porcelain 2>$null | Set-Content "CustomAgentLogsTmp/PRState/phase2-exit-git-status.log" -ErrorAction SilentlyContinue - git checkout HEAD -- . 2>&1 | Out-Null - Write-Host " ✅ Working tree restored" -ForegroundColor Green - - # 3a: Post PR agent summary comment (from Phase 1 state file) - $scriptPath = ".github/skills/ai-summary-comment/scripts/post-ai-summary-comment.ps1" - if (-not (Test-Path $scriptPath)) { - Write-Host "⚠️ Script missing after checkout, attempting targeted recovery..." -ForegroundColor Yellow - git checkout HEAD -- $scriptPath 2>&1 | Out-Null + } + + # ── Merge PR commits (squash) ── + Write-Host " 🔀 Merging PR commits (squashed)..." -ForegroundColor Cyan + git merge --squash $tempBranch 2>&1 | Out-Null + if ($LASTEXITCODE -eq 0) { + # Check if there's anything to commit (PR might already be merged) + $staged = git diff --cached --quiet 2>$null; $hasStagedChanges = $LASTEXITCODE -ne 0 + if ($hasStagedChanges) { + git commit -m "PR #$PRNumber squashed for review" 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { + git branch -D $tempBranch 2>$null + Write-Error "Failed to create squashed commit"; exit 1 } - if (Test-Path $scriptPath) { - Write-Host "💬 Running post-ai-summary-comment.ps1 directly..." -ForegroundColor Yellow - & $scriptPath -PRNumber $PRNumber - - $commentExit = $LASTEXITCODE - if ($commentExit -eq 0) { - Write-Host "✅ Agent summary comment posted" -ForegroundColor Green - } else { - Write-Host "⚠️ post-ai-summary-comment.ps1 exited with code: $commentExit" -ForegroundColor Yellow + Write-Host " ✅ Squash-merge succeeded" -ForegroundColor Green + } else { + Write-Host " ⚠️ No changes to merge (PR may already be up to date)" -ForegroundColor Yellow + } + } else { + Write-Host " ❌ Squash-merge had conflicts." -ForegroundColor Red + git merge --abort 2>$null + git reset --hard HEAD 2>$null + + # Clean up branches + git checkout $originalBranch 2>$null + git branch -D $reviewBranch 2>$null + git branch -D $tempBranch 2>$null + + # Post a comment on the PR about merge conflicts + $conflictBody = "⚠️ **Merge Conflict Detected** — This PR has merge conflicts with its target branch. Please rebase onto the target branch and resolve the conflicts." + try { + gh pr comment $PRNumber --body $conflictBody 2>&1 | Out-Null + Write-Host " 📝 Posted merge conflict comment on PR" -ForegroundColor Cyan + } catch { + Write-Host " ⚠️ Could not post merge conflict comment (non-fatal): $_" -ForegroundColor Yellow + } + + Write-Error "Merge conflicts for PR #$PRNumber. Review cannot proceed until conflicts are resolved." + exit 1 + } + + # Clean up temp branch + git branch -D $tempBranch 2>$null | Out-Null + + # Verify + $headCommit = git log --oneline -1 2>$null + Write-Host " ✅ Review branch ready: $reviewBranch" -ForegroundColor Green + Write-Host " 📝 HEAD: $headCommit" -ForegroundColor Gray +} + +# ─── Helper: Invoke Copilot ────────────────────────────────────────────────── +function Invoke-CopilotStep { + param([string]$StepName, [string]$Prompt) + + Write-Host "" + Write-Host "╔═══════════════════════════════════════════════════════════╗" -ForegroundColor Magenta + Write-Host "║ $($StepName.PadRight(55))║" -ForegroundColor Magenta + Write-Host "╚═══════════════════════════════════════════════════════════╝" -ForegroundColor Magenta + + if ($DryRun) { + Write-Host "[DRY RUN] Prompt:" -ForegroundColor Magenta + Write-Host $Prompt -ForegroundColor Gray + return 0 + } + + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + $toolCount = 0 + $turnCount = 0 + $currentIntent = "" + $modelName = "" + $failedTools = @() + + # Tool icon mapping for common tools + $toolIcons = @{ + 'bash' = '🖥️'; 'view' = '📄'; 'edit' = '✏️'; 'create' = '📝' + 'grep' = '🔍'; 'glob' = '📂'; 'task' = '🤖'; 'skill' = '⚡' + 'sql' = '🗃️'; 'web_search' = '🌐'; 'ask_user' = '❓' + 'github-mcp-server-pull_request_read' = '🔀' + 'github-mcp-server-issue_read' = '🐛' + 'github-mcp-server-get_file_contents' = '📦' + 'github-mcp-server-search_code' = '🔎' + } + + # Use JSON output format to stream live progress of agent activity. + & copilot -p $Prompt --allow-all --output-format json 2>&1 | ForEach-Object { + $line = $_.ToString() + try { + $event = $line | ConvertFrom-Json -ErrorAction Stop + switch ($event.type) { + 'session.tools_updated' { + if ($event.data.model) { + $modelName = $event.data.model + Write-Host " ⚙️ Model: " -ForegroundColor DarkGray -NoNewline + Write-Host $modelName -ForegroundColor DarkCyan + } } - } else { - Write-Host "⚠️ Script not found at: $scriptPath" -ForegroundColor Yellow - Write-Host " Current directory: $(Get-Location)" -ForegroundColor Gray - Write-Host " Skipping agent summary comment." -ForegroundColor Gray - } - - # 3b: Post PR finalize comment (from Phase 2 finalize results) - if ($RunFinalize) { - $finalizeScriptPath = ".github/skills/ai-summary-comment/scripts/post-pr-finalize-comment.ps1" - if (-not (Test-Path $finalizeScriptPath)) { - Write-Host "⚠️ Finalize script missing, attempting targeted recovery..." -ForegroundColor Yellow - git checkout HEAD -- $finalizeScriptPath 2>&1 | Out-Null + 'assistant.turn_start' { + $turnCount++ + $elapsed = $stopwatch.Elapsed.ToString("mm\:ss") + Write-Host "" + Write-Host " ┌─ Turn $turnCount " -ForegroundColor DarkGray -NoNewline + Write-Host "[$elapsed]" -ForegroundColor DarkYellow -NoNewline + if ($currentIntent) { + Write-Host " · $currentIntent" -ForegroundColor DarkCyan + } else { + Write-Host "" + } + } + 'assistant.turn_end' { + Write-Host " └─" -ForegroundColor DarkGray } - if (Test-Path $finalizeScriptPath) { - Write-Host "💬 Running post-pr-finalize-comment.ps1 directly..." -ForegroundColor Yellow - & $finalizeScriptPath -PRNumber $PRNumber - - $finalizeCommentExit = $LASTEXITCODE - if ($finalizeCommentExit -eq 0) { - Write-Host "✅ Finalize comment posted" -ForegroundColor Green + 'tool.execution_start' { + $toolName = $event.data.toolName + $args_ = $event.data.arguments + + # Capture intent changes silently + if ($toolName -eq 'report_intent') { + $currentIntent = $args_.intent ?? $currentIntent + Write-Host " │ 🎯 " -ForegroundColor DarkGray -NoNewline + Write-Host $currentIntent -ForegroundColor Yellow + break + } + + $toolCount++ + $icon = $toolIcons[$toolName] + if (-not $icon) { + # Prefix match for github-mcp-server-* and other compound names + $icon = if ($toolName -like 'github-*') { '🔀' } else { '🔧' } + } + + # Build a short display name for long tool names + $displayName = $toolName -replace '^github-mcp-server-', 'gh/' + + # Pick the most useful detail from arguments + $detail = $args_.description ?? $args_.intent ?? '' + if (-not $detail) { + # Fallback: pick first informative arg + $detail = $args_.command ?? $args_.pattern ?? $args_.query ?? $args_.path ?? $args_.prompt ?? '' + } + if ($detail) { + $detail = $detail.Substring(0, [Math]::Min($detail.Length, 90)) + # Truncate at last word boundary if we cut mid-word + if ($detail.Length -eq 90) { + $lastSpace = $detail.LastIndexOf(' ') + if ($lastSpace -gt 60) { $detail = $detail.Substring(0, $lastSpace) + "…" } + else { $detail += "…" } + } + } + + Write-Host " │ $icon " -ForegroundColor DarkGray -NoNewline + Write-Host $displayName -ForegroundColor Cyan -NoNewline + if ($detail) { + Write-Host " $detail" -ForegroundColor DarkGray } else { - Write-Host "⚠️ post-pr-finalize-comment.ps1 exited with code: $finalizeCommentExit" -ForegroundColor Yellow + Write-Host "" + } + } + 'tool.execution_complete' { + if (-not $event.data.success) { + $failedTool = $event.data.toolCallId + $failedTools += $failedTool + Write-Host " │ ❌ Tool failed" -ForegroundColor Red + } + } + 'assistant.message' { + $content = $event.data.content + # Show agent text responses (skip empty tool-request-only messages) + if ($content -and $content.Trim()) { + $preview = $content.Trim() + if ($preview.Length -gt 400) { + $preview = $preview.Substring(0, 400) + "…" + } + Write-Host " │ 💬 " -ForegroundColor DarkGray -NoNewline + Write-Host $preview -ForegroundColor White } - } else { - Write-Host "⚠️ Script not found at: $finalizeScriptPath" -ForegroundColor Yellow - Write-Host " Skipping finalize comment." -ForegroundColor Gray } + 'result' { + # Final stats + $usage = $event.data.usage + if ($usage) { + $elapsed = $stopwatch.Elapsed.ToString("mm\:ss") + $apiMs = if ($usage.totalApiDurationMs) { [math]::Round($usage.totalApiDurationMs / 1000, 1) } else { "?" } + $changes = $usage.codeChanges + $filesChanged = if ($changes -and $changes.filesModified) { $changes.filesModified.Count } else { 0 } + $linesAdded = if ($changes) { $changes.linesAdded } else { 0 } + $linesRemoved = if ($changes) { $changes.linesRemoved } else { 0 } + + Write-Host "" + Write-Host " ╭──────────────────────────────────────────╮" -ForegroundColor DarkGray + Write-Host " │ ⏱ $elapsed elapsed ($($apiMs)s API)" -ForegroundColor DarkGray -NoNewline + Write-Host " │ 🔧 $toolCount tools" -ForegroundColor DarkGray -NoNewline + Write-Host " │ 🔄 $turnCount turns" -ForegroundColor DarkGray + if ($filesChanged -gt 0 -or $linesAdded -gt 0 -or $linesRemoved -gt 0) { + Write-Host " │ 📝 $filesChanged files " -ForegroundColor DarkGray -NoNewline + Write-Host "+$linesAdded" -ForegroundColor Green -NoNewline + Write-Host "/" -ForegroundColor DarkGray -NoNewline + Write-Host "-$linesRemoved" -ForegroundColor Red + } + Write-Host " ╰──────────────────────────────────────────╯" -ForegroundColor DarkGray + } + } + } + } catch { + # Non-JSON line (e.g. stats) — pass through as-is + if ($line.Trim()) { + Write-Host " $line" -ForegroundColor DarkGray } } } + $exitCode = $LASTEXITCODE + $stopwatch.Stop() + + if ($exitCode -eq 0) { + Write-Host " ✅ $StepName completed" -ForegroundColor Green + } else { + Write-Host " ⚠️ $StepName exited with code: $exitCode" -ForegroundColor Yellow + } + if ($failedTools.Count -gt 0) { + Write-Host " ⚠️ $($failedTools.Count) tool(s) failed during execution" -ForegroundColor Yellow + } + return $exitCode } +# ═════════════════════════════════════════════════════════════════════════════ +# STEP 1: PR Review (4-phase skill) +# ═════════════════════════════════════════════════════════════════════════════ + +$step1Prompt = @" +Use a skill to review PR #$PRNumber. + +$platformInstruction +$autonomousRules + +📁 Write phase output to ``CustomAgentLogsTmp/PRState/$PRNumber/PRAgent/{phase}/content.md`` +"@ + +Invoke-CopilotStep -StepName "STEP 1: PR REVIEW" -Prompt $step1Prompt | Out-Null + +# Restore review branch — the Copilot agent may have switched branches (e.g. via gh pr checkout) +git checkout $reviewBranch 2>$null | Out-Null + +# ═════════════════════════════════════════════════════════════════════════════ +# STEP 2: PR Finalize +# ═════════════════════════════════════════════════════════════════════════════ + +$step2Prompt = @" +Use a skill to finalize PR #$PRNumber. Write findings to ``CustomAgentLogsTmp/PRState/$PRNumber/PRAgent/pr-finalize/pr-finalize-summary.md``. +$autonomousRules +"@ + +Invoke-CopilotStep -StepName "STEP 2: PR FINALIZE" -Prompt $step2Prompt | Out-Null + +# Restore review branch — the Copilot agent may have switched branches (e.g. via gh pr checkout) +git checkout $reviewBranch 2>$null | Out-Null + +# ═════════════════════════════════════════════════════════════════════════════ +# STEP 3: Post AI Summary Comment (direct script invocation) +# ═════════════════════════════════════════════════════════════════════════════ + Write-Host "" -Write-Host "📝 State file: CustomAgentLogsTmp/PRState/pr-$PRNumber.md" -ForegroundColor Gray -Write-Host "📋 Plan template: $planTemplatePath" -ForegroundColor Gray -if (-not $DryRun) { - Write-Host "📁 Copilot logs: CustomAgentLogsTmp/PRState/$PRNumber/copilot-logs/" -ForegroundColor Gray - if (-not $Interactive) { - Write-Host "📄 Session markdown: $sessionFile" -ForegroundColor Gray +Write-Host "╔═══════════════════════════════════════════════════════════╗" -ForegroundColor Magenta +Write-Host "║ STEP 3: POST AI SUMMARY ║" -ForegroundColor Magenta +Write-Host "╚═══════════════════════════════════════════════════════════╝" -ForegroundColor Magenta + +$summaryScriptsDir = Join-Path $RepoRoot ".github/scripts" +$dryRunFlag = if ($DryRun) { @('-DryRun') } else { @() } + +# 3a: Post PR review phases (pre-flight, gate, try-fix, report) +$reviewScript = Join-Path $summaryScriptsDir "post-ai-summary-comment.ps1" +if (Test-Path $reviewScript) { + try { + Write-Host " 📝 Posting PR review summary..." -ForegroundColor Cyan + & $reviewScript -PRNumber $PRNumber @dryRunFlag + Write-Host " ✅ PR review summary posted" -ForegroundColor Green + } catch { + Write-Host " ⚠️ PR review summary posting failed (non-fatal): $_" -ForegroundColor Yellow + } +} else { + Write-Host " ⚠️ post-ai-summary-comment.ps1 not found — skipping review summary" -ForegroundColor Yellow +} + +# 3b: Post PR finalize section (title, description, code review) +$finalizeScript = Join-Path $summaryScriptsDir "post-pr-finalize-comment.ps1" +if (Test-Path $finalizeScript) { + try { + Write-Host " 📝 Posting PR finalize summary..." -ForegroundColor Cyan + & $finalizeScript -PRNumber $PRNumber @dryRunFlag + Write-Host " ✅ PR finalize summary posted" -ForegroundColor Green + } catch { + Write-Host " ⚠️ PR finalize summary posting failed (non-fatal): $_" -ForegroundColor Yellow } +} else { + Write-Host " ⚠️ post-pr-finalize-comment.ps1 not found — skipping finalize summary" -ForegroundColor Yellow } + +# ═════════════════════════════════════════════════════════════════════════════ +# STEP 4: Apply Labels +# ═════════════════════════════════════════════════════════════════════════════ + Write-Host "" +Write-Host "╔═══════════════════════════════════════════════════════════╗" -ForegroundColor Blue +Write-Host "║ STEP 4: APPLY LABELS ║" -ForegroundColor Blue +Write-Host "╚═══════════════════════════════════════════════════════════╝" -ForegroundColor Blue + +$labelHelperPath = Join-Path $RepoRoot ".github/scripts/shared/Update-AgentLabels.ps1" +if (Test-Path $labelHelperPath) { + try { + . $labelHelperPath + Apply-AgentLabels -PRNumber $PRNumber -RepoRoot $RepoRoot + Write-Host " ✅ Labels applied" -ForegroundColor Green + } catch { + Write-Host " ⚠️ Label application failed (non-fatal): $_" -ForegroundColor Yellow + } +} else { + Write-Host " ⚠️ Label helper not found — skipping" -ForegroundColor Yellow +} + +# ═════════════════════════════════════════════════════════════════════════════ +# Cleanup +# ═════════════════════════════════════════════════════════════════════════════ -# NOTE: This cleanup targets CI/ADO agent environments where only this script's -# Copilot CLI processes should exist. On developer machines, this could potentially -# affect other Copilot processes (e.g., VS Code extension). The risk is low since -# this runs at script end, but be aware if running locally. -# Clean up orphaned copilot CLI processes that may hold stdout fd open -# IMPORTANT: Only target processes whose command line contains "copilot" to avoid -# accidentally terminating the ADO agent's own node process +Write-Host "" Write-Host "🧹 Cleaning up child processes..." -ForegroundColor Gray try { - $myPid = $PID - # Find node processes running copilot CLI (not the ADO agent's node process) $orphans = Get-Process -Name "node" -ErrorAction SilentlyContinue | Where-Object { - $_.Id -ne $myPid -and - (($_.Path -and $_.Path -match "copilot") -or - ($_.CommandLine -and $_.CommandLine -match "copilot")) + ($_.Path -and $_.Path -match "copilot") -or + ($_.CommandLine -and $_.CommandLine -match "copilot") } - # Also get any process literally named "copilot" $copilotProcs = Get-Process -Name "copilot" -ErrorAction SilentlyContinue $allOrphans = @($orphans) + @($copilotProcs) | Where-Object { $_ -ne $null } | Sort-Object Id -Unique if ($allOrphans.Count -gt 0) { - Write-Host " Stopping $($allOrphans.Count) orphaned process(es): $($allOrphans | ForEach-Object { "$($_.ProcessName)($($_.Id))" } | Join-String -Separator ', ')" -ForegroundColor Gray + Write-Host " Stopping $($allOrphans.Count) orphaned process(es)" -ForegroundColor Gray $allOrphans | Stop-Process -Force -ErrorAction SilentlyContinue - } else { - Write-Host " No orphaned copilot processes found" -ForegroundColor Gray } } catch { Write-Host " ⚠️ Cleanup warning: $_" -ForegroundColor Yellow } -if ($LogFile) { - Stop-Transcript | Out-Null -} +Write-Host "" +Write-Host "✅ Review complete for PR #$PRNumber" -ForegroundColor Green +Write-Host "📁 Output: CustomAgentLogsTmp/PRState/$PRNumber/PRAgent/" -ForegroundColor Gray +Write-Host "" + +if ($LogFile) { Stop-Transcript | Out-Null } diff --git a/.github/skills/ai-summary-comment/scripts/post-ai-summary-comment.ps1 b/.github/scripts/post-ai-summary-comment.ps1 similarity index 78% rename from .github/skills/ai-summary-comment/scripts/post-ai-summary-comment.ps1 rename to .github/scripts/post-ai-summary-comment.ps1 index 46388f0a320f..f10e6b022c57 100644 --- a/.github/skills/ai-summary-comment/scripts/post-ai-summary-comment.ps1 +++ b/.github/scripts/post-ai-summary-comment.ps1 @@ -1,14 +1,16 @@ #!/usr/bin/env pwsh <# .SYNOPSIS - Posts or updates the PR agent review comment on a GitHub Pull Request with validation. + Posts or updates the agent review comment on a GitHub Pull Request with validation. .DESCRIPTION Creates ONE comment for the entire PR review with all phases wrapped in an expandable section. Uses HTML marker for identification. - **NEW: Validates that phases marked as COMPLETE actually have content.** - **NEW: Auto-loads state file from CustomAgentLogsTmp/PRState/pr-XXXXX.md** + Content is always auto-loaded from PRAgent phase files + (CustomAgentLogsTmp/PRState//PRAgent/*/content.md). + + **Validates that phases marked as COMPLETE actually have content.** Format: ## 🤖 AI Summary — ✅ APPROVE @@ -17,14 +19,7 @@
.PARAMETER PRNumber - The pull request number (required unless -StateFile is provided with pr-XXXXX.md naming) - -.PARAMETER StateFile - Path to state file (defaults to CustomAgentLogsTmp/PRState/pr-{PRNumber}.md) - If provided with pr-XXXXX.md naming, PRNumber is auto-extracted - -.PARAMETER Content - The full state file content (alternative to -StateFile) + The pull request number (required) .PARAMETER DryRun Print comment instead of posting @@ -33,28 +28,16 @@ Skip validation checks (not recommended) .EXAMPLE - # Simplest: just provide PR number, state file auto-loaded ./post-ai-summary-comment.ps1 -PRNumber 12345 .EXAMPLE - # Provide state file directly (PR number auto-extracted from filename) - ./post-ai-summary-comment.ps1 -StateFile CustomAgentLogsTmp/PRState/pr-27246.md - -.EXAMPLE - # Legacy: provide content directly - ./post-ai-summary-comment.ps1 -PRNumber 12345 -Content "$(cat CustomAgentLogsTmp/PRState/pr-12345.md)" + ./post-ai-summary-comment.ps1 -PRNumber 12345 -DryRun #> param( [Parameter(Mandatory=$false)] [int]$PRNumber, - [Parameter(Mandatory=$false)] - [string]$StateFile, - - [Parameter(Mandatory=$false)] - [string]$Content, - [Parameter(Mandatory=$false)] [switch]$DryRun, @@ -68,77 +51,141 @@ param( $ErrorActionPreference = "Stop" # ============================================================================ -# STATE FILE RESOLUTION +# INPUT VALIDATION # ============================================================================ -# Priority: 1) -Content, 2) -StateFile, 3) Auto-detect from PRNumber - -# If StateFile provided, extract PRNumber from filename if not already set -if (-not [string]::IsNullOrWhiteSpace($StateFile)) { - if ($StateFile -match 'pr-(\d+)\.md$') { - $extractedPR = [int]$Matches[1] - if ($PRNumber -eq 0) { - $PRNumber = $extractedPR - Write-Host "ℹ️ Auto-detected PRNumber: $PRNumber from state file name" -ForegroundColor Cyan - } elseif ($PRNumber -ne $extractedPR) { - Write-Host "⚠️ Warning: PRNumber ($PRNumber) differs from state file name (pr-$extractedPR.md)" -ForegroundColor Yellow - } +if ($PRNumber -eq 0) { + throw "PRNumber is required." +} + +# Auto-load from PRAgent phase files +Write-Host "ℹ️ Auto-loading from PRAgent phase files..." -ForegroundColor Cyan + +$PRAgentDir = "CustomAgentLogsTmp/PRState/$PRNumber/PRAgent" +if (-not (Test-Path $PRAgentDir)) { + $repoRoot = git rev-parse --show-toplevel 2>$null + if ($repoRoot) { + $PRAgentDir = Join-Path $repoRoot "CustomAgentLogsTmp/PRState/$PRNumber/PRAgent" } - - if (Test-Path $StateFile) { - $Content = Get-Content $StateFile -Raw -Encoding UTF8 - Write-Host "ℹ️ Loaded state file: $StateFile" -ForegroundColor Cyan +} + +if (-not (Test-Path $PRAgentDir)) { + Write-Host "" + Write-Host "╔═══════════════════════════════════════════════════════════╗" -ForegroundColor Red + Write-Host "║ ⛔ No PRAgent directory found ║" -ForegroundColor Red + Write-Host "╚═══════════════════════════════════════════════════════════╝" -ForegroundColor Red + Write-Host "" + Write-Host "Expected directory: $PRAgentDir" -ForegroundColor Yellow + Write-Host "Ensure PRAgent phase files exist." -ForegroundColor Yellow + throw "No PRAgent directory found. Ensure PRAgent/*/content.md files exist." +} + +# Load each phase content file +$phaseFiles = @{ + "pre-flight" = Join-Path $PRAgentDir "pre-flight/content.md" + "gate" = Join-Path $PRAgentDir "gate/content.md" + "try-fix" = Join-Path $PRAgentDir "try-fix/content.md" + "report" = Join-Path $PRAgentDir "report/content.md" +} + +$loadedPhases = @() +$phaseContentMap = @{} + +foreach ($phase in $phaseFiles.GetEnumerator()) { + if (Test-Path $phase.Value) { + $phaseContentMap[$phase.Key] = Get-Content $phase.Value -Raw -Encoding UTF8 + $loadedPhases += $phase.Key + Write-Host " ✅ Loaded: $($phase.Key) ($((Get-Item $phase.Value).Length) bytes)" -ForegroundColor Green } else { - throw "State file not found: $StateFile" + Write-Host " ⏭️ Skipped: $($phase.Key) (no content.md)" -ForegroundColor Gray } } -# If no Content and no StateFile, try auto-detect from PRNumber -if ([string]::IsNullOrWhiteSpace($Content) -and $PRNumber -gt 0) { - $autoStateFile = "CustomAgentLogsTmp/PRState/pr-$PRNumber.md" - if (Test-Path $autoStateFile) { - $Content = Get-Content $autoStateFile -Raw -Encoding UTF8 - Write-Host "ℹ️ Auto-loaded state file: $autoStateFile" -ForegroundColor Cyan +if ($loadedPhases.Count -eq 0) { + throw "No phase content files found in $PRAgentDir. Ensure at least one phase has a content.md file." +} + +Write-Host " 📦 Loaded $($loadedPhases.Count) phase(s): $($loadedPhases -join ', ')" -ForegroundColor Cyan + +# Build synthetic Content from phase files in the expected
format +$syntheticParts = @() + +# Determine phase statuses based on which files exist and content +$phaseStatusMap = @{} +foreach ($phase in @("pre-flight", "gate", "try-fix", "report")) { + if ($phaseContentMap.ContainsKey($phase)) { + $phaseStatusMap[$phase] = "✅ COMPLETE" } else { - # Try relative to repo root - $repoRoot = git rev-parse --show-toplevel 2>$null - if ($repoRoot) { - $autoStateFile = Join-Path $repoRoot "CustomAgentLogsTmp/PRState/pr-$PRNumber.md" - if (Test-Path $autoStateFile) { - $Content = Get-Content $autoStateFile -Raw -Encoding UTF8 - Write-Host "ℹ️ Auto-loaded state file: $autoStateFile" -ForegroundColor Cyan - } - } + $phaseStatusMap[$phase] = "⏳ PENDING" } } -# If Content still not provided, try stdin (legacy support) -if ([string]::IsNullOrWhiteSpace($Content)) { - $Content = $input | Out-String +# Build status table +$statusTable = @" +| Phase | Status | +|-------|--------| +| Pre-Flight | $($phaseStatusMap['pre-flight']) | +| Gate | $($phaseStatusMap['gate']) | +| Fix | $($phaseStatusMap['try-fix']) | +| Report | $($phaseStatusMap['report']) | +"@ +$syntheticParts += $statusTable + +# Build phase sections +if ($phaseContentMap.ContainsKey('pre-flight')) { + $syntheticParts += @" +
📋 Pre-Flight — Issue Summary + +$($phaseContentMap['pre-flight']) + +
+"@ } +if ($phaseContentMap.ContainsKey('gate')) { + $syntheticParts += @" +
🚦 Gate — Test Verification + +$($phaseContentMap['gate']) + +
+"@ +} + +if ($phaseContentMap.ContainsKey('try-fix')) { + $syntheticParts += @" +
🔧 Fix — Analysis & Comparison + +$($phaseContentMap['try-fix']) + +
+"@ +} + +if ($phaseContentMap.ContainsKey('report')) { + $syntheticParts += @" +
📋 Report — Final Recommendation + +$($phaseContentMap['report']) + +
+"@ +} + +$Content = $syntheticParts -join "`n`n---`n`n" +Write-Host " ✅ Built synthetic content ($($Content.Length) chars)" -ForegroundColor Green + # Final validation if ([string]::IsNullOrWhiteSpace($Content)) { Write-Host "" Write-Host "╔═══════════════════════════════════════════════════════════╗" -ForegroundColor Red - Write-Host "║ ⛔ No state file content found ║" -ForegroundColor Red + Write-Host "║ ⛔ No content loaded from phase files ║" -ForegroundColor Red Write-Host "╚═══════════════════════════════════════════════════════════╝" -ForegroundColor Red Write-Host "" - Write-Host "Usage options:" -ForegroundColor Yellow - Write-Host " 1. ./post-ai-summary-comment.ps1 -PRNumber 12345" -ForegroundColor Gray - Write-Host " (auto-loads CustomAgentLogsTmp/PRState/pr-12345.md)" -ForegroundColor Gray - Write-Host "" - Write-Host " 2. ./post-ai-summary-comment.ps1 -StateFile path/to/pr-12345.md" -ForegroundColor Gray - Write-Host " (loads specified file, extracts PRNumber from name)" -ForegroundColor Gray + Write-Host "Usage:" -ForegroundColor Yellow + Write-Host " ./post-ai-summary-comment.ps1 -PRNumber 12345 # auto-loads from PRAgent/*/content.md" -ForegroundColor Gray Write-Host "" - Write-Host " 3. ./post-ai-summary-comment.ps1 -PRNumber 12345 -Content `"...`"" -ForegroundColor Gray - Write-Host " (legacy: provide content directly)" -ForegroundColor Gray - Write-Host "" - throw "Content is required. See usage options above." -} - -if ($PRNumber -eq 0) { - throw "PRNumber is required. Provide via -PRNumber or use a state file named pr-XXXXX.md" + throw "No content loaded from PRAgent phase files." } Write-Host "╔═══════════════════════════════════════════════════════════╗" -ForegroundColor Cyan @@ -167,7 +214,7 @@ function Test-PhaseContentComplete { # Check if content exists if ([string]::IsNullOrWhiteSpace($PhaseContent)) { - $validationErrors += "Phase $PhaseName is marked as '$PhaseStatus' but has NO content in state file" + $validationErrors += "Phase $PhaseName is marked as '$PhaseStatus' but has NO content" if ($Debug) { Write-Host " [DEBUG] Content is null or whitespace for phase: $PhaseName" -ForegroundColor DarkGray } @@ -179,8 +226,9 @@ function Test-PhaseContentComplete { Write-Host " [DEBUG] First 100 chars: $($PhaseContent.Substring(0, [Math]::Min(100, $PhaseContent.Length)))" -ForegroundColor DarkGray } - # Check for PENDING markers - $pendingMatches = [regex]::Matches($PhaseContent, '\[PENDING\]|⏳\s*PENDING') + # Check for PENDING markers - only match [PENDING] placeholder markers. + # ⏳ PENDING is a status indicator (redundant with phase table), not an unfilled placeholder. + $pendingMatches = [regex]::Matches($PhaseContent, '\[PENDING\]') if ($pendingMatches.Count -gt 0) { $validationErrors += "Phase $PhaseName is marked as '$PhaseStatus' but contains $($pendingMatches.Count) PENDING markers" } @@ -235,7 +283,7 @@ function Test-PhaseContentComplete { # EXTRACTION FUNCTIONS # ============================================================================ -# Extract recommendation from state file +# Extract recommendation from content $recommendation = "IN PROGRESS" if ($Content -match '##\s+✅\s+Final Recommendation:\s+APPROVE') { $recommendation = "✅ APPROVE" @@ -247,7 +295,7 @@ if ($Content -match '##\s+✅\s+Final Recommendation:\s+APPROVE') { $recommendation = "⚠️ REQUEST CHANGES" } -# Extract phase statuses from state file +# Extract phase statuses from content $phaseStatuses = @{ "Pre-Flight" = "⏳ PENDING" "Gate" = "⏳ PENDING" @@ -273,7 +321,7 @@ if ($Content -match '(?s)\|\s*Phase\s*\|\s*Status\s*\|.*?\n\|[\s-]+\|[\s-]+\|(.* # DYNAMIC SECTION EXTRACTION # ============================================================================ -# Extract ALL sections from state file dynamically +# Extract ALL sections from content dynamically function Extract-AllSections { param( [string]$StateContent, @@ -288,7 +336,7 @@ function Extract-AllSections { $matches = [regex]::Matches($StateContent, $pattern) if ($Debug) { - Write-Host " [DEBUG] Found $($matches.Count) section(s) in state file" -ForegroundColor Cyan + Write-Host " [DEBUG] Found $($matches.Count) section(s) in content" -ForegroundColor Cyan } foreach ($match in $matches) { @@ -361,6 +409,27 @@ $reportContent = Get-SectionByPattern -Sections $allSections -Patterns @( 'Final Report' ) -Debug:$debugMode +# Fallback: If Report content not found in
blocks, look for +# "## Final Recommendation" section directly in the markdown (agent sometimes +# writes Report as a top-level heading instead of a
block) +if ([string]::IsNullOrWhiteSpace($reportContent)) { + # Look for "## Final Recommendation" heading - capture up to the first --- separator + # or
block to avoid including content from other phases + if ($Content -match '(?s)##\s+[✅⚠️❌\uFE0F]*\s*Final Recommendation[:\s].+?(?=\n---|\n) function New-ReviewSession { param([string]$PhaseContent, [string]$CommitTitle, [string]$CommitSha, [string]$CommitUrl) @@ -461,16 +530,8 @@ function New-ReviewSession { return "" } - return @" -
-📝 Review Session$CommitTitle · $CommitSha - ---- - -$PhaseContent - -
-"@ + # Return raw content — the commit info is shown on the top-level summary + return $PhaseContent } # Helper function to extract existing review sessions from a phase @@ -482,11 +543,25 @@ function Get-ExistingReviewSessions { } $sessions = @() + # Try old format first (wrapped in
📝 ...) $pattern = '(?s)
\s*📝.*?.*?
' $matches = [regex]::Matches($PhaseContent, $pattern) - foreach ($match in $matches) { - $sessions += $match.Value + if ($matches.Count -gt 0) { + # Old format: extract the inner content from each session wrapper + foreach ($match in $matches) { + $inner = $match.Value + if ($inner -match '(?s)📝.*?\s*---\s*(.*?)\s*
') { + $sessions += ($Matches[1].Trim() -replace '(?m)^---\s*$', '').Trim() + } else { + $sessions += $inner + } + } + } else { + # New format: content is directly in the phase section (no wrapper) + # Strip leading/trailing --- separators that may remain from old format + $cleaned = ($PhaseContent.Trim() -replace '(?m)^---\s*$', '').Trim() + $sessions += $cleaned } return $sessions @@ -585,7 +660,7 @@ if ($existingComment) { Write-Host "✓ No existing comment found - creating new..." -ForegroundColor Yellow } -# Create NEW review sessions from current state file +# Create NEW review sessions from current content $newPreFlightSession = New-ReviewSession -PhaseContent $preFlightContent -CommitTitle $latestCommitTitle -CommitSha $latestCommitSha -CommitUrl $latestCommitUrl $newGateSession = New-ReviewSession -PhaseContent $gateContent -CommitTitle $latestCommitTitle -CommitSha $latestCommitSha -CommitUrl $latestCommitUrl $newFixSession = New-ReviewSession -PhaseContent $fixContent -CommitTitle $latestCommitTitle -CommitSha $latestCommitSha -CommitUrl $latestCommitUrl @@ -659,7 +734,7 @@ $SECTION_END = "" $prReviewSection = @" $SECTION_START
-📊 Expand Full Review +📊 Expand Full Review$latestCommitSha · $latestCommitTitle --- diff --git a/.github/scripts/post-pr-finalize-comment.ps1 b/.github/scripts/post-pr-finalize-comment.ps1 new file mode 100644 index 000000000000..7e80d48a6b27 --- /dev/null +++ b/.github/scripts/post-pr-finalize-comment.ps1 @@ -0,0 +1,199 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Posts the PR finalize summary into the unified AI Summary comment on a GitHub PR. + +.DESCRIPTION + Reads the pr-finalize-summary.md file and injects its content directly into the + block of the unified AI Summary comment. + + No parsing or re-formatting — the summary file content is posted as-is inside + a collapsible details block. + + Auto-discovers the summary file from: + CustomAgentLogsTmp/PRState/{PRNumber}/PRAgent/pr-finalize/pr-finalize-summary.md + +.PARAMETER PRNumber + The PR number to post comment on (required) + +.PARAMETER SummaryFile + Path to pr-finalize-summary.md file. Auto-discovered from PRNumber if not provided. + +.PARAMETER DryRun + Print comment instead of posting + +.PARAMETER PreviewFile + Custom path for dry-run preview output + +.EXAMPLE + ./post-pr-finalize-comment.ps1 -PRNumber 34427 + +.EXAMPLE + ./post-pr-finalize-comment.ps1 -PRNumber 34427 -DryRun +#> + +param( + [Parameter(Mandatory=$false)] + [int]$PRNumber, + + [Parameter(Mandatory=$false)] + [string]$SummaryFile, + + [Parameter(Mandatory=$false)] + [switch]$DryRun, + + [Parameter(Mandatory=$false)] + [string]$PreviewFile +) + +$ErrorActionPreference = "Stop" + +Write-Host "╔═══════════════════════════════════════════════════════════╗" -ForegroundColor Cyan +Write-Host "║ PR Finalize Comment (Post/Update) ║" -ForegroundColor Cyan +Write-Host "╚═══════════════════════════════════════════════════════════╝" -ForegroundColor Cyan + +# ============================================================================ +# AUTO-DISCOVERY +# ============================================================================ + +$repoRoot = git rev-parse --show-toplevel 2>$null + +if ($PRNumber -gt 0 -and [string]::IsNullOrWhiteSpace($SummaryFile)) { + $candidates = @( + "CustomAgentLogsTmp/PRState/$PRNumber/PRAgent/pr-finalize/pr-finalize-summary.md" + ) + if ($repoRoot) { + $candidates += Join-Path $repoRoot "CustomAgentLogsTmp/PRState/$PRNumber/PRAgent/pr-finalize/pr-finalize-summary.md" + } + foreach ($candidate in $candidates) { + if (Test-Path $candidate) { + $SummaryFile = $candidate + Write-Host "ℹ️ Auto-discovered summary file: $SummaryFile" -ForegroundColor Cyan + break + } + } +} + +# Extract PRNumber from path if not provided +if ($PRNumber -eq 0 -and -not [string]::IsNullOrWhiteSpace($SummaryFile) -and $SummaryFile -match '[/\\](\d+)[/\\]') { + $PRNumber = [int]$Matches[1] + Write-Host "ℹ️ Auto-detected PRNumber: $PRNumber from path" -ForegroundColor Cyan +} + +if ($PRNumber -eq 0) { + throw "PRNumber is required. Provide via -PRNumber or use -SummaryFile with path containing PR number." +} + +# ============================================================================ +# LOAD SUMMARY CONTENT +# ============================================================================ + +if ([string]::IsNullOrWhiteSpace($SummaryFile) -or -not (Test-Path $SummaryFile)) { + Write-Host "⚠️ No pr-finalize summary file found for PR #$PRNumber — nothing to post" -ForegroundColor Yellow + exit 0 +} + +$summaryContent = (Get-Content $SummaryFile -Raw -Encoding UTF8).Trim() +if ([string]::IsNullOrWhiteSpace($summaryContent)) { + Write-Host "⚠️ Summary file is empty — nothing to post" -ForegroundColor Yellow + exit 0 +} + +Write-Host " ✅ Loaded summary ($($summaryContent.Length) chars) from: $SummaryFile" -ForegroundColor Green + +# ============================================================================ +# BUILD SECTION +# ============================================================================ + +$MAIN_MARKER = "" +$SECTION_START = "" +$SECTION_END = "" + +$finalizeSection = @" +$SECTION_START +
+📋 Expand PR Finalization Review + +$summaryContent + +
+$SECTION_END +"@ + +# ============================================================================ +# POST / UPDATE +# ============================================================================ + +Write-Host "`nInjecting into AI Summary comment on #$PRNumber..." -ForegroundColor Yellow + +# Find existing unified comment +$existingUnifiedComment = $null +try { + $commentsJson = gh api "repos/dotnet/maui/issues/$PRNumber/comments?per_page=100" 2>$null + $comments = $commentsJson | ConvertFrom-Json + foreach ($comment in $comments) { + if ($comment.body -match [regex]::Escape($MAIN_MARKER)) { + $existingUnifiedComment = $comment + Write-Host "✓ Found unified AI Summary comment (ID: $($comment.id))" -ForegroundColor Green + break + } + } + if (-not $existingUnifiedComment) { + Write-Host "✓ No existing AI Summary comment found — will create new" -ForegroundColor Yellow + } +} catch { + Write-Host "⚠️ Could not fetch comments: $_" -ForegroundColor Yellow +} + +# Build the final comment body +if ($existingUnifiedComment) { + $body = $existingUnifiedComment.body + if ($body -match [regex]::Escape($SECTION_START)) { + $pattern = [regex]::Escape($SECTION_START) + "[\s\S]*?" + [regex]::Escape($SECTION_END) + $finalComment = $body -replace $pattern, $finalizeSection + } else { + $finalComment = $body.TrimEnd() + "`n`n" + $finalizeSection + } + $finalComment = $finalComment -replace "`n{4,}", "`n`n`n" +} else { + $finalComment = @" +$MAIN_MARKER + +## 🤖 AI Summary + +$finalizeSection +"@ +} + +# DryRun: preview to file +if ($DryRun) { + if ([string]::IsNullOrWhiteSpace($PreviewFile)) { + $PreviewFile = "CustomAgentLogsTmp/PRState/$PRNumber/ai-summary-comment-preview.md" + } + $previewDir = Split-Path $PreviewFile -Parent + if ($previewDir -and -not (Test-Path $previewDir)) { + New-Item -ItemType Directory -Path $previewDir -Force | Out-Null + } + Set-Content -Path $PreviewFile -Value "$($finalComment.TrimEnd())`n" -Encoding UTF8 -NoNewline + Write-Host "`n=== COMMENT PREVIEW ===" -ForegroundColor Yellow + Write-Host $finalComment + Write-Host "`n=== END PREVIEW ===" -ForegroundColor Yellow + Write-Host "`n✅ Preview saved to: $PreviewFile" -ForegroundColor Green + exit 0 +} + +# Post to GitHub +$tempFile = [System.IO.Path]::GetTempFileName() +@{ body = $finalComment } | ConvertTo-Json -Depth 10 | Set-Content -Path $tempFile -Encoding UTF8 + +if ($existingUnifiedComment) { + Write-Host "Updating unified comment ID $($existingUnifiedComment.id)..." -ForegroundColor Yellow + $result = gh api --method PATCH "repos/dotnet/maui/issues/comments/$($existingUnifiedComment.id)" --input $tempFile --jq '.html_url' + Write-Host "✅ PR finalize section updated: $result" -ForegroundColor Green +} else { + Write-Host "Creating new unified comment on PR #$PRNumber..." -ForegroundColor Yellow + $result = gh api --method POST "repos/dotnet/maui/issues/$PRNumber/comments" --input $tempFile --jq '.html_url' + Write-Host "✅ Unified comment posted: $result" -ForegroundColor Green +} + +Remove-Item $tempFile diff --git a/.github/scripts/shared/Build-AndDeploy.ps1 b/.github/scripts/shared/Build-AndDeploy.ps1 index 8abdafe7b21c..ae81e05a1ea8 100644 --- a/.github/scripts/shared/Build-AndDeploy.ps1 +++ b/.github/scripts/shared/Build-AndDeploy.ps1 @@ -85,19 +85,56 @@ if ($Platform -eq "android") { Write-Info "Build command: dotnet build $($buildArgs -join ' ')" $buildStartTime = Get-Date + $maxAttempts = 2 + $buildExitCode = 1 + + for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) { + if ($attempt -gt 1) { + Write-Warn "Retrying build/deploy (attempt $attempt of $maxAttempts)..." + + # Uninstall any MAUI test packages to clear bad state + $installedPkg = & adb shell pm list packages 2>$null | Select-String "maui" | ForEach-Object { ($_ -replace "package:", "").Trim() } + if ($installedPkg) { + foreach ($pkg in $installedPkg) { + Write-Info "Uninstalling $pkg before retry..." + & adb uninstall $pkg 2>$null + } + } + + # Restart ADB server to recover from broken pipe / transient errors + Write-Info "Restarting ADB server..." + & adb kill-server 2>$null + Start-Sleep -Seconds 2 + & adb start-server + Start-Sleep -Seconds 2 + & adb wait-for-device + Start-Sleep -Seconds 3 + } + + & dotnet build @buildArgs + $buildExitCode = $LASTEXITCODE + + if ($buildExitCode -eq 0) { + break + } + + if ($attempt -lt $maxAttempts) { + Write-Warn "Build/deploy failed (attempt $attempt). ADB0010/broken-pipe errors are transient on API 30 — will retry." + } + } - # Build and deploy in one step (Run target handles both) - & dotnet build @buildArgs - - $buildExitCode = $LASTEXITCODE $buildDuration = (Get-Date) - $buildStartTime if ($buildExitCode -ne 0) { - Write-Error "Build/deploy failed with exit code $buildExitCode" + Write-Error "Build/deploy failed after $maxAttempts attempts with exit code $buildExitCode" exit $buildExitCode } - Write-Success "Build and deploy completed in $($buildDuration.TotalSeconds) seconds" + if ($attempt -gt 1) { + Write-Success "Build and deploy succeeded on attempt $attempt in $($buildDuration.TotalSeconds) seconds" + } else { + Write-Success "Build and deploy completed in $($buildDuration.TotalSeconds) seconds" + } #endregion @@ -136,6 +173,20 @@ if ($Platform -eq "android") { # Deploy to iOS simulator Write-Step "Deploying to iOS simulator..." + + # Shutdown any OTHER booted simulators to avoid Appium connecting to the wrong device + $bootedSims = xcrun simctl list devices --json | ConvertFrom-Json + $otherBooted = $bootedSims.devices.PSObject.Properties.Value | + ForEach-Object { $_ } | + Where-Object { $_.state -eq "Booted" -and $_.udid -ne $DeviceUdid } + + if ($otherBooted) { + foreach ($sim in $otherBooted) { + Write-Info "Shutting down other booted simulator: $($sim.name) ($($sim.udid))" + xcrun simctl shutdown $sim.udid 2>$null + } + } + Write-Info "Booting simulator (if not already running)..." xcrun simctl boot $DeviceUdid 2>$null diff --git a/.github/scripts/shared/Start-Emulator.ps1 b/.github/scripts/shared/Start-Emulator.ps1 index 9fd9d227331a..33f9c8687a54 100644 --- a/.github/scripts/shared/Start-Emulator.ps1 +++ b/.github/scripts/shared/Start-Emulator.ps1 @@ -6,7 +6,7 @@ .DESCRIPTION Handles device detection and startup for both Android and iOS platforms. - Android: Automatically selects and starts emulator with priority: API 30 Nexus > API 30 > Nexus > First available - - iOS: Automatically selects iPhone Xs with iOS 18.5 by default + - iOS: Automatically selects iPhone Xs with iOS 18.x (or iPhone 11 Pro with iOS 26.x) to match CI .PARAMETER Platform Target platform: "android" or "ios" @@ -32,7 +32,10 @@ param( [string]$Platform, [Parameter(Mandatory=$false)] - [string]$DeviceUdid + [string]$DeviceUdid, + + [Parameter(Mandatory=$false)] + [switch]$Headless ) # Import shared utilities @@ -65,7 +68,8 @@ if ($Platform -eq "android") { # Check if DeviceUdid is an AVD name (not an emulator-XXXX format) if ($DeviceUdid -and $DeviceUdid -notmatch "^emulator-\d+$") { # DeviceUdid is likely an AVD name - check if it's in the AVD list - $avdList = emulator -list-avds 2>$null + # Force array output - single AVD returns a string which breaks -contains + [string[]]$avdList = @(emulator -list-avds 2>$null) if ($avdList -contains $DeviceUdid) { Write-Info "DeviceUdid '$DeviceUdid' is an AVD name. Will boot this emulator..." $selectedAvd = $DeviceUdid @@ -103,7 +107,8 @@ if ($Platform -eq "android") { # Get list of available AVDs (if not already set from parameter) if (-not $selectedAvd) { - $avdList = emulator -list-avds 2>$null + # Force array output - single AVD returns a string which breaks indexing + [string[]]$avdList = @(emulator -list-avds 2>$null) if (-not $avdList -or $avdList.Count -eq 0) { Write-Error "No Android emulators found. Please create an Android Virtual Device (AVD) using Android Studio." @@ -119,7 +124,7 @@ if ($Platform -eq "android") { # Selection priority: # 1. API 34 device (matches CI provisioning) # 2. API 30 Nexus device - # 3. Any API 30 device + # 3. Any API 30 device (matches names like "Emulator_30", "API_30_xxx", etc.) # 4. Any Nexus device # 5. First available device @@ -132,16 +137,16 @@ if ($Platform -eq "android") { # Try to find API 30 Nexus device if (-not $selectedAvd) { - $api30Nexus = $avdList | Where-Object { $_ -match "API.*30" -and $_ -match "Nexus" } | Select-Object -First 1 + $api30Nexus = $avdList | Where-Object { $_ -match "30" -and $_ -match "Nexus" } | Select-Object -First 1 if ($api30Nexus) { $selectedAvd = $api30Nexus Write-Info "Selected API 30 Nexus device: $selectedAvd" } } - # Try to find any API 30 device + # Try to find any API 30 device (match "30" anywhere in name) if (-not $selectedAvd) { - $api30Device = $avdList | Where-Object { $_ -match "API.*30" } | Select-Object -First 1 + $api30Device = $avdList | Where-Object { $_ -match "30" } | Select-Object -First 1 if ($api30Device) { $selectedAvd = $api30Device Write-Info "Selected API 30 device: $selectedAvd" @@ -192,17 +197,26 @@ if ($Platform -eq "android") { # Redirect output to a log file for debugging $emulatorLog = Join-Path ([System.IO.Path]::GetTempPath()) "emulator-$selectedAvd.log" + # Use -no-window only when explicitly headless or running in CI + $useHeadless = $Headless -or $env:CI -or $env:TF_BUILD -or $env:GITHUB_ACTIONS + if ($IsWindows) { - Start-Process $emulatorBin -ArgumentList "-avd", $selectedAvd, "-no-snapshot-load", "-no-boot-anim", "-gpu", "swiftshader_indirect" -WindowStyle Hidden + $windowStyle = if ($useHeadless) { "Hidden" } else { "Normal" } + Start-Process $emulatorBin -ArgumentList "-avd", $selectedAvd, "-no-snapshot-load", "-no-boot-anim", "-gpu", "swiftshader_indirect" -WindowStyle $windowStyle } else { # macOS/Linux: Use nohup to detach from terminal # Use -no-snapshot (not -no-snapshot-load) to ensure clean emulator state for CI/testing. # This disables both snapshot load and save, so each boot is a cold boot. # Trade-off: slower boots, but guarantees no stale state between test runs. - $startScript = "nohup '$emulatorBin' -avd '$selectedAvd' -no-window -no-snapshot -no-audio -no-boot-anim -gpu swiftshader_indirect > '$emulatorLog' 2>&1 &" + $windowFlag = if ($useHeadless) { "-no-window" } else { "" } + $startScript = "nohup '$emulatorBin' -avd '$selectedAvd' $windowFlag -no-snapshot -no-audio -no-boot-anim -gpu swiftshader_indirect > '$emulatorLog' 2>&1 &" bash -c $startScript - Write-Info "Emulator started in background. Log file: $emulatorLog" + if ($useHeadless) { + Write-Info "Emulator started headless (no window). Log file: $emulatorLog" + } else { + Write-Info "Emulator started with window. Log file: $emulatorLog" + } } # Give the emulator process time to start @@ -339,15 +353,27 @@ if ($Platform -eq "android") { exit 1 } - # Get device UDID if not provided + # Get device UDID if not provided - check env var first + if (-not $DeviceUdid -and $env:DEVICE_UDID) { + Write-Info "Using DEVICE_UDID from environment: $($env:DEVICE_UDID)" + $DeviceUdid = $env:DEVICE_UDID + } + if (-not $DeviceUdid) { Write-Info "Auto-detecting iOS simulator..." $simList = xcrun simctl list devices available --json | ConvertFrom-Json - # Preferred devices in order of priority - $preferredDevices = @("iPhone 16 Pro", "iPhone 15 Pro", "iPhone 14 Pro", "iPhone Xs") # Preferred iOS versions in order (stable preferred, beta fallback) $preferredVersions = @("iOS-18", "iOS-17", "iOS-26") + # Preferred devices per iOS version to match CI configuration: + # iOS 18.x → iPhone Xs (matches CI default in UITest.cs) + # iOS 26.x → iPhone 11 Pro (matches CI visual test requirement) + # iOS 17.x → iPhone Xs (fallback) + $preferredDevicesPerVersion = @{ + "iOS-18" = @("iPhone Xs", "iPhone 16 Pro", "iPhone 15 Pro", "iPhone 14 Pro") + "iOS-17" = @("iPhone Xs", "iPhone 15 Pro", "iPhone 14 Pro") + "iOS-26" = @("iPhone 11 Pro", "iPhone 16 Pro", "iPhone 15 Pro") + } $selectedDevice = $null $selectedVersion = $null @@ -356,13 +382,16 @@ if ($Platform -eq "android") { foreach ($version in $preferredVersions) { if ($selectedDevice) { break } - # Get all runtimes matching this version prefix + # Get all runtimes matching this version prefix, sorted by version descending + # so the latest minor version is preferred (e.g., iOS-18-5 before iOS-18-3) $matchingRuntimes = $simList.devices.PSObject.Properties | - Where-Object { $_.Name -match $version } + Where-Object { $_.Name -match $version } | + Sort-Object { $_.Name } -Descending if ($matchingRuntimes) { - # Try each preferred device - foreach ($deviceName in $preferredDevices) { + # Try each preferred device for this version + $devicesForVersion = if ($preferredDevicesPerVersion.ContainsKey($version)) { $preferredDevicesPerVersion[$version] } else { @("iPhone Xs", "iPhone 16 Pro") } + foreach ($deviceName in $devicesForVersion) { $device = $null $deviceRuntime = $null foreach ($rt in $matchingRuntimes) { @@ -444,6 +473,19 @@ if ($Platform -eq "android") { Write-Success "iOS simulator: $deviceName ($DeviceUdid)" + # Shutdown any OTHER booted simulators to avoid Appium connecting to the wrong device + $bootedSims = xcrun simctl list devices --json | ConvertFrom-Json + $otherBooted = $bootedSims.devices.PSObject.Properties.Value | + ForEach-Object { $_ } | + Where-Object { $_.state -eq "Booted" -and $_.udid -ne $DeviceUdid } + + if ($otherBooted) { + foreach ($sim in $otherBooted) { + Write-Info "Shutting down other booted simulator: $($sim.name) ($($sim.udid))" + xcrun simctl shutdown $sim.udid 2>$null + } + } + # Boot simulator if not already booted Write-Info "Booting simulator (if not already running)..." xcrun simctl boot $DeviceUdid 2>$null @@ -460,7 +502,7 @@ if ($Platform -eq "android") { exit 1 } - Write-Success "Simulator is booted and ready" + Write-Success "Simulator is booted and ready: $deviceName" #endregion } diff --git a/.github/scripts/shared/Update-AgentLabels.ps1 b/.github/scripts/shared/Update-AgentLabels.ps1 new file mode 100644 index 000000000000..7c2cd59c1d15 --- /dev/null +++ b/.github/scripts/shared/Update-AgentLabels.ps1 @@ -0,0 +1,460 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Shared functions for managing agent workflow labels on GitHub PRs. + +.DESCRIPTION + Provides idempotent label management for the pr-review skill review workflow. + Labels use the 's/agent-*' prefix convention for easy querying. + + Label categories: + - Outcome labels (mutually exclusive): agent-approved, agent-changes-requested, agent-review-incomplete + - Signal labels (additive): agent-gate-passed, agent-gate-failed, agent-fix-win, agent-fix-pr-picked + - Manual labels (applied by maintainers): agent-fix-implemented + - Tracking label: agent-reviewed (always applied on completed run) + +.NOTES + All functions are designed to be non-fatal: label failures emit warnings + but do not throw or exit with error codes. +#> + +# ============================================================ +# Label definitions +# ============================================================ + +$script:OutcomeLabels = @{ + 's/agent-approved' = @{ Description = 'AI agent recommends approval - PR fix is correct and optimal'; Color = '2E7D32' } + 's/agent-changes-requested' = @{ Description = 'AI agent recommends changes - found a better alternative or issues'; Color = 'E65100' } + 's/agent-review-incomplete' = @{ Description = 'AI agent could not complete all phases (blocker, timeout, error)'; Color = 'B71C1C' } +} + +$script:SignalLabels = @{ + 's/agent-gate-passed' = @{ Description = 'AI verified tests catch the bug (fail without fix, pass with fix)'; Color = '4CAF50' } + 's/agent-gate-failed' = @{ Description = 'AI could not verify tests catch the bug'; Color = 'FF9800' } + 's/agent-fix-win' = @{ Description = 'AI found a better alternative fix than the PR'; Color = '66BB6A' } + 's/agent-fix-pr-picked' = @{ Description = 'AI could not beat the PR fix - PR is the best among all candidates'; Color = 'FF7043' } +} + +$script:ManualLabels = @{ + 's/agent-fix-implemented' = @{ Description = 'PR author implemented the agent suggested fix'; Color = '7B1FA2' } +} + +$script:TrackingLabel = @{ + 's/agent-reviewed' = @{ Description = 'PR was reviewed by AI agent workflow (full 4-phase review)'; Color = '1565C0' } +} + +# All label definitions combined +$script:AllLabelDefs = @{} +foreach ($group in @($script:OutcomeLabels, $script:SignalLabels, $script:ManualLabels, $script:TrackingLabel)) { + foreach ($key in $group.Keys) { + $script:AllLabelDefs[$key] = $group[$key] + } +} + +# ============================================================ +# Helper: Ensure a label exists in the repository +# ============================================================ +function Ensure-LabelExists { + <# + .SYNOPSIS + Creates a label in the repository if it doesn't already exist. + Updates description/color if the label exists but has stale metadata. + #> + param( + [Parameter(Mandatory)] [string]$LabelName, + [Parameter(Mandatory)] [string]$Description, + [Parameter(Mandatory)] [string]$Color, + [string]$Owner = 'dotnet', + [string]$Repo = 'maui' + ) + + try { + # Check if label exists + $existing = gh api "repos/$Owner/$Repo/labels/$([uri]::EscapeDataString($LabelName))" 2>$null | ConvertFrom-Json + if ($LASTEXITCODE -eq 0 -and $existing) { + # Label exists — update if description or color changed + $needsUpdate = ($existing.description -ne $Description) -or ($existing.color -ne $Color) + if ($needsUpdate) { + gh api "repos/$Owner/$Repo/labels/$([uri]::EscapeDataString($LabelName))" ` + --method PATCH ` + -f description="$Description" ` + -f color="$Color" 2>$null | Out-Null + Write-Host " 🏷️ Updated label: $LabelName" -ForegroundColor Gray + } + } else { + # Label doesn't exist — create it + gh api "repos/$Owner/$Repo/labels" ` + --method POST ` + -f name="$LabelName" ` + -f description="$Description" ` + -f color="$Color" 2>$null | Out-Null + if ($LASTEXITCODE -eq 0) { + Write-Host " 🏷️ Created label: $LabelName" -ForegroundColor Green + } else { + Write-Host " ⚠️ Failed to create label: $LabelName" -ForegroundColor Yellow + } + } + } + catch { + Write-Host " ⚠️ Label operation failed for '$LabelName': $_" -ForegroundColor Yellow + } +} + +# ============================================================ +# Helper: Get current agent labels on a PR +# ============================================================ +function Get-AgentLabels { + param( + [Parameter(Mandatory)] [string]$PRNumber, + [string]$Owner = 'dotnet', + [string]$Repo = 'maui' + ) + + $labels = gh api "repos/$Owner/$Repo/issues/$PRNumber/labels" --jq '.[].name' 2>$null + if ($LASTEXITCODE -ne 0) { return @() } + return @($labels | Where-Object { $_ -like 's/agent-*' }) +} + +# ============================================================ +# Helper: Add a label to a PR +# ============================================================ +function Add-Label { + param( + [Parameter(Mandatory)] [string]$PRNumber, + [Parameter(Mandatory)] [string]$LabelName, + [string]$Owner = 'dotnet', + [string]$Repo = 'maui' + ) + + gh api "repos/$Owner/$Repo/issues/$PRNumber/labels" ` + --method POST ` + -f "labels[]=$LabelName" 2>$null | Out-Null + return $LASTEXITCODE -eq 0 +} + +# ============================================================ +# Helper: Remove a label from a PR +# ============================================================ +function Remove-Label { + param( + [Parameter(Mandatory)] [string]$PRNumber, + [Parameter(Mandatory)] [string]$LabelName, + [string]$Owner = 'dotnet', + [string]$Repo = 'maui' + ) + + gh api "repos/$Owner/$Repo/issues/$PRNumber/labels/$([uri]::EscapeDataString($LabelName))" ` + --method DELETE 2>$null | Out-Null + return $LASTEXITCODE -eq 0 +} + +# ============================================================ +# Update-AgentOutcomeLabel +# ============================================================ +function Update-AgentOutcomeLabel { + <# + .SYNOPSIS + Applies exactly one outcome label, removing any conflicting outcome labels. + + .PARAMETER Outcome + One of: 'approved', 'changes-requested', 'review-incomplete' + #> + param( + [Parameter(Mandatory)] [string]$PRNumber, + [Parameter(Mandatory)] + [ValidateSet('approved', 'changes-requested', 'review-incomplete')] + [string]$Outcome, + [string]$Owner = 'dotnet', + [string]$Repo = 'maui' + ) + + $targetLabel = "s/agent-$Outcome" + Write-Host " 📌 Outcome: $targetLabel" -ForegroundColor Cyan + + # Ensure the target label exists in the repo + $def = $script:OutcomeLabels[$targetLabel] + Ensure-LabelExists -LabelName $targetLabel -Description $def.Description -Color $def.Color -Owner $Owner -Repo $Repo + + # Get current labels on the PR + $currentLabels = Get-AgentLabels -PRNumber $PRNumber -Owner $Owner -Repo $Repo + + # Remove conflicting outcome labels + foreach ($olName in $script:OutcomeLabels.Keys) { + if ($olName -ne $targetLabel -and $currentLabels -contains $olName) { + Write-Host " 🗑️ Removing stale: $olName" -ForegroundColor Yellow + Remove-Label -PRNumber $PRNumber -LabelName $olName -Owner $Owner -Repo $Repo + } + } + + # Add the target label (idempotent — GitHub ignores duplicates) + if ($currentLabels -notcontains $targetLabel) { + $ok = Add-Label -PRNumber $PRNumber -LabelName $targetLabel -Owner $Owner -Repo $Repo + if ($ok) { + Write-Host " ✅ Applied: $targetLabel" -ForegroundColor Green + } else { + Write-Host " ⚠️ Failed to apply: $targetLabel" -ForegroundColor Yellow + } + } else { + Write-Host " ✅ Already present: $targetLabel" -ForegroundColor Green + } +} + +# ============================================================ +# Update-AgentSignalLabels +# ============================================================ +function Update-AgentSignalLabels { + <# + .SYNOPSIS + Adds or removes signal labels based on phase results. + + .PARAMETER GateResult + Gate phase result: 'passed', 'failed', or $null (skipped) + + .PARAMETER FixResult + Fix phase result: 'win' (PR best), 'lose' (alternative better), or $null (skipped) + #> + param( + [Parameter(Mandatory)] [string]$PRNumber, + [string]$GateResult, # 'passed', 'failed', or $null + [string]$FixResult, # 'win' (agent found better alternative), 'lose' (PR is best), or $null + [string]$Owner = 'dotnet', + [string]$Repo = 'maui' + ) + + $currentLabels = Get-AgentLabels -PRNumber $PRNumber -Owner $Owner -Repo $Repo + + # --- Gate labels --- + if ($GateResult -eq 'passed') { + $label = 's/agent-gate-passed' + $def = $script:SignalLabels[$label] + Ensure-LabelExists -LabelName $label -Description $def.Description -Color $def.Color -Owner $Owner -Repo $Repo + + # Add gate-passed, remove gate-failed + if ($currentLabels -notcontains $label) { + Add-Label -PRNumber $PRNumber -LabelName $label -Owner $Owner -Repo $Repo | Out-Null + Write-Host " ✅ Signal: $label" -ForegroundColor Green + } + if ($currentLabels -contains 's/agent-gate-failed') { + Remove-Label -PRNumber $PRNumber -LabelName 's/agent-gate-failed' -Owner $Owner -Repo $Repo | Out-Null + Write-Host " 🗑️ Removed stale: s/agent-gate-failed" -ForegroundColor Yellow + } + } + elseif ($GateResult -eq 'failed') { + $label = 's/agent-gate-failed' + $def = $script:SignalLabels[$label] + Ensure-LabelExists -LabelName $label -Description $def.Description -Color $def.Color -Owner $Owner -Repo $Repo + + # Add gate-failed, remove gate-passed + if ($currentLabels -notcontains $label) { + Add-Label -PRNumber $PRNumber -LabelName $label -Owner $Owner -Repo $Repo | Out-Null + Write-Host " ✅ Signal: $label" -ForegroundColor Green + } + if ($currentLabels -contains 's/agent-gate-passed') { + Remove-Label -PRNumber $PRNumber -LabelName 's/agent-gate-passed' -Owner $Owner -Repo $Repo | Out-Null + Write-Host " 🗑️ Removed stale: s/agent-gate-passed" -ForegroundColor Yellow + } + } + + # --- Fix labels --- + if ($FixResult -eq 'win') { + $label = 's/agent-fix-win' + $def = $script:SignalLabels[$label] + Ensure-LabelExists -LabelName $label -Description $def.Description -Color $def.Color -Owner $Owner -Repo $Repo + + if ($currentLabels -notcontains $label) { + Add-Label -PRNumber $PRNumber -LabelName $label -Owner $Owner -Repo $Repo | Out-Null + Write-Host " ✅ Signal: $label" -ForegroundColor Green + } + if ($currentLabels -contains 's/agent-fix-pr-picked') { + Remove-Label -PRNumber $PRNumber -LabelName 's/agent-fix-pr-picked' -Owner $Owner -Repo $Repo | Out-Null + Write-Host " 🗑️ Removed stale: s/agent-fix-pr-picked" -ForegroundColor Yellow + } + } + elseif ($FixResult -eq 'lose') { + $label = 's/agent-fix-pr-picked' + $def = $script:SignalLabels[$label] + Ensure-LabelExists -LabelName $label -Description $def.Description -Color $def.Color -Owner $Owner -Repo $Repo + + if ($currentLabels -notcontains $label) { + Add-Label -PRNumber $PRNumber -LabelName $label -Owner $Owner -Repo $Repo | Out-Null + Write-Host " ✅ Signal: $label" -ForegroundColor Green + } + if ($currentLabels -contains 's/agent-fix-win') { + Remove-Label -PRNumber $PRNumber -LabelName 's/agent-fix-win' -Owner $Owner -Repo $Repo | Out-Null + Write-Host " 🗑️ Removed stale: s/agent-fix-win" -ForegroundColor Yellow + } + } +} + +# ============================================================ +# Update-AgentReviewedLabel +# ============================================================ +function Update-AgentReviewedLabel { + <# + .SYNOPSIS + Ensures the s/agent-reviewed tracking label is on the PR. + #> + param( + [Parameter(Mandatory)] [string]$PRNumber, + [string]$Owner = 'dotnet', + [string]$Repo = 'maui' + ) + + $label = 's/agent-reviewed' + $def = $script:TrackingLabel[$label] + Ensure-LabelExists -LabelName $label -Description $def.Description -Color $def.Color -Owner $Owner -Repo $Repo + + $currentLabels = Get-AgentLabels -PRNumber $PRNumber -Owner $Owner -Repo $Repo + if ($currentLabels -notcontains $label) { + $ok = Add-Label -PRNumber $PRNumber -LabelName $label -Owner $Owner -Repo $Repo + if ($ok) { + Write-Host " ✅ Tracking: $label" -ForegroundColor Green + } else { + Write-Host " ⚠️ Failed to apply: $label" -ForegroundColor Yellow + } + } else { + Write-Host " ✅ Already present: $label" -ForegroundColor Green + } +} + +# ============================================================ +# Parse-PhaseOutcomes — read content.md files to determine labels +# ============================================================ +function Parse-PhaseOutcomes { + <# + .SYNOPSIS + Reads phase output content.md files and determines outcome + signal labels. + + .OUTPUTS + Hashtable with keys: Outcome, GateResult, FixResult + #> + param( + [Parameter(Mandatory)] [string]$PRNumber, + [string]$RepoRoot = (git rev-parse --show-toplevel 2>$null) + ) + + $baseDir = Join-Path $RepoRoot "CustomAgentLogsTmp/PRState/$PRNumber/PRAgent" + $result = @{ + Outcome = $null # 'approved', 'changes-requested', 'review-incomplete' + GateResult = $null # 'passed', 'failed' + FixResult = $null # 'win', 'lose' + } + + # --- Parse Gate content.md --- + $gateFile = Join-Path $baseDir "gate/content.md" + if (Test-Path $gateFile) { + $gateContent = Get-Content $gateFile -Raw -ErrorAction SilentlyContinue + if ($gateContent) { + # Match the Result line specifically to avoid false matches from other text + if ($gateContent -match '(?im)^\*?\*?Result\*?\*?:.*(?:✅|PASSED)') { + $result.GateResult = 'passed' + } + elseif ($gateContent -match '(?im)^\*?\*?Result\*?\*?:.*(?:❌|FAILED|SKIPPED)') { + $result.GateResult = 'failed' + } + } + } + + # --- Parse try-fix content.md for fix result --- + $fixFile = Join-Path $baseDir "try-fix/content.md" + if (Test-Path $fixFile) { + $fixContent = Get-Content $fixFile -Raw -ErrorAction SilentlyContinue + if ($fixContent) { + # Extract just the fix name (before any reason separator like " — ") + # to avoid false matches from reason text containing keywords like "try-fix" or "alternative" + if ($fixContent -match '(?i)Selected Fix:\s*\*?\*?\s*(.+?)(?:\s*—|\s*$)') { + $fixName = $matches[1].Trim() + # Agent wins: fix name starts with Candidate/Alternative/try-fix + if ($fixName -match '(?i)^(?:Candidate|Alternative|try-fix)') { + $result.FixResult = 'win' + } + # Agent loses: fix name starts with PR + elseif ($fixName -match '(?i)^(?:\*?\*?\s*)?PR\b') { + $result.FixResult = 'lose' + } + } + } + } + + # --- Parse report content.md for outcome --- + $reportFile = Join-Path $baseDir "report/content.md" + if (Test-Path $reportFile) { + $reportContent = Get-Content $reportFile -Raw -ErrorAction SilentlyContinue + if ($reportContent) { + if ($reportContent -match '(?i)Final\s+Recommendation:\s*APPROVE|✅\s*Final\s+Recommendation:\s*APPROVE') { + $result.Outcome = 'approved' + } + elseif ($reportContent -match '(?i)Final\s+Recommendation:\s*REQUEST.CHANGES|⚠️\s*Final\s+Recommendation:\s*REQUEST.CHANGES') { + $result.Outcome = 'changes-requested' + } + else { + $result.Outcome = 'review-incomplete' + } + } else { + $result.Outcome = 'review-incomplete' + } + } else { + # No report means the agent didn't finish + $result.Outcome = 'review-incomplete' + } + + return $result +} + +# ============================================================ +# Apply-AgentLabels — main entry point +# ============================================================ +function Apply-AgentLabels { + <# + .SYNOPSIS + Main entry point: parses phase outputs and applies all appropriate labels. + + .DESCRIPTION + 1. Parses content.md files from each phase + 2. Applies exactly one outcome label + 3. Applies signal labels based on phase results + 4. Always applies s/agent-reviewed + + .PARAMETER PRNumber + The GitHub PR number. + + .PARAMETER RepoRoot + Repository root path. Defaults to git rev-parse --show-toplevel. + #> + param( + [Parameter(Mandatory)] [string]$PRNumber, + [string]$RepoRoot = (git rev-parse --show-toplevel 2>$null), + [string]$Owner = 'dotnet', + [string]$Repo = 'maui' + ) + + Write-Host "" + Write-Host "🏷️ Applying agent labels to PR #$PRNumber..." -ForegroundColor Cyan + + # Parse phase outcomes from content.md files + $outcomes = Parse-PhaseOutcomes -PRNumber $PRNumber -RepoRoot $RepoRoot + Write-Host " 📊 Parsed outcomes:" -ForegroundColor Gray + Write-Host " Outcome: $($outcomes.Outcome ?? '(none)')" -ForegroundColor Gray + Write-Host " Gate: $($outcomes.GateResult ?? '(skipped)')" -ForegroundColor Gray + Write-Host " Fix: $($outcomes.FixResult ?? '(skipped)')" -ForegroundColor Gray + + try { + # 1. Apply outcome label (exactly one) + if ($outcomes.Outcome) { + Update-AgentOutcomeLabel -PRNumber $PRNumber -Outcome $outcomes.Outcome -Owner $Owner -Repo $Repo + } + + # 2. Apply signal labels + Update-AgentSignalLabels -PRNumber $PRNumber -GateResult $outcomes.GateResult -FixResult $outcomes.FixResult -Owner $Owner -Repo $Repo + + # 3. Always apply tracking label + Update-AgentReviewedLabel -PRNumber $PRNumber -Owner $Owner -Repo $Repo + + Write-Host "" + Write-Host " ✅ Labels applied successfully" -ForegroundColor Green + } + catch { + Write-Host "" + Write-Host " ⚠️ Label application error (non-fatal): $_" -ForegroundColor Yellow + } +} diff --git a/.github/skills/ai-summary-comment/IMPROVEMENTS.md b/.github/skills/ai-summary-comment/IMPROVEMENTS.md deleted file mode 100644 index 2de8dc008fe4..000000000000 --- a/.github/skills/ai-summary-comment/IMPROVEMENTS.md +++ /dev/null @@ -1,640 +0,0 @@ -# PR Comment Script Improvements - -## Summary of Changes - -The `post-ai-summary-comment.ps1` script has been significantly improved to make posting PR review comments easier and more flexible using **dynamic section extraction**. - -## Key Improvements - -### 1. **Dynamic Section Extraction** (NEW!) - -**Before:** Script used hardcoded pattern matching with predefined title variations - -**After:** Script **automatically discovers ALL sections** from your state file and extracts them dynamically - -```powershell -# Extracts ALL
TITLE sections -$allSections = Extract-AllSections -StateContent $Content - -# Then maps them to phases using flexible regex patterns -$preFlightContent = Get-SectionByPattern -Sections $allSections -Patterns @( - '📋.*Issue Summary', - '📋.*Pre-Flight' -) -``` - -**Benefits:** -- ✅ **No hardcoded titles** - works with ANY section header you use -- ✅ **Automatically adapts** - add new sections without modifying the script -- ✅ **Better debugging** - shows exactly which sections were found -- ✅ **More maintainable** - less code, more flexible - -**Example debug output:** -``` -[DEBUG] Found 6 section(s) in state file -[DEBUG] Section: '📋 Issue Summary' (803 chars) -[DEBUG] Section: '🚦 Gate - Test Verification' (488 chars) -[DEBUG] Section: '🔧 Fix Candidates' (868 chars) -[DEBUG] Section: '📋 Final Report' (2351 chars) -[DEBUG] Matched '📋 Final Report' with pattern '📋.*Report' -``` - ---- - -### 2. **Flexible Pattern Matching** - -**Before:** Exact string matching required - -**After:** Uses **regex patterns** to match section titles flexibly - -```powershell -# Matches any of these (and more!): -- "📋 Final Report" ✅ -- "📋 Phase 4: Final Report" ✅ -- "📋 Report - Final Recommendation" ✅ -- Any title containing "📋" and "Report" ✅ -``` - -**Pattern examples:** -- `'📋.*Issue Summary'` matches "📋 Issue Summary", "📋 Pre-Flight Issue Summary", etc. -- `'🚦.*Gate'` matches "🚦 Gate", "🚦 Phase 2: Gate", etc. -- `'📋.*Report'` matches any title with 📋 and Report in it - ---- - -### 3. **Errors vs Warnings** - -**Validation levels:** -- **Errors** (❌) - Block posting (missing content, PENDING markers) -- **Warnings** (⚠️) - Suggestions only (missing optional sections) - -**Example:** -``` -✅ All validation checks passed! - -⚠️ VALIDATION WARNINGS -Found 2 warning(s) (non-critical): - - Fix: Fix phase missing 'Exhausted' field (non-critical) - -💡 These are suggestions but won't block posting. -``` - ---- - -### 4. **Debug Mode** - -Enable detailed extraction information: - -```powershell -$DebugPreference = 'Continue' -./post-ai-summary-comment.ps1 -PRNumber 12345 -``` - -**Shows:** -- Which sections were found in the state file -- How many characters each section contains -- Which patterns matched which sections -- Why validation passed or failed - ---- - -### 5. **Better Error Messages** - -**Comprehensive guidance when validation fails:** -``` -⛔ VALIDATION FAILED - -💡 Fix these issues in the state file before posting. - Or use -SkipValidation to bypass these checks. - -🐛 Debug tip: Run with $DebugPreference = 'Continue' for details -``` - ---- - -## How Dynamic Extraction Works - -### Step 1: Extract ALL Sections - -```powershell -function Extract-AllSections { - # Pattern matches:
TITLE...content...
- $pattern = '(?s)
\s*([^<]+)(.*?)
' - $matches = [regex]::Matches($StateContent, $pattern) - - # Returns hashtable: @{ "Title" = "content", ... } -} -``` - -**Result:** Hashtable with ALL sections from your state file - -### Step 2: Map to Phases - -```powershell -function Get-SectionByPattern { - # Try each pattern until one matches - foreach ($pattern in $Patterns) { - foreach ($key in $Sections.Keys) { - if ($key -match $pattern) { - return $Sections[$key] # Found it! - } - } - } -} -``` - -**Result:** Phase content matched by flexible regex patterns - ---- - -## Usage Examples - -### Basic Usage (unchanged) -```powershell -pwsh .github/skills/ai-summary-comment/scripts/post-ai-summary-comment.ps1 -PRNumber 27340 -``` - -### With Debug Mode (recommended when troubleshooting) -```powershell -pwsh -Command '$DebugPreference = "Continue"; ./.github/skills/ai-summary-comment/scripts/post-ai-summary-comment.ps1 -PRNumber 27340' -``` - -### Skip Validation -```powershell -pwsh .github/skills/ai-summary-comment/scripts/post-ai-summary-comment.ps1 -PRNumber 27340 -SkipValidation -``` - -### Dry Run -```powershell -pwsh .github/skills/ai-summary-comment/scripts/post-ai-summary-comment.ps1 -PRNumber 27340 -DryRun -``` - ---- - -## What Section Headers Work Now? - -**The script uses regex patterns, so it's VERY flexible:** - -### Pre-Flight Phase -Any title matching `'📋.*Issue Summary'` or `'📋.*Pre-Flight'`: -- ✅ "📋 Issue Summary" (preferred) -- ✅ "📋 Pre-Flight Analysis" -- ✅ "📋 Context and Issue Summary" - -### Gate Phase -Any title matching `'🚦.*Gate'`: -- ✅ "🚦 Gate - Test Verification" -- ✅ "🚦 Gate" -- ✅ "🚦 Phase 2: Gate" - -### Fix Phase -Any title matching `'🔧.*Fix'`: -- ✅ "🔧 Fix Candidates" -- ✅ "🔧 Fix Analysis" -- ✅ "🔧 Fix" - -### Report Phase -Any title matching `'📋.*Report'` or `'Final Report'`: -- ✅ "📋 Final Report" -- ✅ "📋 Phase 4: Report" -- ✅ "📋 Report - Final Recommendation" -- ✅ "Final Report" - -**The beauty:** You don't need to remember exact titles anymore! - ---- - -## Migration Guide - -**No changes needed!** The script is backward compatible. - -**Old state files** with exact headers like: -```markdown -📋 Phase 4: Report — Final Recommendation -``` - -**New state files** with simpler headers like: -```markdown -📋 Final Report -``` - -**Both work!** The dynamic extraction finds them automatically. - ---- - -## Advantages Over Old Approach - -| Aspect | Old (Pattern Matching) | New (Dynamic Extraction) | -|--------|------------------------|--------------------------| -| **Flexibility** | ❌ Hardcoded titles | ✅ Any title works | -| **Maintenance** | ❌ Update code for new headers | ✅ No code changes needed | -| **Debugging** | ⚠️ Limited visibility | ✅ Full extraction visibility | -| **Speed** | ⚠️ Tries multiple patterns | ✅ Single pass extraction | -| **Reliability** | ⚠️ Can miss variations | ✅ Finds everything | - ---- - -## Common Issues & Solutions - -### Issue: "Phase X has NO content in state file" - -**Step 1:** Enable debug mode to see what was found -```powershell -pwsh -Command '$DebugPreference = "Continue"; ./post-ai-summary-comment.ps1 -PRNumber XXXXX' -``` - -**Look for:** -``` -[DEBUG] Found 7 section(s) in state file -[DEBUG] Section: 'Your Section Title' (XXX chars) -``` - -**Step 2:** Check if your section title matches the patterns - -Report phase patterns: `'📋.*Report'`, `'Final Report'` - -If your title is `"📋 Final Analysis"`, it won't match! - -**Solution:** Either: -- Rename section to include "Report": `"📋 Final Report"` ✅ -- Or use `-SkipValidation` if content is there - ---- - -### Issue: Section extracted but content is empty - -**Cause:** State file structure issue (missing content between tags) - -**Check your markdown:** -```markdown -
-📋 Final Report - - -Your report content... - -
-``` - -**Not this:** -```markdown -
-📋 Final Report -
❌ No content! -``` - ---- - -## Developer Notes - -### How to Add Support for New Phase Patterns - -Just add a regex pattern to the mapping: - -```powershell -$reportContent = Get-SectionByPattern -Sections $allSections -Patterns @( - '📋.*Report', - 'Final Report', - 'Your New Pattern Here' # Add here -) -Debug:$debugMode -``` - -**Example:** Support "Summary" as alias for "Report": -```powershell -$reportContent = Get-SectionByPattern -Sections $allSections -Patterns @( - '📋.*Report', - '📋.*Summary', # New pattern - 'Final Report' -) -``` - ---- - -### Regex Pattern Tips - -- `.*` matches any characters -- `^` matches start of string -- `$` matches end of string -- Use `[regex]::Escape()` if you need literal special chars - -**Examples:** -- `'🚦.*Gate'` - Title must contain both 🚦 and Gate -- `'^📋 Report'` - Title must START with "📋 Report" -- `'Report$'` - Title must END with "Report" - ---- - -## Testing - -Tested with: -- ✅ PR #27340 (6 sections extracted successfully) -- ✅ Debug mode showing section discovery -- ✅ Various header formats -- ✅ Dry run mode -- ✅ Skip validation mode -- ✅ Empty sections (proper error handling) - -**Debug output example:** -``` -[DEBUG] Found 6 section(s) in state file -[DEBUG] Section: '📋 Issue Summary' (803 chars) -[DEBUG] Section: '📁 Files Changed' (0 chars) -[DEBUG] Section: '💬 PR Discussion Summary' (0 chars) -[DEBUG] Section: '🚦 Gate - Test Verification' (488 chars) -[DEBUG] Section: '🔧 Fix Candidates' (868 chars) -[DEBUG] Section: '📋 Final Report' (2351 chars) -[DEBUG] Matched '📋 Issue Summary' with pattern '📋.*Issue Summary' -[DEBUG] Matched '🚦 Gate - Test Verification' with pattern '🚦.*Gate' -[DEBUG] Matched '🔧 Fix Candidates' with pattern '🔧.*Fix' -[DEBUG] Matched '📋 Final Report' with pattern '📋.*Report' -``` - ---- - -## Future Improvements - -Potential enhancements: -- [ ] Auto-generate comment structure from discovered sections -- [ ] Support markdown headings (`##`/`###`) as alternative to `
` -- [ ] Validate section content structure (required fields) -- [ ] Suggest section renaming for better patterns -- [ ] Export sections as separate files for versioning - ---- - -## Feedback - -The dynamic extraction makes the script much more maintainable and flexible! - -If you find sections that aren't being extracted: -1. Run with `$DebugPreference = 'Continue'` to see what was found -2. Check which patterns are being used -3. Add a new pattern if needed (or rename your section) - ---- - -### 2. **Errors vs Warnings** - -**Before:** Everything was treated as a blocking error - -**After:** Two levels of feedback: -- **Errors** (❌) - Block posting (e.g., missing content, PENDING markers) -- **Warnings** (⚠️) - Suggestions only (e.g., missing optional sections) - -**Example output:** -``` -✅ All validation checks passed! - -⚠️ VALIDATION WARNINGS -Found 2 warning(s) (non-critical): - - Report: Report phase missing root cause analysis (non-critical) - - Fix: Fix phase missing 'Exhausted' field (non-critical) - -💡 These are suggestions for improvement but won't block posting. -``` - ---- - -### 3. **Debug Mode** - -**New feature:** Set `$DebugPreference = 'Continue'` to see detailed extraction information - -```powershell -$DebugPreference = 'Continue' -./post-ai-summary-comment.ps1 -PRNumber 12345 -``` - -**Debug output shows:** -``` -[DEBUG] Matched pattern for: 📋 Final Report -[DEBUG] Content length: 2355 chars -[DEBUG] First 100 chars: --- - -### Summary - -PR #27340 provides a **correct and well-tested fix**... -``` - -**Benefit:** Easy troubleshooting when validation fails - ---- - -### 4. **Better Error Messages** - -**Before:** -``` -⛔ VALIDATION FAILED -Found 1 validation error(s): - - Report: Phase Report is marked as '✅ COMPLETE' but has NO content in state file -``` - -**After:** -``` -⛔ VALIDATION FAILED -Found 1 validation error(s): - - Report: Phase Report is marked as '✅ COMPLETE' but has NO content in state file - -💡 Fix these issues in the state file before posting the review comment. - Or use -SkipValidation to bypass these checks (not recommended). - -🐛 Debug tip: Run with $DebugPreference = 'Continue' for detailed extraction info -``` - ---- - -### 5. **Relaxed Phase 4 Validation** - -**Before:** Report phase required: -- Exact "Final Recommendation" text -- "Root Cause" section -- "Key Findings" section -- "Solution Analysis" section -- Minimum 500 characters - -**After:** Report phase only requires: -- Any form of recommendation (APPROVE, REQUEST CHANGES, etc.) -- Any form of analysis (Summary, Fix Quality, etc.) -- Minimum 200 characters (reduced from 500) - -**Benefit:** More flexibility in how you structure the final report - ---- - -## Usage Examples - -### Basic Usage (unchanged) -```powershell -pwsh .github/skills/ai-summary-comment/scripts/post-ai-summary-comment.ps1 -PRNumber 27340 -``` - -### With Debug Mode -```powershell -$DebugPreference = 'Continue' -pwsh .github/skills/ai-summary-comment/scripts/post-ai-summary-comment.ps1 -PRNumber 27340 -``` - -### Skip Validation (when needed) -```powershell -pwsh .github/skills/ai-summary-comment/scripts/post-ai-summary-comment.ps1 -PRNumber 27340 -SkipValidation -``` - -### Dry Run (preview only) -```powershell -pwsh .github/skills/ai-summary-comment/scripts/post-ai-summary-comment.ps1 -PRNumber 27340 -DryRun -``` - ---- - -## What You Need to Know - -### Headers That Work Now - -Any of these variations will be recognized: - -**Pre-Flight:** -- `📋 Issue Summary` ✅ (preferred) -- `📋 Pre-Flight` ✅ -- `🔍 Pre-Flight` ✅ - -**Gate:** -- `🚦 Gate - Test Verification` ✅ (preferred) -- `🚦 Gate` ✅ -- `📋 Gate` ✅ - -**Fix:** -- `🔧 Fix Candidates` ✅ (preferred) -- `🔧 Fix` ✅ -- `📋 Fix` ✅ - -**Report:** -- `📋 Final Report` ✅ -- `📋 Phase 4: Final Report` ✅ -- `📋 Report` ✅ -- `Phase 4: Report` ✅ -- `Final Report` ✅ - ---- - -## Migration Guide - -**No changes needed!** The script is backward compatible. If you have existing state files with the old header format, they'll continue to work. - -If you want to use the new flexibility: -- Just use simpler headers like `📋 Final Report` instead of `📋 Phase 4: Report — Final Recommendation` -- The script will find it either way - ---- - -## Common Issues & Solutions - -### Issue: "Phase Report has NO content in state file" - -**Solution 1:** Check your state file structure -```bash -grep -A 5 "📋.*Report" CustomAgentLogsTmp/PRState/pr-XXXXX.md -``` - -Make sure you have: -```markdown -
-📋 Final Report - -Your report content here... - -
-``` - -**Solution 2:** Use debug mode to see what's happening -```powershell -$DebugPreference = 'Continue' -./post-ai-summary-comment.ps1 -PRNumber XXXXX -``` - -**Solution 3:** Use `-SkipValidation` if content is definitely there -```powershell -./post-ai-summary-comment.ps1 -PRNumber XXXXX -SkipValidation -``` - ---- - -### Issue: Validation warnings about missing sections - -**These are just suggestions!** Warnings won't block posting. You can: -- Ignore them (the comment will post anyway) -- Address them if you want a more complete review -- Use `-SkipValidation` to hide all validation output - ---- - -## Developer Notes - -### How Pattern Matching Works - -```powershell -function Extract-PhaseContent { - param( - [string]$StateContent, - [string[]]$PhaseTitles, # Array of possible titles - [switch]$Debug - ) - - foreach ($title in $PhaseTitles) { - $pattern = "(?s)
\s*$([regex]::Escape($title))(.*?)
" - if ($StateContent -match $pattern) { - return $Matches[1].Trim() - } - } - return $null # No match found -} -``` - -The function tries each pattern in order until one matches. - -### Adding New Pattern Variations - -To support a new header variation, just add it to the array: - -```powershell -$reportContent = Extract-PhaseContent -StateContent $Content -PhaseTitles @( - "📋 Phase 4: Report — Final Recommendation", - "📋 Phase 4: Final Report", - "📋 Phase 4: Report", - "📋 Final Report", - "📋 Report", - "Phase 4: Report", - "Final Report", - "Your New Pattern Here" # <-- Add here -) -Debug:$debugMode -``` - ---- - -## Future Improvements - -Potential enhancements: -- [ ] Auto-detect phase titles from state file (no hardcoded patterns) -- [ ] Support markdown headings (`##` / `###`) in addition to `
` -- [ ] Validate links and references work -- [ ] Check that commit SHAs are valid -- [ ] Suggest fixes for common issues (auto-fix mode) - ---- - -## Testing - -The improvements have been tested with: -- ✅ PR #27340 (Entry/Editor keyboard issue) -- ✅ State files with various header formats -- ✅ Dry run mode -- ✅ Debug mode -- ✅ Skip validation mode -- ✅ Multiple phase title variations - ---- - -## Feedback - -If you encounter issues or have suggestions, please: -1. Try debug mode first: `$DebugPreference = 'Continue'` -2. Check the state file structure -3. Report the issue with debug output included diff --git a/.github/skills/ai-summary-comment/NO-EXTERNAL-REFERENCES-RULE.md b/.github/skills/ai-summary-comment/NO-EXTERNAL-REFERENCES-RULE.md deleted file mode 100644 index 84545c9fa546..000000000000 --- a/.github/skills/ai-summary-comment/NO-EXTERNAL-REFERENCES-RULE.md +++ /dev/null @@ -1,192 +0,0 @@ -# Critical Rule: No External File References in PR Comments - -## The Problem - -When the PR agent posts review comments to GitHub, those comments are viewed by: -- PR authors -- Other reviewers -- Future contributors searching issue history -- Community members watching the PR - -**None of these people have access to your local files!** - -## The Rule - -### ❌ NEVER Do This - -```markdown -### Title & Description: ⚠️ Minor Updates Needed - -**Issues to fix:** -1. Missing required NOTE block -2. Technical inaccuracy in description - -**See:** `CustomAgentLogsTmp/PRState/27340/pr-finalize/pr-finalize-summary.md` for recommended updates -``` - -**Why this is bad:** -- GitHub users can't access `CustomAgentLogsTmp/` -- Makes the review useless - "see file I can't access" -- Author can't act on recommendations -- Future agents can't learn from the review - ---- - -### ✅ ALWAYS Do This - -```markdown -### Title & Description: ⚠️ Minor Updates Needed - -**Current description is HIGH QUALITY:** -- ✅ Clear root cause for both platforms -- ✅ Before/after videos -- ✅ Well-structured sections - -**Issues to fix:** - -**Issue 1: Missing required NOTE block** - -Add this at the top of the description: -```markdown -> [!NOTE] -> Are you waiting for the changes in this PR to be merged? -> It would be very helpful if you could [test the resulting artifacts](https://github.com/dotnet/maui/wiki/Testing-PR-Builds) from this PR and let us know in a comment if this change resolves your issue. Thank you! -``` - -**Issue 2: Technical inaccuracy in "Description of Change"** - -Current text says: -> "Added platform-specific handling to dismiss the soft keyboard **and remove focus**..." - -Should say: -> "Added platform-specific handling to dismiss the soft keyboard when the Entry or Editor visibility is set to False." - -**Reason:** The code only calls `HideSoftInputAsync()` to dismiss the keyboard. It does NOT call `Unfocus()` to remove focus. Focus state remains unchanged. - -**Recommended additions:** - -Add an **Implementation** subsection: -```markdown -**Implementation:** -- Added `MapIsVisible` handler in `InputView.Platform.cs` (iOS/Android only) -- Calls `HideSoftInputAsync()` when control becomes invisible and keyboard is showing -- Registered handler in `Entry.Mapper.cs` and `Editor.Mapper.cs` -``` -``` - -**Why this is good:** -- ✅ Self-contained - everything needed is in the comment -- ✅ Actionable - author can copy/paste the fixes -- ✅ Clear - shows exact before/after text -- ✅ Educational - explains the reasoning -- ✅ Accessible - works on GitHub - ---- - -## Where This Applies - -### pr-finalize Skill - -When running `pr-finalize` skill, you create TWO outputs: - -1. **Summary file** (local reference) - - Location: `CustomAgentLogsTmp/PRState/XXXXX/pr-finalize/pr-finalize-summary.md` - - Purpose: Your detailed analysis and working notes - - Audience: You and local CLI users - -2. **State file Report section** (GitHub audience) - - Location: `CustomAgentLogsTmp/PRState/pr-XXXXX.md` (Report phase) - - Purpose: Final recommendations that get posted to GitHub - - Audience: PR authors, reviewers, community - - **MUST be self-contained** - no external references - -### PR Agent Phase 4 (Report) - -When completing Phase 4: -- Include ALL pr-finalize findings inline -- Show exact code blocks for NOTE block -- Show exact before/after text for corrections -- Explain reasoning for each recommendation -- Never reference local files - ---- - -## Examples from Real Usage - -### ❌ Bad Example (PR #27340 - first attempt) - -```markdown -**Issues to fix:** -1. **Missing required NOTE block** (mandatory for all PRs) -2. **Minor technical inaccuracy:** Description says "remove focus" but code only dismisses keyboard - -**See:** `CustomAgentLogsTmp/PRState/27340/pr-finalize/pr-finalize-summary.md` for recommended updates -``` - -**Result:** PR author sees the issues but has no idea how to fix them without accessing local files. - ---- - -### ✅ Good Example (PR #27340 - corrected) - -```markdown -**Issues to fix:** - -**Issue 1: Missing required NOTE block** - -Add this at the top of the description: -```markdown -> [!NOTE] -> Are you waiting for the changes in this PR to be merged? -> It would be very helpful if you could [test the resulting artifacts](https://github.com/dotnet/maui/wiki/Testing-PR-Builds) from this PR and let us know in a comment if this change resolves your issue. Thank you! -``` - -**Issue 2: Technical inaccuracy in "Description of Change"** - -Current text says: -> "Added platform-specific handling to dismiss the soft keyboard **and remove focus**..." - -Should say: -> "Added platform-specific handling to dismiss the soft keyboard when the Entry or Editor visibility is set to False." - -**Reason:** The code only calls `HideSoftInputAsync()` to dismiss the keyboard. It does NOT call `Unfocus()` to remove focus. - -**Recommended additions to description:** - -Add an **Implementation** subsection after "Description of Change": -```markdown -**Implementation:** -- Added `MapIsVisible` handler in `InputView.Platform.cs` (iOS/Android only) -- Calls `HideSoftInputAsync()` when control becomes invisible and keyboard is showing -- Registered handler in `Entry.Mapper.cs` and `Editor.Mapper.cs` -``` -``` - -**Result:** PR author can immediately act on every recommendation with clear guidance. - ---- - -## Checklist for Report Phase - -When completing Phase 4, verify: - -- [ ] All recommendations are inline (no file references) -- [ ] Code blocks show exact text to add -- [ ] Before/after comparisons for corrections -- [ ] Reasoning explained for each issue -- [ ] Examples are copy/paste ready -- [ ] No references to `CustomAgentLogsTmp/` - ---- - -## Quick Reference - -| What | Where | Audience | Self-Contained? | -|------|-------|----------|-----------------| -| Summary file | `CustomAgentLogsTmp/.../summary.md` | Local CLI | N/A (local only) | -| State file | `CustomAgentLogsTmp/PRState/pr-XXXXX.md` | GitHub users | ✅ YES - REQUIRED | -| PR comment | GitHub PR page | Public | ✅ YES - REQUIRED | - -**Remember:** Anything that goes in the state file's `
` sections will be posted to GitHub. Make it self-contained! - --- diff --git a/.github/skills/ai-summary-comment/SKILL.md b/.github/skills/ai-summary-comment/SKILL.md deleted file mode 100644 index 9073d3bef37c..000000000000 --- a/.github/skills/ai-summary-comment/SKILL.md +++ /dev/null @@ -1,558 +0,0 @@ ---- -name: ai-summary-comment -description: Posts or updates automated progress comments on GitHub PRs. Use after completing any PR agent phase (pre-flight, tests, gate, fix, report). Triggers on 'post comment to PR', 'update PR progress', 'comment on PR with results', 'post pre-flight comment'. Creates single aggregated review comment with collapsible sections per commit. -metadata: - author: dotnet-maui - version: "5.0" -compatibility: Requires GitHub CLI (gh) authenticated with access to dotnet/maui repository. ---- - -# PR Comment Skill - -This skill posts automated progress comments to GitHub Pull Requests during the PR review workflow. Comments are **self-contained** with collapsible Review Session details, providing rich context to maintainers and contributors. - -**⚠️ Self-Contained Rule**: All content in PR comments must be self-contained. Never reference local files like `CustomAgentLogsTmp/` - GitHub users cannot access your local filesystem. - -**✨ Key Features**: -- **Single Unified Comment**: ONE comment per PR/Issue containing ALL sections (PR Review, Try-Fix, Write-Tests, Verify-Tests) -- **Section-Based Updates**: Each script updates only its section, preserving others -- **Duplicate Prevention**: Finds existing `` comment and updates it -- **File-Based DryRun Preview**: Use `-DryRun` to preview changes in a local file before posting -- **Auto-Loading State Files**: Automatically finds and loads state files from `CustomAgentLogsTmp/PRState/` -- **Simple Interface**: Just provide PR number - script handles everything else - -## Comment Architecture - -### Unified AI Summary Comment - -Most scripts post to the **same single comment** identified by ``. Each script updates its own section: - -```markdown - - -## 🤖 AI Summary - - -... PR review phases ... - - - -... try-fix attempts ... - - - -... write-tests attempts ... - - - -... test verification results ... - -``` - -**Behavior:** -- First script to run creates the comment -- Subsequent scripts find the existing comment and update/add their section -- Sections are independent - updating one preserves others - -### Separate PR Finalization Comment - -The `post-pr-finalize-comment.ps1` script posts a **separate comment** identified by ``. This comment contains three sections: -- **Title**: Shows the current vs recommended PR title -- **Description**: Shows description assessment, missing elements, and **recommended description** -- **Code Review**: Shows code review findings (critical issues, suggestions, positive observations) - -If an existing finalize comment exists, it will be replaced with the updated sections. This keeps finalization reviews distinct from automated analysis. - -**⚠️ Important Requirements for PR Finalize Comments:** -- When `TitleStatus` is `NeedsUpdate`, **always provide** `-RecommendedTitle` -- When `DescriptionStatus` is `NeedsUpdate` or `NeedsRewrite`, **always provide** `-RecommendedDescription` with the full suggested description text -- The script will warn if these are missing but won't fail - -## Section Scripts - -### AI Summary Sections (Unified Comment) - -| Section | Script | Location | -|---------|--------|----------| -| `PR-REVIEW` | `post-ai-summary-comment.ps1` | `.github/skills/ai-summary-comment/scripts/` | -| `TRY-FIX` | `post-try-fix-comment.ps1` | `.github/skills/ai-summary-comment/scripts/` | -| `WRITE-TESTS` | `post-write-tests-comment.ps1` | `.github/skills/ai-summary-comment/scripts/` | -| `VERIFY-TESTS` | `post-verify-tests-comment.ps1` | `.github/skills/ai-summary-comment/scripts/` | - -### Separate Comments - -| Comment | Script | Marker | -|---------|--------|--------| -| PR Finalization | `post-pr-finalize-comment.ps1` | `` | - -## Supported Phases - -| Phase | Description | When to Post | What This Enables Next | -|-------|-------------|--------------|------------------------| -| `pre-flight` | Context gathering complete | After documenting issue, files, and discussion | **Tests Phase**: Agent can now verify/create test files that reproduce the bug | -| `tests` | Test analysis complete | After identifying test files and coverage | **Gate Phase**: Agent can run tests to verify they catch the bug | -| `gate` | Test validation complete | After running tests and verifying bug reproduction | **Fix Phase**: Agent can explore alternative fixes (tests proven to catch bug) | -| `fix` | Solution comparison complete | After comparing PR fix with alternatives | **Report Phase**: Agent can finalize recommendation based on fix comparison | -| `report` | Final analysis complete | After generating comprehensive review | **PR Decision**: Maintainers can approve/merge or request changes based on full analysis | - -## Usage - -### Simplest: Just provide PR number - -```bash -# Auto-loads CustomAgentLogsTmp/PRState/pr-27246.md -pwsh .github/skills/ai-summary-comment/scripts/post-ai-summary-comment.ps1 -PRNumber 27246 -``` - -### With explicit state file path - -```bash -# PRNumber auto-extracted from filename (pr-27246.md → 27246) -pwsh .github/skills/ai-summary-comment/scripts/post-ai-summary-comment.ps1 -StateFile CustomAgentLogsTmp/PRState/pr-27246.md -``` - -### Legacy: Provide content directly - -```bash -pwsh .github/skills/ai-summary-comment/scripts/post-ai-summary-comment.ps1 -PRNumber 12345 -Content "$(cat CustomAgentLogsTmp/PRState/pr-12345.md)" -``` - -### Parameters - -| Parameter | Required | Description | Example | -|-----------|----------|-------------|---------| -| `PRNumber` | No* | Pull request number | `12345` | -| `StateFile` | No* | Path to state file (PRNumber auto-extracted from `pr-XXXXX.md` naming) | `CustomAgentLogsTmp/PRState/pr-27246.md` | -| `Content` | No* | Full state file content (legacy, can be piped via stdin) | Content from state file | -| `DryRun` | No | Preview changes in local file instead of posting to GitHub | `-DryRun` | -| `PreviewFile` | No | Path to local preview file for DryRun mode (default: `CustomAgentLogsTmp/PRState/{PRNumber}/ai-summary-comment-preview.md`) | `-PreviewFile ./preview.md` | -| `SkipValidation` | No | Skip validation checks (not recommended) | `-SkipValidation` | - -*At least one of PRNumber, StateFile, or Content is required. The script will: -- If `-PRNumber` provided: Auto-load `CustomAgentLogsTmp/PRState/pr-{PRNumber}.md` -- If `-StateFile` provided: Load the file and extract PRNumber from `pr-XXXXX.md` filename -- If `-Content` provided: Use content directly (legacy, requires `-PRNumber`) - -## DryRun Preview Workflow - -Use `-DryRun` to preview the combined comment before posting to GitHub. Each script updates the same preview file, mirroring how the actual GitHub comment is updated. - -```bash -# Step 1: Run verify-tests script (creates preview file) -pwsh .github/skills/ai-summary-comment/scripts/post-verify-tests-comment.ps1 -PRNumber 32891 -DryRun - -# Step 2: Run try-fix script (updates same preview file) -pwsh .github/skills/ai-summary-comment/scripts/post-try-fix-comment.ps1 -IssueNumber 32891 -DryRun - -# Step 3: Review the combined preview -open CustomAgentLogsTmp/PRState/32891/ai-summary-comment-preview.md - -# Step 4: Post for real (remove -DryRun) -pwsh .github/skills/ai-summary-comment/scripts/post-verify-tests-comment.ps1 -PRNumber 32891 -pwsh .github/skills/ai-summary-comment/scripts/post-try-fix-comment.ps1 -IssueNumber 32891 -``` - -**Key behavior:** The preview file exactly matches what will be posted to GitHub. Multiple scripts accumulate their sections in the same file. - -### Section Ordering - -Sections appear in the unified comment in this order (based on which scripts run first): -1. **VERIFY-TESTS** - Test verification results -2. **TRY-FIX** - Alternative fix exploration attempts -3. **WRITE-TESTS** - Test writing attempts -4. **PR-REVIEW** - PR review phases - -Each section is wrapped with markers like `` and ``. - -### Cleanup - -To reset the preview file for a fresh start: -```bash -rm CustomAgentLogsTmp/PRState/{PRNumber}/ai-summary-comment-preview.md -``` - -### Prerequisites - -Scripts require GitHub CLI authentication: -```bash -gh auth status # Verify authentication before running -``` - -## Comment Format - -Comments are formatted with: -- **Phase badge** (🔍 Pre-Flight, 🚦 Gate, 🔧 Fix, 📋 Report) -- **Status indicator** (✅ Completed, ⚠️ Issues Found) -- **Expandable review sessions** (each session is a collapsible section) -- **What's Next** (what phase happens next) - -### Review Session Tracking - -When the same PR is reviewed multiple times (e.g., after new commits), the script **updates the single aggregated review comment** and adds a new expandable section for each commit-based review session. - -### Example Output - -```markdown -## 🔍 Pre-Flight: Context Gathering Complete - -✅ **Status**: Phase completed successfully - -### Summary -- **Issue**: #33356 - CollectionView crash on iOS -- **Platforms Affected**: iOS, MacCatalyst -- **Files Changed**: 2 implementation files, 1 test file -- **Discussion**: 3 key reviewer comments identified - -### Key Findings -- Crash occurs when scrolling rapidly with large datasets -- Existing PR adds null check in ItemsViewController -- Test coverage includes iOS device test - -### Next Steps -→ **Phase 2: Gate** - Verifying tests catch the bug - ---- -*Posted by PR Agent @ 2026-01-17 14:23:45 UTC* -``` - -## Script Files - -- [`post-ai-summary-comment.ps1`](scripts/post-ai-summary-comment.ps1) - Posts or updates the aggregated PR agent review comment -- [`post-try-fix-comment.ps1`](scripts/post-try-fix-comment.ps1) - Posts or updates try-fix attempts comment - -## Try-Fix Comment Script - -The `post-try-fix-comment.ps1` script updates the `` section of the unified AI Summary comment. It aggregates all try-fix attempts into collapsible sections. Works for both issues and PRs (GitHub treats PR comments as issue comments). - -**✨ Auto-Loading from `CustomAgentLogsTmp`**: The script automatically discovers and aggregates ALL attempt directories from `CustomAgentLogsTmp/PRState/{IssueNumber}/try-fix/`. - -### Usage - -#### Simplest: Provide attempt directory - -```powershell -# All parameters auto-loaded from directory structure -pwsh .github/skills/ai-summary-comment/scripts/post-try-fix-comment.ps1 ` - -TryFixDir CustomAgentLogsTmp/PRState/27246/try-fix/attempt-1 -``` - -#### Or just provide issue number - -```powershell -# Auto-discovers and posts latest attempt from CustomAgentLogsTmp/PRState/27246/try-fix/ -pwsh .github/skills/ai-summary-comment/scripts/post-try-fix-comment.ps1 -IssueNumber 27246 -``` - -#### Legacy: Manual parameters - -```powershell -pwsh .github/skills/ai-summary-comment/scripts/post-try-fix-comment.ps1 ` - -IssueNumber 19806 ` - -AttemptNumber 1 ` - -Approach "LayoutExtensions Width Constraint" ` - -RootCause "ComputeFrame only constrains width for Fill alignment" ` - -FilesChanged "| File | Changes |`n|------|---------|`n| LayoutExtensions.cs | +17/-3 |" ` - -Status "Compiles" ` - -CodeSnippet "else if (!hasExplicitWidth) { ... }" ` - -Analysis "Core project compiles successfully" -``` - -### Parameters - -| Parameter | Required | Description | -|-----------|----------|-------------| -| `TryFixDir` | No* | Path to try-fix attempt directory (auto-loads all parameters) | -| `IssueNumber` | No* | Issue or PR number to post comment on | -| `AttemptNumber` | No* | Attempt number (1, 2, 3, etc.) - auto-detected from TryFixDir | -| `Approach` | No* | Brief description of fix approach | -| `RootCause` | No | Description of root cause identified | -| `FilesChanged` | No* | Markdown table of files changed - auto-generated from diff | -| `Status` | No* | "Compiles", "Pass", or "Fail" - loaded from result.txt | -| `CodeSnippet` | No | Code snippet showing the fix - loaded from fix.diff | -| `Analysis` | No | Analysis of why it worked/failed - loaded from analysis.md | -| `DryRun` | No | Print comment instead of posting | - -*When using `-TryFixDir`, all marked parameters are auto-loaded from files in the directory. - -### Expected Directory Structure - -``` -CustomAgentLogsTmp/PRState/{IssueNumber}/try-fix/ -├── attempt-1/ -│ ├── approach.md # Brief description of the approach (required) -│ ├── result.txt # "Pass", "Fail", or "Compiles" (required) -│ ├── fix.diff # Git diff of the fix (optional) -│ └── analysis.md # Detailed analysis (optional) -├── attempt-2/ -│ └── ... -└── attempt-3/ - └── ... -``` - -### Comment Format - -```markdown -## 🔧 Try-Fix Attempts for Issue #XXXXX - - - -
-📊 Expand Full Details - -**Issue:** [#XXXXX](link) - ---- - -
-🔧 Attempt #1: Approach Name ✅ Status -... attempt details ... -
- ---- - -*This fix was developed independently.* - -
-``` - -### Key Behaviors - -- First attempt creates new comment with `` marker -- Subsequent attempts **edit the same comment** (no new comments) -- Outer wrapper shows "📊 Expand Full Details" - keeps PR page clean -- Each attempt is a nested collapsible section inside the wrapper - ---- - -## Verify-Tests Comment Script - -The `post-verify-tests-comment.ps1` script updates the `` section of the unified AI Summary comment. It documents test verification results (whether tests fail without fix and pass with fix). - -**✨ Auto-Loading from `CustomAgentLogsTmp`**: The script automatically loads verification results from `CustomAgentLogsTmp/PRState/{PRNumber}/verify-tests-fail/verification-report.md`. - -### Usage - -#### Simplest: Provide PR number - -```powershell -# Auto-loads from CustomAgentLogsTmp/PRState/{PRNumber}/verify-tests-fail/ -pwsh .github/skills/ai-summary-comment/scripts/post-verify-tests-comment.ps1 -PRNumber 32891 -``` - -#### With explicit report file - -```powershell -pwsh .github/skills/ai-summary-comment/scripts/post-verify-tests-comment.ps1 ` - -PRNumber 32891 ` - -ReportFile CustomAgentLogsTmp/PRState/32891/verify-tests-fail/verification-report.md -``` - -### Parameters - -| Parameter | Required | Description | -|-----------|----------|-------------| -| `PRNumber` | Yes | Pull request number | -| `ReportFile` | No | Path to verification report (auto-discovered if not provided) | -| `Status` | No | "Passed" or "Failed" - auto-detected from report | -| `Platform` | No | Platform tested (ios, android, etc.) - auto-detected from report | -| `Mode` | No | "FailureOnly" or "FullVerification" - auto-detected from report | -| `DryRun` | No | Preview changes in local file instead of posting | -| `PreviewFile` | No | Path to local preview file for DryRun mode | - -### Expected Directory Structure - -``` -CustomAgentLogsTmp/PRState/{PRNumber}/verify-tests-fail/ -├── verification-report.md # Full verification report (required) -├── verification-log.txt # Detailed log (optional) -├── test-without-fix.log # Test output without fix (optional) -└── test-with-fix.log # Test output with fix (optional) -``` - ---- - -## Write-Tests Comment Script - -The `post-write-tests-comment.ps1` script updates the `` section of the unified AI Summary comment. It documents test writing attempts for an issue. - -**✨ Auto-Loading from `CustomAgentLogsTmp`**: The script can automatically load test details from the write-tests output directory structure. - -### Usage - -#### Simplest: Provide test directory - -```powershell -# All parameters auto-loaded from directory structure -pwsh .github/skills/ai-summary-comment/scripts/post-write-tests-comment.ps1 ` - -TestDir CustomAgentLogsTmp/PRState/27246/write-tests/attempt-1 -``` - -#### Or just provide issue number - -```powershell -# Auto-discovers and posts latest attempt from CustomAgentLogsTmp/PRState/27246/write-tests/ -pwsh .github/skills/ai-summary-comment/scripts/post-write-tests-comment.ps1 -IssueNumber 27246 -``` - -#### Legacy: Manual parameters - -```powershell -pwsh .github/skills/ai-summary-comment/scripts/post-write-tests-comment.ps1 ` - -IssueNumber 33331 ` - -AttemptNumber 1 ` - -TestDescription "Verifies Picker.IsOpen property changes correctly" ` - -HostAppFile "src/Controls/tests/TestCases.HostApp/Issues/Issue33331.cs" ` - -TestFile "src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue33331.cs" ` - -TestMethod "PickerIsOpenPropertyChanges" ` - -Category "Picker" ` - -VerificationStatus "Verified" -``` - -### Parameters - -| Parameter | Required | Description | -|-----------|----------|-------------| -| `TestDir` | No* | Path to write-tests attempt directory (auto-loads all parameters) | -| `IssueNumber` | No* | Issue or PR number to post comment on | -| `AttemptNumber` | No* | Attempt number (1, 2, 3, etc.) - auto-detected from TestDir | -| `TestDescription` | No* | Brief description of what the test verifies | -| `HostAppFile` | No* | Path to the HostApp test page file | -| `TestFile` | No* | Path to the NUnit test file | -| `TestMethod` | No* | Name of the test method | -| `Category` | No* | UITestCategories category used | -| `VerificationStatus` | No* | "Verified", "Failed", or "Unverified" - loaded from result.txt | -| `Platforms` | No | Platforms the test runs on (default: "All") | -| `Notes` | No | Additional notes - loaded from notes.md | -| `DryRun` | No | Print comment instead of posting | - -*When using `-TestDir`, all marked parameters are auto-loaded from files in the directory. - -### Expected Directory Structure - -``` -CustomAgentLogsTmp/PRState/{IssueNumber}/write-tests/ -├── attempt-1/ -│ ├── description.md # Brief test description (required) -│ ├── test-info.json # {HostAppFile, TestFile, TestMethod, Category} (required) -│ ├── result.txt # "Verified", "Pass", "Failed", or "Unverified" (required) -│ └── notes.md # Additional notes (optional) -├── attempt-2/ -│ └── ... -└── attempt-3/ - └── ... -``` - -### test-info.json Format - -```json -{ - "HostAppFile": "src/Controls/tests/TestCases.HostApp/Issues/Issue27246.cs", - "TestFile": "src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue27246.cs", - "TestMethod": "ScrollToFirstItemWithHeader", - "Category": "CollectionView" -} -``` - ---- - -## PR Finalize Comment Script - -The `post-pr-finalize-comment.ps1` script posts a **separate comment** (not part of the unified AI Summary) specifically for PR finalization reviews. It provides structured feedback on the PR title, description, and code review findings. - -### Usage - -#### Simplest: Just provide PR number (auto-loads from summary file) - -```powershell -# Auto-loads from CustomAgentLogsTmp/PRState/{PRNumber}/pr-finalize/pr-finalize-summary.md -pwsh .github/skills/ai-summary-comment/scripts/post-pr-finalize-comment.ps1 -PRNumber 33892 -``` - -#### Full manual parameters (recommended for best results) - -```powershell -pwsh .github/skills/ai-summary-comment/scripts/post-pr-finalize-comment.ps1 ` - -PRNumber 33892 ` - -TitleStatus "NeedsUpdate" ` - -CurrentTitle "Fix 32650 Image Orientation" ` - -RecommendedTitle "[iOS][Android] MediaPicker: Fix image orientation when RotateImage=true" ` - -TitleIssues "- Missing platform tags -- Doesn't describe the behavior fix" ` - -DescriptionStatus "NeedsUpdate" ` - -DescriptionAssessment "The current description is minimal and missing: -- ❌ Missing NOTE block for testing artifacts -- ❌ No root cause analysis -- ❌ No technical details" ` - -MissingElements "Add the NOTE block, root cause, and technical details." ` - -RecommendedDescription "> [!NOTE] -> Are you waiting for this PR? Test it: [Testing PR Builds](link) - -### Root Cause -...description... - -### Description of Change -...details..." ` - -CodeReviewStatus "IssuesFound" ` - -CodeReviewFindings "### 🔴 Critical Issues -**1. Broken indentation** -- File: \`src/file.cs\` -- Problem: Inconsistent tabs/spaces - -### 🟡 Suggestions -1. Consider disposing Matrix object - -### ✅ Looks Good -- Proper cleanup in finally block" -``` - -### Parameters - -| Parameter | Required | Description | -|-----------|----------|-------------| -| `PRNumber` | Yes* | Pull request number | -| `SummaryFile` | No | Path to pr-finalize-summary.md (auto-discovered) | -| `TitleStatus` | No* | `Good` or `NeedsUpdate` | -| `CurrentTitle` | No* | Current PR title (fetched from GitHub if not provided) | -| `RecommendedTitle` | No | **Required if TitleStatus is NeedsUpdate** | -| `TitleIssues` | No | List of issues with current title | -| `DescriptionStatus` | No* | `Excellent`, `Good`, `NeedsUpdate`, or `NeedsRewrite` | -| `DescriptionAssessment` | Yes | Assessment of description quality | -| `MissingElements` | No | What's missing from the description | -| `RecommendedDescription` | No | **Required if DescriptionStatus is NeedsUpdate/NeedsRewrite** | -| `CodeReviewStatus` | No | `Passed`, `IssuesFound`, or `Skipped` | -| `CodeReviewFindings` | No | Markdown content for code review section | -| `DryRun` | No | Preview instead of posting | - -*At least PRNumber or SummaryFile required. Script auto-detects values when possible. - -### ⚠️ Common Mistakes to Avoid - -1. **Missing RecommendedTitle when TitleStatus is NeedsUpdate** - - The script will warn but still post - always provide a recommended title - -2. **Missing RecommendedDescription when DescriptionStatus is NeedsUpdate** - - Users need to see what the description SHOULD look like - -3. **Code review findings not starting with proper headers** - - Always structure with `### 🔴 Critical Issues`, `### 🟡 Suggestions`, `### ✅ Looks Good` - -4. **Auto-parsing from summary file getting confused** - - When in doubt, provide explicit parameters instead of relying on auto-parsing - -### Expected Directory Structure for Auto-Loading - -``` -CustomAgentLogsTmp/PRState/{PRNumber}/pr-finalize/ -├── pr-finalize-summary.md # Main summary (auto-parsed) -├── recommended-description.md # Full recommended description (optional) -└── code-review.md # Code review findings (optional) -``` - ---- - -## Technical Details - -- Comments identified by HTML marker `` -- Existing comments are updated (not duplicated) when posting again -- Review sessions grouped by commit SHA -- Uses `gh api` for create/update operations diff --git a/.github/skills/ai-summary-comment/scripts/post-pr-finalize-comment.ps1 b/.github/skills/ai-summary-comment/scripts/post-pr-finalize-comment.ps1 deleted file mode 100644 index 2dd2919d2b16..000000000000 --- a/.github/skills/ai-summary-comment/scripts/post-pr-finalize-comment.ps1 +++ /dev/null @@ -1,653 +0,0 @@ -#!/usr/bin/env pwsh -<# -.SYNOPSIS - Posts or updates a PR finalize comment on a GitHub Pull Request. - -.DESCRIPTION - Creates ONE comment for PR finalization with three collapsible sections: Title, Description, and Code Review. - Uses HTML marker for identification. - - **Auto-loads from CustomAgentLogsTmp/PRState/{PRNumber}/pr-finalize/** - - If an existing finalize comment exists, it will be REPLACED with the new content. - Otherwise, a new comment will be created. - - Format: - ## 📋 PR Finalization Review - - -
- Title: ✅ Good - ... title details ... -
- -
- Description: ⚠️ Needs Update - ... description details ... -
- -
- Code Review: ✅ Passed - ... code review findings ... -
- -.PARAMETER PRNumber - The PR number to post comment on (required unless SummaryFile provided) - -.PARAMETER SummaryFile - Path to pr-finalize-summary.md file. If provided, auto-loads review data from this file. - -.PARAMETER TitleStatus - Title assessment: "Good", "NeedsUpdate" (required unless loading from SummaryFile) - -.PARAMETER CurrentTitle - Current PR title (required unless loading from SummaryFile) - -.PARAMETER RecommendedTitle - Recommended PR title (optional - only if TitleStatus is NeedsUpdate) - -.PARAMETER TitleIssues - List of issues with the current title (optional - only if TitleStatus is NeedsUpdate) - -.PARAMETER DescriptionStatus - Description assessment: "Excellent", "Good", "NeedsUpdate", "NeedsRewrite" (required unless loading from SummaryFile) - -.PARAMETER DescriptionAssessment - Quality assessment of the description (required unless loading from SummaryFile) - -.PARAMETER MissingElements - List of missing elements (optional) - -.PARAMETER RecommendedDescription - Full recommended description (optional - only if needs rewrite) - -.PARAMETER CodeReviewStatus - Code review assessment: "Passed", "IssuesFound", "Skipped" (optional - defaults to "Skipped" if not provided) - -.PARAMETER CodeReviewFindings - Code review findings content (optional - markdown with critical issues, suggestions, and positive observations) - -.PARAMETER DryRun - Print comment instead of posting - -.EXAMPLE - # Simplest: Just provide PR number (auto-loads from CustomAgentLogsTmp) - ./post-pr-finalize-comment.ps1 -PRNumber 27246 - -.EXAMPLE - # Or provide summary file path - ./post-pr-finalize-comment.ps1 -SummaryFile CustomAgentLogsTmp/PRState/27246/pr-finalize/pr-finalize-summary.md - -.EXAMPLE - # Manual parameters - ./post-pr-finalize-comment.ps1 -PRNumber 25748 ` - -TitleStatus "Good" ` - -CurrentTitle "[iOS] Fix SafeArea padding calculation" ` - -DescriptionStatus "Good" ` - -DescriptionAssessment "Clear structure, accurate technical details, matches implementation" ` - -CodeReviewStatus "Passed" ` - -CodeReviewFindings "No critical issues found. Code follows best practices." -#> - -param( - [Parameter(Mandatory=$false)] - [int]$PRNumber, - - [Parameter(Mandatory=$false)] - [string]$SummaryFile, - - [Parameter(Mandatory=$false)] - [ValidateSet("Good", "NeedsUpdate", "")] - [string]$TitleStatus, - - [Parameter(Mandatory=$false)] - [string]$CurrentTitle, - - [Parameter(Mandatory=$false)] - [string]$RecommendedTitle, - - [Parameter(Mandatory=$false)] - [string]$TitleIssues, - - [Parameter(Mandatory=$false)] - [ValidateSet("Excellent", "Good", "NeedsUpdate", "NeedsRewrite", "")] - [string]$DescriptionStatus, - - [Parameter(Mandatory=$false)] - [string]$DescriptionAssessment, - - [Parameter(Mandatory=$false)] - [string]$MissingElements, - - [Parameter(Mandatory=$false)] - [string]$RecommendedDescription, - - [Parameter(Mandatory=$false)] - [ValidateSet("Passed", "IssuesFound", "Skipped", "")] - [string]$CodeReviewStatus, - - [Parameter(Mandatory=$false)] - [string]$CodeReviewFindings, - - [Parameter(Mandatory=$false)] - [switch]$DryRun, - - [Parameter(Mandatory=$false)] - [string]$PreviewFile -) - -$ErrorActionPreference = "Stop" - -Write-Host "╔═══════════════════════════════════════════════════════════╗" -ForegroundColor Cyan -Write-Host "║ PR Finalize Comment (Post/Update) ║" -ForegroundColor Cyan -Write-Host "╚═══════════════════════════════════════════════════════════╝" -ForegroundColor Cyan - -# ============================================================================ -# AUTO-DISCOVERY FROM SUMMARY FILE -# ============================================================================ - -# If PRNumber provided but no SummaryFile, try to find it -if ($PRNumber -gt 0 -and [string]::IsNullOrWhiteSpace($SummaryFile)) { - $summaryPath = "CustomAgentLogsTmp/PRState/$PRNumber/pr-finalize/pr-finalize-summary.md" - if (-not (Test-Path $summaryPath)) { - $repoRoot = git rev-parse --show-toplevel 2>$null - if ($repoRoot) { - $summaryPath = Join-Path $repoRoot "CustomAgentLogsTmp/PRState/$PRNumber/pr-finalize/pr-finalize-summary.md" - } - } - - if (Test-Path $summaryPath) { - $SummaryFile = $summaryPath - Write-Host "ℹ️ Auto-discovered summary file: $SummaryFile" -ForegroundColor Cyan - } -} - -# If SummaryFile provided, parse it -if (-not [string]::IsNullOrWhiteSpace($SummaryFile)) { - if (-not (Test-Path $SummaryFile)) { - throw "Summary file not found: $SummaryFile" - } - - $content = Get-Content $SummaryFile -Raw -Encoding UTF8 - Write-Host "ℹ️ Loading from summary file: $SummaryFile" -ForegroundColor Cyan - - # Extract PRNumber from path if not provided - if ($PRNumber -eq 0 -and $SummaryFile -match '[/\\](\d+)[/\\]pr-finalize') { - $PRNumber = [int]$Matches[1] - Write-Host "ℹ️ Auto-detected PRNumber: $PRNumber from path" -ForegroundColor Cyan - } - - # Extract Recommended Title FIRST (needed for TitleStatus detection) - if ([string]::IsNullOrWhiteSpace($RecommendedTitle)) { - # Try different patterns - if ($content -match '\*\*Recommended.*?Title.*?\*\*:?\s*[`"]?([^`"\n]+)[`"]?') { - $RecommendedTitle = $Matches[1].Trim() - } elseif ($content -match 'Recommended:\s*`([^`]+)`') { - $RecommendedTitle = $Matches[1].Trim() - } elseif ($content -match '(?s)\*\*Recommended:\*\*\s*```\s*([^\n]+)') { - # Code fence format - $RecommendedTitle = $Matches[1].Trim() - } elseif ($content -match '(?s)### 📋 Title Assessment.+?\*\*Recommended:\*\*\s*```\s*([^\n]+)') { - $RecommendedTitle = $Matches[1].Trim() - } - if ($RecommendedTitle) { - Write-Host "ℹ️ Extracted RecommendedTitle: $RecommendedTitle" -ForegroundColor Cyan - } - } - - # Extract Title assessment - if RecommendedTitle exists, title needs update - if ([string]::IsNullOrWhiteSpace($TitleStatus)) { - # If we have a recommended title, the title needs update - if (-not [string]::IsNullOrWhiteSpace($RecommendedTitle)) { - $TitleStatus = "NeedsUpdate" - } - # Look for explicit status in Title Assessment section - elseif ($content -match '(?s)### 📋 Title Assessment.+?\*\*Status:\*\*\s*(✅|❌|⚠️)?\s*(Good|NeedsUpdate|Needs Update)') { - $statusMatch = $Matches[2] -replace '\s+', '' - if ($statusMatch -eq "Good") { - $TitleStatus = "Good" - } else { - $TitleStatus = "NeedsUpdate" - } - } - # Fallback: check for recommended title specifically in title section - elseif ($content -match '(?s)### 📋 Title Assessment.+?\*\*Recommended.*?Title') { - $TitleStatus = "NeedsUpdate" - } else { - $TitleStatus = "Good" - } - Write-Host "ℹ️ Detected TitleStatus: $TitleStatus" -ForegroundColor Cyan - } - - # Extract Current Title - if ([string]::IsNullOrWhiteSpace($CurrentTitle)) { - if ($content -match '\*\*Current.*?Title.*?\*\*:?\s*[`"]?([^`"\n]+)[`"]?') { - $CurrentTitle = $Matches[1].Trim() - } elseif ($content -match 'Current:\s*`([^`]+)`') { - $CurrentTitle = $Matches[1].Trim() - } - if ($CurrentTitle) { - Write-Host "ℹ️ Extracted CurrentTitle: $CurrentTitle" -ForegroundColor Cyan - } - } - - # Extract Description assessment - if ([string]::IsNullOrWhiteSpace($DescriptionStatus)) { - if ($content -match 'Description.*?Excellent|Excellent.*?description') { - $DescriptionStatus = "Excellent" - } elseif ($content -match 'Description.*?Good|Good.*?description') { - $DescriptionStatus = "Good" - } elseif ($content -match 'Needs\s*Rewrite|NeedsRewrite') { - $DescriptionStatus = "NeedsRewrite" - } elseif ($content -match 'Needs\s*Update|NeedsUpdate') { - $DescriptionStatus = "NeedsUpdate" - } else { - $DescriptionStatus = "Good" - } - Write-Host "ℹ️ Detected DescriptionStatus: $DescriptionStatus" -ForegroundColor Cyan - } - - # Extract Title Issues/Reasoning - $titleIssues = "" - if ([string]::IsNullOrWhiteSpace($RecommendedTitle)) { - # No recommended title means title is good - $titleIssues = "Title is clear and follows conventions." - } else { - # Extract the issues list - if ($content -match '(?s)\*\*Issues:\*\*(.+?)(?=\*\*Recommended|\*\*Reasoning|###|$)') { - $titleIssues = $Matches[1].Trim() - } elseif ($content -match '(?s)### 📋 Title Assessment.+?\*\*Issues:\*\*(.+?)(?=\*\*Recommended|###|$)') { - $titleIssues = $Matches[1].Trim() - } - } - - # Extract Description Assessment text - if ([string]::IsNullOrWhiteSpace($DescriptionAssessment)) { - # Try to extract detailed issues from the summary - $issuesSection = "" - if ($content -match '(?s)### 📝 Description Quality Assessment(.+?)(?=###|---|\z)') { - $issuesSection = $Matches[1].Trim() - # Remove the Status line since we already show it in the header - $issuesSection = $issuesSection -replace '(?m)^\*\*Status:\*\*.*$\n?', '' - $issuesSection = $issuesSection.Trim() - } elseif ($content -match '(?s)\*\*Issues:\*\*(.+?)(?=\*\*Recommended|\*\*Action|###|---|\z)') { - $issuesSection = $Matches[1].Trim() - } - - # Try to extract what works - $worksSection = "" - if ($content -match '(?s)### ❌ Issues Found(.+?)(?=###|---|\z)') { - $worksSection = $Matches[1].Trim() - } elseif ($content -match '(?s)\| Issue \| Severity \| Details \|(.+?)(?=###|---|\z)') { - # Extract table content - $worksSection = "**Issues:**`n" + $Matches[1].Trim() - } - - # Combine into assessment - if (-not [string]::IsNullOrWhiteSpace($issuesSection) -or -not [string]::IsNullOrWhiteSpace($worksSection)) { - $DescriptionAssessment = "" - if ($worksSection) { $DescriptionAssessment += $worksSection + "`n`n" } - if ($issuesSection) { $DescriptionAssessment += $issuesSection } - } else { - # Fallback - try to get the verdict section - if ($content -match '(?s)## ⚠️ Verdict:(.+?)(?=###|$)') { - $DescriptionAssessment = $Matches[1].Trim() - } else { - $DescriptionAssessment = "Description needs updates. See details below." - } - } - } - - # Extract Missing Elements - if ([string]::IsNullOrWhiteSpace($MissingElements)) { - if ($content -match '(?s)Missing.*?elements?:(.+?)(?=###|$)') { - $MissingElements = $Matches[1].Trim() - } elseif ($content -match '(?s)Only addition needed:(.+?)(?=###|\*\*Action|$)') { - $MissingElements = $Matches[1].Trim() - } - } - - # Extract Recommended Description - if ([string]::IsNullOrWhiteSpace($RecommendedDescription)) { - # First, try to find a separate recommended-description.md file in same directory - $summaryDir = Split-Path $SummaryFile -Parent - $recommendedDescFile = Join-Path $summaryDir "recommended-description.md" - - if (Test-Path $recommendedDescFile) { - Write-Host "ℹ️ Found recommended-description.md file, loading full content..." -ForegroundColor Cyan - $RecommendedDescription = Get-Content $recommendedDescFile -Raw -Encoding UTF8 - $RecommendedDescription = $RecommendedDescription.Trim() - } - # Try to extract from
section in summary file - elseif ($content -match '(?s)
\s*Click to see proposed description\s*```markdown\s*(.+?)```\s*
') { - Write-Host "ℹ️ Extracted recommended description from expandable section..." -ForegroundColor Cyan - $RecommendedDescription = $Matches[1].Trim() - } - # Otherwise, try to extract from header in summary file - elseif ($content -match '(?s)### Recommended Description(.+?)(?=### [A-Z]|---|\z)') { - $RecommendedDescription = $Matches[1].Trim() - } - # If still empty, check for recommended description block in summary - elseif ($content -match '(?s)```markdown\s*" - -Write-Host "`nChecking for existing PR Finalization comment on #$PRNumber..." -ForegroundColor Yellow -$existingComment = $null - -try { - $commentsJson = gh api "repos/dotnet/maui/issues/$PRNumber/comments?per_page=100" 2>$null - $comments = $commentsJson | ConvertFrom-Json - - foreach ($comment in $comments) { - if ($comment.body -match [regex]::Escape($FINALIZE_MARKER)) { - $existingComment = $comment - Write-Host "✓ Found existing PR Finalization comment (ID: $($comment.id))" -ForegroundColor Green - break - } - } - - if (-not $existingComment) { - Write-Host "✓ No existing PR Finalization comment found - will create new" -ForegroundColor Yellow - } -} catch { - Write-Host "✓ No existing PR Finalization comment found - will create new" -ForegroundColor Yellow -} - -# Build the full comment body (always replaces existing comment entirely) -$commentBody = @" -## 📋 PR Finalization Review -$FINALIZE_MARKER - -$titleSection - -$descSection -$codeReviewSection -"@ - -if ($DryRun) { - # File-based DryRun: uses separate preview file for finalize (separate comment from unified) - if ([string]::IsNullOrWhiteSpace($PreviewFile)) { - $PreviewFile = "CustomAgentLogsTmp/PRState/$PRNumber/pr-finalize-preview.md" - } - - # Ensure directory exists - $previewDir = Split-Path $PreviewFile -Parent - if (-not (Test-Path $previewDir)) { - New-Item -ItemType Directory -Path $previewDir -Force | Out-Null - } - - # For finalize, we replace the entire file (it's a separate comment) - Write-Host "ℹ️ Saving finalize preview to: $PreviewFile" -ForegroundColor Cyan - Set-Content -Path $PreviewFile -Value "$($commentBody.TrimEnd())`n" -Encoding UTF8 -NoNewline - - Write-Host "`n=== COMMENT PREVIEW ===" -ForegroundColor Yellow - Write-Host $commentBody - Write-Host "`n=== END PREVIEW ===" -ForegroundColor Yellow - Write-Host "`n✅ Preview saved to: $PreviewFile" -ForegroundColor Green - Write-Host " Run 'open $PreviewFile' to view in editor" -ForegroundColor Gray - exit 0 -} - -# Write to temp file to avoid shell escaping issues -$tempFile = [System.IO.Path]::GetTempFileName() -@{ body = $commentBody } | ConvertTo-Json -Depth 10 | Set-Content -Path $tempFile -Encoding UTF8 - -if ($existingComment) { - Write-Host "Updating comment ID $($existingComment.id)..." -ForegroundColor Yellow - $result = gh api --method PATCH "repos/dotnet/maui/issues/comments/$($existingComment.id)" --input $tempFile --jq '.html_url' - Write-Host "✅ Comment updated: $result" -ForegroundColor Green -} else { - Write-Host "Posting new comment to PR #$PRNumber..." -ForegroundColor Yellow - $result = gh api --method POST "repos/dotnet/maui/issues/$PRNumber/comments" --input $tempFile --jq '.html_url' - Write-Host "✅ Comment posted: $result" -ForegroundColor Green -} - -Remove-Item $tempFile diff --git a/.github/skills/ai-summary-comment/scripts/post-try-fix-comment.ps1 b/.github/skills/ai-summary-comment/scripts/post-try-fix-comment.ps1 deleted file mode 100644 index f5bcf49540c5..000000000000 --- a/.github/skills/ai-summary-comment/scripts/post-try-fix-comment.ps1 +++ /dev/null @@ -1,515 +0,0 @@ -#!/usr/bin/env pwsh -<# -.SYNOPSIS - Posts or updates a try-fix attempts comment on a GitHub Issue or Pull Request. - -.DESCRIPTION - Creates ONE comment for all try-fix attempts with each attempt in a collapsible section. - Uses HTML marker for identification. - - If an existing try-fix comment exists, it will be EDITED with the new attempt added. - Otherwise, a new comment will be created. - - **NEW: Auto-loads from CustomAgentLogsTmp/PRState/{PRNumber}/try-fix/** - - Format: - ## 🔧 Try-Fix Analysis for Issue #XXXXX - - -
- Attempt 1: Approach Name ✅ PASS - - ... attempt details ... -
- -
- Attempt 2: Different Approach ❌ FAIL - - ... attempt details ... -
- -.PARAMETER IssueNumber - The issue number to post comment on (required unless -TryFixDir provided) - -.PARAMETER AttemptNumber - The attempt number (1, 2, 3, etc.) - auto-detected from TryFixDir if not specified - -.PARAMETER TryFixDir - Path to try-fix attempt directory (e.g., CustomAgentLogsTmp/PRState/27246/try-fix/attempt-1) - If provided, all parameters are auto-loaded from files in this directory - -.PARAMETER Approach - Brief description of the fix approach (required unless loading from TryFixDir) - -.PARAMETER RootCause - Description of the root cause identified (optional for failed attempts) - -.PARAMETER FilesChanged - Markdown table or list of files changed (required unless loading from TryFixDir) - -.PARAMETER Status - Status of the attempt: "Compiles", "Pass", "Fail" (required unless loading from TryFixDir) - -.PARAMETER Analysis - Analysis of why it worked or failed (optional) - -.PARAMETER CodeSnippet - Code snippet showing the fix (optional) - -.PARAMETER DryRun - Print comment instead of posting - -.EXAMPLE - # Simplest: Just provide attempt directory (all info auto-loaded) - ./post-try-fix-comment.ps1 -TryFixDir CustomAgentLogsTmp/PRState/27246/try-fix/attempt-1 - -.EXAMPLE - # Post all attempts for an issue - ./post-try-fix-comment.ps1 -IssueNumber 27246 - -.EXAMPLE - # Manual parameters (legacy) - ./post-try-fix-comment.ps1 -IssueNumber 19560 -AttemptNumber 1 ` - -Approach "Change Shadow base class to StyleableElement" ` - -RootCause "Shadow inherits from Element which lacks styling support" ` - -FilesChanged "| File | Changes |`n|------|---------|`n| Shadow.cs | +1/-1 |" ` - -Status "Pass" -#> - -param( - [Parameter(Mandatory=$false)] - [int]$IssueNumber, - - [Parameter(Mandatory=$false)] - [int]$AttemptNumber, - - [Parameter(Mandatory=$false)] - [string]$TryFixDir, - - [Parameter(Mandatory=$false)] - [string]$Approach, - - [Parameter(Mandatory=$false)] - [string]$RootCause, - - [Parameter(Mandatory=$false)] - [string]$FilesChanged, - - [Parameter(Mandatory=$false)] - [ValidateSet("Compiles", "Pass", "Fail", "")] - [string]$Status, - - [Parameter(Mandatory=$false)] - [string]$Analysis, - - [Parameter(Mandatory=$false)] - [string]$CodeSnippet, - - [Parameter(Mandatory=$false)] - [switch]$DryRun, - - [Parameter(Mandatory=$false)] - [string]$PreviewFile -) - -$ErrorActionPreference = "Stop" - -Write-Host "╔═══════════════════════════════════════════════════════════╗" -ForegroundColor Cyan -Write-Host "║ Try-Fix Comment (Post/Update) ║" -ForegroundColor Cyan -Write-Host "╚═══════════════════════════════════════════════════════════╝" -ForegroundColor Cyan - -# ============================================================================ -# AUTO-DISCOVERY FROM DIRECTORIES -# ============================================================================ - -# If TryFixDir provided, load everything from there -if (-not [string]::IsNullOrWhiteSpace($TryFixDir)) { - if (-not (Test-Path $TryFixDir)) { - throw "Try-fix directory not found: $TryFixDir" - } - - # Extract IssueNumber from path (e.g., CustomAgentLogsTmp/PRState/27246/try-fix/attempt-1) - if ($TryFixDir -match '[/\\](\d+)[/\\]try-fix') { - if ($IssueNumber -eq 0) { - $IssueNumber = [int]$Matches[1] - Write-Host "ℹ️ Auto-detected IssueNumber: $IssueNumber from path" -ForegroundColor Cyan - } - } - - # Extract AttemptNumber from path (e.g., attempt-1) - if ($TryFixDir -match 'attempt-(\d+)$') { - if ($AttemptNumber -eq 0) { - $AttemptNumber = [int]$Matches[1] - Write-Host "ℹ️ Auto-detected AttemptNumber: $AttemptNumber from path" -ForegroundColor Cyan - } - } - - # Load approach from approach.md or approach.txt - if ([string]::IsNullOrWhiteSpace($Approach)) { - $approachFile = Join-Path $TryFixDir "approach.md" - if (-not (Test-Path $approachFile)) { - $approachFile = Join-Path $TryFixDir "approach.txt" - } - if (Test-Path $approachFile) { - $Approach = Get-Content $approachFile -Raw -Encoding UTF8 - Write-Host "ℹ️ Loaded approach from: $approachFile" -ForegroundColor Cyan - } - } - - # Load result from result.txt - if ([string]::IsNullOrWhiteSpace($Status)) { - $resultFile = Join-Path $TryFixDir "result.txt" - if (Test-Path $resultFile) { - $resultContent = (Get-Content $resultFile -Raw -Encoding UTF8).Trim().ToUpper() - $Status = switch -Regex ($resultContent) { - 'PASS' { "Pass" } - 'FAIL' { "Fail" } - 'COMPILES' { "Compiles" } - default { "Fail" } - } - Write-Host "ℹ️ Loaded status: $Status from result.txt" -ForegroundColor Cyan - } - } - - # Load analysis from analysis.md - if ([string]::IsNullOrWhiteSpace($Analysis)) { - $analysisFile = Join-Path $TryFixDir "analysis.md" - if (Test-Path $analysisFile) { - $Analysis = Get-Content $analysisFile -Raw -Encoding UTF8 - Write-Host "ℹ️ Loaded analysis from: $analysisFile" -ForegroundColor Cyan - } - } - - # Load diff from fix.diff - if ([string]::IsNullOrWhiteSpace($CodeSnippet)) { - $diffFile = Join-Path $TryFixDir "fix.diff" - if (Test-Path $diffFile) { - $CodeSnippet = Get-Content $diffFile -Raw -Encoding UTF8 - Write-Host "ℹ️ Loaded code diff from: $diffFile" -ForegroundColor Cyan - } - } - - # Generate FilesChanged from diff if not provided - if ([string]::IsNullOrWhiteSpace($FilesChanged) -and -not [string]::IsNullOrWhiteSpace($CodeSnippet)) { - $files = $CodeSnippet | Select-String -Pattern "^\+\+\+ b/(.+)$" -AllMatches | - ForEach-Object { $_.Matches.Groups[1].Value } | - Sort-Object -Unique - if ($files) { - $FilesChanged = "| File | Type |`n|------|------|`n" - foreach ($file in $files) { - $FilesChanged += "| ``$file`` | Modified |`n" - } - } else { - $FilesChanged = "_See diff above_" - } - } -} - -# If IssueNumber provided but no TryFixDir, try to find all attempts -if ($IssueNumber -gt 0 -and [string]::IsNullOrWhiteSpace($TryFixDir) -and [string]::IsNullOrWhiteSpace($Approach)) { - $tryFixBase = "CustomAgentLogsTmp/PRState/$IssueNumber/try-fix" - if (-not (Test-Path $tryFixBase)) { - $repoRoot = git rev-parse --show-toplevel 2>$null - if ($repoRoot) { - $tryFixBase = Join-Path $repoRoot "CustomAgentLogsTmp/PRState/$IssueNumber/try-fix" - } - } - - if (Test-Path $tryFixBase) { - $attemptDirs = Get-ChildItem -Path $tryFixBase -Directory | Where-Object { $_.Name -match '^attempt-\d+$' } | Sort-Object { [int]($_.Name -replace 'attempt-', '') } - if ($attemptDirs.Count -gt 0) { - Write-Host "ℹ️ Found $($attemptDirs.Count) attempt(s) in $tryFixBase" -ForegroundColor Cyan - Write-Host " Posting ALL attempts..." -ForegroundColor Cyan - - # Loop through ALL attempts and recurse for each - foreach ($attemptDir in $attemptDirs) { - Write-Host " Processing: $($attemptDir.Name)" -ForegroundColor Gray - & $PSCommandPath -TryFixDir $attemptDir.FullName -DryRun:$DryRun -PreviewFile:$PreviewFile - } - exit 0 - } - } -} - -# Validate required parameters -if ($IssueNumber -eq 0) { - throw "IssueNumber is required. Provide via -IssueNumber or use -TryFixDir with path containing issue number" -} - -if ($AttemptNumber -eq 0) { - throw "AttemptNumber is required. Provide via -AttemptNumber or use -TryFixDir with path like attempt-N" -} - -if ([string]::IsNullOrWhiteSpace($Approach)) { - throw "Approach is required. Provide via -Approach or create approach.md in TryFixDir" -} - -if ([string]::IsNullOrWhiteSpace($Status)) { - throw "Status is required. Provide via -Status or create result.txt in TryFixDir" -} - -if ([string]::IsNullOrWhiteSpace($FilesChanged)) { - $FilesChanged = "_No files changed information available_" -} - -# Status emoji mapping -$statusEmoji = switch ($Status) { - "Pass" { "✅" } - "Fail" { "❌" } - "Compiles" { "🔨" } - default { "⚪" } -} - -# Build the new attempt section - compact format -$attemptSection = @" -
-$statusEmoji Fix $AttemptNumber - -"@ - -# Show brief approach description -if (-not [string]::IsNullOrWhiteSpace($Approach)) { - $attemptSection += "`n$Approach`n`n" -} - -# Only show diff if available -if (-not [string]::IsNullOrWhiteSpace($CodeSnippet)) { - $attemptSection += @" -``````diff -$CodeSnippet -`````` - -"@ -} - -# Show analysis if available (explains why it passed/failed) -if (-not [string]::IsNullOrWhiteSpace($Analysis)) { - $attemptSection += "$Analysis`n" -} - -$attemptSection += @" -
-"@ - -# ============================================================================ -# UNIFIED COMMENT HANDLING -# Uses single comment with section markers -# ============================================================================ - -$MAIN_MARKER = "" -$SECTION_START = "" -$SECTION_END = "" - -Write-Host "`nChecking for existing AI Summary comment on #$IssueNumber..." -ForegroundColor Yellow -$existingComment = $null -$existingBody = "" - -try { - $commentsJson = gh api "repos/dotnet/maui/issues/$IssueNumber/comments" 2>$null - $comments = $commentsJson | ConvertFrom-Json - - foreach ($comment in $comments) { - if ($comment.body -match [regex]::Escape($MAIN_MARKER)) { - $existingComment = $comment - $existingBody = $comment.body - Write-Host "✓ Found existing AI Summary comment (ID: $($comment.id))" -ForegroundColor Green - break - } - } - - if (-not $existingComment) { - Write-Host "✓ No existing AI Summary comment found - will create new" -ForegroundColor Yellow - } -} catch { - Write-Host "✓ No existing AI Summary comment found - will create new" -ForegroundColor Yellow -} - -# Build the try-fix section content -# Count existing attempts to show in summary -$existingAttemptCount = 0 -$passCount = 0 -$failCount = 0 - -# Extract existing try-fix section to preserve previous attempts -$existingTryFixContent = "" -$startPattern = [regex]::Escape($SECTION_START) -$endPattern = [regex]::Escape($SECTION_END) -if ($existingBody -match "(?s)$startPattern(.*?)$endPattern") { - $existingTryFixContent = $Matches[1].Trim() -} - -# Extract just the inner attempt details (strip outer wrapper and headers) -$innerAttempts = "" -if ($existingTryFixContent -match '(?s)
\s*🔧 Try-Fix Analysis.*?\s*(.*?)\s*
\s*$') { - # New format - extract content inside the outer details - $innerAttempts = $Matches[1].Trim() -} elseif ($existingTryFixContent -match '(?s)### 🔧.*?`n`n(.*)') { - # Old header format - $innerAttempts = $Matches[1].Trim() -} else { - # Just use as-is but strip any stray headers - $innerAttempts = $existingTryFixContent -replace "(?s)^### 🔧[^\n]*\n+", "" -} - -# Strip any leading horizontal rules,
tags, or whitespace before the first
-$innerAttempts = $innerAttempts -replace "(?s)^\s*---\s*\n+", "" -$innerAttempts = $innerAttempts -replace "(?s)^(
\s*)+", "" -$innerAttempts = $innerAttempts.TrimStart() - -# Count existing attempts (only count inner
that are Fix attempts) -$existingAttemptCount = ([regex]::Matches($innerAttempts, '
\s*[✅❌🔨⚪]')).Count -$passCount = ([regex]::Matches($innerAttempts, '
\s*✅')).Count -$failCount = ([regex]::Matches($innerAttempts, '
\s*❌')).Count - -# Check if this attempt number already exists and replace it, or add new -$attemptPattern = "(?s)
\s*[✅❌🔨⚪]\s*Fix $AttemptNumber.*?
" -if ($innerAttempts -match $attemptPattern) { - Write-Host "Replacing existing Fix $AttemptNumber..." -ForegroundColor Yellow - $tryFixInnerContent = $innerAttempts -replace $attemptPattern, $attemptSection -} elseif (-not [string]::IsNullOrWhiteSpace($innerAttempts)) { - Write-Host "Adding new Fix $AttemptNumber..." -ForegroundColor Yellow - $tryFixInnerContent = $innerAttempts.TrimEnd() + "`n`n" + $attemptSection -} else { - Write-Host "Creating first fix..." -ForegroundColor Yellow - $tryFixInnerContent = $attemptSection -} - -# Recalculate attempt statistics from the final content to ensure consistency -$totalAttemptCount = ([regex]::Matches($tryFixInnerContent, '
\s*[✅❌🔨⚪]')).Count -$passCount = ([regex]::Matches($tryFixInnerContent, '
\s*✅')).Count -$failCount = ([regex]::Matches($tryFixInnerContent, '
\s*❌')).Count - -# Build summary line with counts -$summaryStatus = if ($passCount -gt 0) { "✅ $passCount passed" } else { "" } -if ($failCount -gt 0) { - if ($summaryStatus -ne "") { $summaryStatus += ", " } - $summaryStatus += "❌ $failCount failed" -} -if ($summaryStatus -eq "") { $summaryStatus = "$totalAttemptCount attempt(s)" } - -# Wrap everything in a single collapsible section -$tryFixContent = @" -
-🔧 Try-Fix Analysis: $summaryStatus
-$tryFixInnerContent - -
-"@ - -# Build the section with markers -$tryFixSection = @" -$SECTION_START -$tryFixContent -$SECTION_END -"@ - -if ($existingComment) { - # Update existing comment - replace or add try-fix section - if ($existingBody -match "(?s)$startPattern.*?$endPattern") { - # Replace existing try-fix section - $commentBody = $existingBody -replace "(?s)$startPattern.*?$endPattern", $tryFixSection - } else { - # Add try-fix section before footer - $footerPattern = "(?s)(---\s*\n+.*?\s*)$" - if ($existingBody -match $footerPattern) { - $commentBody = $existingBody -replace $footerPattern, "`n$tryFixSection`n`n`$1" - } else { - $commentBody = $existingBody.TrimEnd() + "`n`n$tryFixSection" - } - } -} else { - # Create new unified comment - $commentBody = @" -$MAIN_MARKER - -## 🤖 AI Summary - -$tryFixSection -"@ -} - -if ($DryRun) { - # File-based DryRun: mirrors GitHub comment behavior using a local file - if ([string]::IsNullOrWhiteSpace($PreviewFile)) { - $PreviewFile = "CustomAgentLogsTmp/PRState/$IssueNumber/ai-summary-comment-preview.md" - } - - # Ensure directory exists - $previewDir = Split-Path $PreviewFile -Parent - if (-not (Test-Path $previewDir)) { - New-Item -ItemType Directory -Path $previewDir -Force | Out-Null - } - - # Read existing preview file (mimics reading existing GitHub comment) - $existingPreview = "" - if (Test-Path $PreviewFile) { - $existingPreview = Get-Content $PreviewFile -Raw -Encoding UTF8 - Write-Host "ℹ️ Updating existing preview file: $PreviewFile" -ForegroundColor Cyan - } else { - Write-Host "ℹ️ Creating new preview file: $PreviewFile" -ForegroundColor Cyan - } - - # Update or insert the TRY-FIX section - $TRY_FIX_MARKER = "" - $TRY_FIX_END_MARKER = "" - - if ($existingPreview -match [regex]::Escape($TRY_FIX_MARKER)) { - # Extract existing TRY-FIX content to preserve previous attempts (same logic as GitHub comment path) - $startPattern = [regex]::Escape($TRY_FIX_MARKER) - $endPattern = [regex]::Escape($TRY_FIX_END_MARKER) - $existingTryFixPreview = "" - if ($existingPreview -match "(?s)$startPattern(.*?)$endPattern") { - $existingTryFixPreview = $Matches[1].Trim() - } - - # Check if this attempt already exists - replace it, otherwise append - $attemptPatternPreview = "(?s)
\s*.*?(Attempt $AttemptNumber`:|Fix $AttemptNumber).*?
" - if ($existingTryFixPreview -match $attemptPatternPreview) { - Write-Host "Replacing existing Fix $AttemptNumber in preview..." -ForegroundColor Yellow - $updatedTryFixContent = $existingTryFixPreview -replace $attemptPatternPreview, $attemptSection - $tryFixSectionUpdated = "$SECTION_START`n$tryFixHeader$updatedTryFixContent`n$SECTION_END" - } else { - Write-Host "Adding Fix $AttemptNumber to preview..." -ForegroundColor Yellow - # Remove header if present to avoid duplication - $existingTryFixPreview = $existingTryFixPreview -replace "^### � (Try-Fix Analysis|Fix Attempts)\s*`n*", "" - $updatedTryFixContent = $tryFixHeader + $existingTryFixPreview.TrimEnd() + "`n`n" + $attemptSection - $tryFixSectionUpdated = "$SECTION_START`n$updatedTryFixContent`n$SECTION_END" - } - - # Replace the section in the preview - $pattern = [regex]::Escape($TRY_FIX_MARKER) + "[\s\S]*?" + [regex]::Escape($TRY_FIX_END_MARKER) - $finalComment = $existingPreview -replace $pattern, $tryFixSectionUpdated - } elseif (-not [string]::IsNullOrWhiteSpace($existingPreview)) { - # Append TRY-FIX section to existing content - $finalComment = $existingPreview.TrimEnd() + "`n`n" + $tryFixSection - } else { - # New file - use full comment body - $finalComment = $commentBody - } - - # Write to preview file - Set-Content -Path $PreviewFile -Value "$($finalComment.TrimEnd())`n" -Encoding UTF8 -NoNewline - - Write-Host "`n=== COMMENT PREVIEW ===" -ForegroundColor Yellow - Write-Host $finalComment - Write-Host "`n=== END PREVIEW ===" -ForegroundColor Yellow - Write-Host "`n✅ Preview saved to: $PreviewFile" -ForegroundColor Green - Write-Host " Run 'open $PreviewFile' to view in editor" -ForegroundColor Gray - exit 0 -} - -# Write to temp file to avoid shell escaping issues -$tempFile = [System.IO.Path]::GetTempFileName() -@{ body = $commentBody } | ConvertTo-Json -Depth 10 | Set-Content -Path $tempFile -Encoding UTF8 - -if ($existingComment) { - Write-Host "Updating comment ID $($existingComment.id)..." -ForegroundColor Yellow - $result = gh api --method PATCH "repos/dotnet/maui/issues/comments/$($existingComment.id)" --input $tempFile --jq '.html_url' - Write-Host "✅ Comment updated: $result" -ForegroundColor Green -} else { - Write-Host "Posting new comment to issue #$IssueNumber..." -ForegroundColor Yellow - $result = gh api --method POST "repos/dotnet/maui/issues/$IssueNumber/comments" --input $tempFile --jq '.html_url' - Write-Host "✅ Comment posted: $result" -ForegroundColor Green -} - -Remove-Item $tempFile diff --git a/.github/skills/ai-summary-comment/scripts/post-verify-tests-comment.ps1 b/.github/skills/ai-summary-comment/scripts/post-verify-tests-comment.ps1 deleted file mode 100644 index 5cace91ca955..000000000000 --- a/.github/skills/ai-summary-comment/scripts/post-verify-tests-comment.ps1 +++ /dev/null @@ -1,331 +0,0 @@ -#!/usr/bin/env pwsh -<# -.SYNOPSIS - Posts or updates the verification test results in the unified AI Summary comment. - -.DESCRIPTION - Reads verification report from CustomAgentLogsTmp and posts/updates - the VERIFY-TESTS section in the unified AI Summary comment. - - Uses the same marker and - section markers as other comment scripts. - -.PARAMETER PRNumber - The PR number to post comment on (required unless ReportFile provided) - -.PARAMETER ReportFile - Path to verification-report.md file. If not provided, auto-discovers from - CustomAgentLogsTmp/PRState/{PRNumber}/verify-tests-fail/verification-report.md - -.PARAMETER Status - Overall verification status: "Passed", "Failed" (auto-detected from report if not provided) - -.PARAMETER Platform - Platform tested (auto-detected from report if not provided) - -.PARAMETER Mode - Verification mode: "FailureOnly", "FullVerification" (auto-detected from report if not provided) - -.PARAMETER Summary - Brief summary of results (auto-generated if not provided) - -.PARAMETER DryRun - Print comment instead of posting - -.EXAMPLE - # Simplest: Just provide PR number (auto-loads from CustomAgentLogsTmp) - ./post-verify-tests-comment.ps1 -PRNumber 27246 - -.EXAMPLE - # Or provide report file path - ./post-verify-tests-comment.ps1 -ReportFile CustomAgentLogsTmp/PRState/27246/verify-tests-fail/verification-report.md - -.EXAMPLE - # Manual parameters - ./post-verify-tests-comment.ps1 -PRNumber 27246 -Status "Passed" -Platform "android" -Mode "FullVerification" -#> - -param( - [Parameter(Mandatory=$false)] - [int]$PRNumber, - - [Parameter(Mandatory=$false)] - [string]$ReportFile, - - [Parameter(Mandatory=$false)] - [ValidateSet("Passed", "Failed", "")] - [string]$Status, - - [Parameter(Mandatory=$false)] - [string]$Platform, - - [Parameter(Mandatory=$false)] - [ValidateSet("FailureOnly", "FullVerification", "")] - [string]$Mode, - - [Parameter(Mandatory=$false)] - [string]$Summary, - - [Parameter(Mandatory=$false)] - [switch]$DryRun, - - [Parameter(Mandatory=$false)] - [string]$PreviewFile -) - -$ErrorActionPreference = "Stop" - -Write-Host "╔═══════════════════════════════════════════════════════════╗" -ForegroundColor Cyan -Write-Host "║ Verify-Tests Comment (Post/Update) ║" -ForegroundColor Cyan -Write-Host "╚═══════════════════════════════════════════════════════════╝" -ForegroundColor Cyan - -# ============================================================================ -# AUTO-DISCOVERY FROM REPORT FILE -# ============================================================================ - -# If PRNumber provided but no ReportFile, try to find it -if ($PRNumber -gt 0 -and [string]::IsNullOrWhiteSpace($ReportFile)) { - $reportPath = "CustomAgentLogsTmp/PRState/$PRNumber/verify-tests-fail/verification-report.md" - if (-not (Test-Path $reportPath)) { - $repoRoot = git rev-parse --show-toplevel 2>$null - if ($repoRoot) { - $reportPath = Join-Path $repoRoot "CustomAgentLogsTmp/PRState/$PRNumber/verify-tests-fail/verification-report.md" - } - } - - if (Test-Path $reportPath) { - $ReportFile = $reportPath - Write-Host "ℹ️ Auto-discovered report file: $ReportFile" -ForegroundColor Cyan - } -} - -# If ReportFile provided, parse it -if (-not [string]::IsNullOrWhiteSpace($ReportFile)) { - if (-not (Test-Path $ReportFile)) { - throw "Report file not found: $ReportFile" - } - - $reportContent = Get-Content $ReportFile -Raw -Encoding UTF8 - Write-Host "ℹ️ Loading from report file: $ReportFile" -ForegroundColor Cyan - - # Extract PRNumber from path if not provided - if ($PRNumber -eq 0 -and $ReportFile -match '[/\\](\d+)[/\\]verify-tests-fail') { - $PRNumber = [int]$Matches[1] - Write-Host "ℹ️ Auto-detected PRNumber: $PRNumber from path" -ForegroundColor Cyan - } - - # Extract Status from report - if ([string]::IsNullOrWhiteSpace($Status)) { - if ($reportContent -match 'VERIFICATION PASSED|✅\s*PASSED') { - $Status = "Passed" - } elseif ($reportContent -match 'VERIFICATION FAILED|❌\s*FAILED') { - $Status = "Failed" - } else { - $Status = "Unknown" - } - Write-Host "ℹ️ Detected Status: $Status" -ForegroundColor Cyan - } - - # Extract Platform from report - if ([string]::IsNullOrWhiteSpace($Platform)) { - if ($reportContent -match 'Platform[:\s]+(\w+)') { - $Platform = $Matches[1] - } elseif ($reportContent -match '(android|ios|catalyst|windows)' ) { - $Platform = $Matches[1] - } - if ($Platform) { - Write-Host "ℹ️ Detected Platform: $Platform" -ForegroundColor Cyan - } - } - - # Extract Mode from report - if ([string]::IsNullOrWhiteSpace($Mode)) { - if ($reportContent -match 'Full Verification|FAIL without fix.*PASS with fix') { - $Mode = "FullVerification" - } elseif ($reportContent -match 'Verify Failure Only|tests.*FAILED as expected') { - $Mode = "FailureOnly" - } - if ($Mode) { - Write-Host "ℹ️ Detected Mode: $Mode" -ForegroundColor Cyan - } - } - - # Use report content as summary if not provided - if ([string]::IsNullOrWhiteSpace($Summary)) { - # Extract key results from report, excluding verbose "Test Results Details" section - if ($reportContent -match '(?s)^(.*?)(?=####\s*Test Results Details)') { - $Summary = $Matches[1].TrimEnd() - } else { - $Summary = $reportContent - } - } -} - -# Validate required parameters -if ($PRNumber -eq 0) { - throw "PRNumber is required. Provide via -PRNumber or use -ReportFile with path containing PR number" -} - -if ([string]::IsNullOrWhiteSpace($Status)) { - throw "Status is required. Provide via -Status or use -ReportFile with verification results" -} - -# Generate summary if not provided -if ([string]::IsNullOrWhiteSpace($Summary)) { - $statusEmoji = if ($Status -eq "Passed") { "✅" } else { "❌" } - $modeDesc = if ($Mode -eq "FullVerification") { "Full verification (FAIL without fix, PASS with fix)" } else { "Failure only (tests FAIL as expected)" } - $Summary = @" -**Status**: $statusEmoji $Status - -**Mode**: $modeDesc -**Platform**: $Platform - -_Run `verify-tests-fail.ps1` for full details._ -"@ -} - -# Status emoji -$statusEmoji = if ($Status -eq "Passed") { "✅ passed" } else { "❌ failed" } -$modeDesc = if ($Mode -eq "FullVerification") { "Full Verification" } else { "Failure Only" } - -# Build verification section content - wrapped in collapsible with status in summary -$verifyContent = @" -
-🚦 Test Verification: $statusEmoji - -$Summary - -
-"@ - -# ============================================================================ -# UNIFIED COMMENT HANDLING -# Uses single comment with section markers -# ============================================================================ - -$MAIN_MARKER = "" -$SECTION_START = "" -$SECTION_END = "" - -Write-Host "`nChecking for existing AI Summary comment on #$PRNumber..." -ForegroundColor Yellow -$existingComment = $null -$existingBody = "" - -try { - $commentsJson = gh api "repos/dotnet/maui/issues/$PRNumber/comments" 2>$null - $comments = $commentsJson | ConvertFrom-Json - - foreach ($comment in $comments) { - if ($comment.body -match [regex]::Escape($MAIN_MARKER)) { - $existingComment = $comment - $existingBody = $comment.body - Write-Host "✓ Found existing AI Summary comment (ID: $($comment.id))" -ForegroundColor Green - break - } - } - - if (-not $existingComment) { - Write-Host "✓ No existing AI Summary comment found - will create new" -ForegroundColor Yellow - } -} catch { - Write-Host "✓ No existing AI Summary comment found - will create new" -ForegroundColor Yellow -} - -# Build the section with markers -$verifySection = @" -$SECTION_START -$verifyContent -$SECTION_END -"@ - -$startPattern = [regex]::Escape($SECTION_START) -$endPattern = [regex]::Escape($SECTION_END) - -if ($existingComment) { - # Update existing comment - replace or add verify-tests section - if ($existingBody -match "(?s)$startPattern.*?$endPattern") { - # Replace existing verify-tests section - $commentBody = $existingBody -replace "(?s)$startPattern.*?$endPattern", $verifySection - } else { - # Add verify-tests section before footer - $footerPattern = "(?s)(---\s*\n+.*?\s*)$" - if ($existingBody -match $footerPattern) { - $commentBody = $existingBody -replace $footerPattern, "`n$verifySection`n`n`$1" - } else { - $commentBody = $existingBody.TrimEnd() + "`n`n$verifySection" - } - } -} else { - # Create new unified comment - $commentBody = @" -$MAIN_MARKER - -## 🤖 AI Summary - -$verifySection -"@ -} - -if ($DryRun) { - # File-based DryRun: mirrors GitHub comment behavior using a local file - if ([string]::IsNullOrWhiteSpace($PreviewFile)) { - $PreviewFile = "CustomAgentLogsTmp/PRState/$PRNumber/ai-summary-comment-preview.md" - } - - # Ensure directory exists - $previewDir = Split-Path $PreviewFile -Parent - if (-not (Test-Path $previewDir)) { - New-Item -ItemType Directory -Path $previewDir -Force | Out-Null - } - - # Read existing preview file (mimics reading existing GitHub comment) - $existingPreview = "" - if (Test-Path $PreviewFile) { - $existingPreview = Get-Content $PreviewFile -Raw -Encoding UTF8 - Write-Host "ℹ️ Updating existing preview file: $PreviewFile" -ForegroundColor Cyan - } else { - Write-Host "ℹ️ Creating new preview file: $PreviewFile" -ForegroundColor Cyan - } - - # Update or insert the VERIFY-TESTS section - $VERIFY_MARKER = "" - $VERIFY_END_MARKER = "" - - if ($existingPreview -match [regex]::Escape($VERIFY_MARKER)) { - # Replace existing VERIFY-TESTS section - $pattern = [regex]::Escape($VERIFY_MARKER) + "[\s\S]*?" + [regex]::Escape($VERIFY_END_MARKER) - $finalComment = $existingPreview -replace $pattern, $verifySection - } elseif (-not [string]::IsNullOrWhiteSpace($existingPreview)) { - # Append VERIFY-TESTS section to existing content - $finalComment = $existingPreview.TrimEnd() + "`n`n" + $verifySection - } else { - # New file - use full comment body - $finalComment = $commentBody - } - - # Write to preview file - Set-Content -Path $PreviewFile -Value "$($finalComment.TrimEnd())`n" -Encoding UTF8 -NoNewline - - Write-Host "`n=== COMMENT PREVIEW ===" -ForegroundColor Yellow - Write-Host $finalComment - Write-Host "`n=== END PREVIEW ===" -ForegroundColor Yellow - Write-Host "`n✅ Preview saved to: $PreviewFile" -ForegroundColor Green - Write-Host " Run 'open $PreviewFile' to view in editor" -ForegroundColor Gray - exit 0 -} - -# Write to temp file to avoid shell escaping issues -$tempFile = [System.IO.Path]::GetTempFileName() -@{ body = $commentBody } | ConvertTo-Json -Depth 10 | Set-Content -Path $tempFile -Encoding UTF8 - -if ($existingComment) { - Write-Host "Updating comment ID $($existingComment.id)..." -ForegroundColor Yellow - $result = gh api --method PATCH "repos/dotnet/maui/issues/comments/$($existingComment.id)" --input $tempFile --jq '.html_url' - Write-Host "✅ Comment updated: $result" -ForegroundColor Green -} else { - Write-Host "Posting new comment to PR #$PRNumber..." -ForegroundColor Yellow - $result = gh api --method POST "repos/dotnet/maui/issues/$PRNumber/comments" --input $tempFile --jq '.html_url' - Write-Host "✅ Comment posted: $result" -ForegroundColor Green -} - -Remove-Item $tempFile diff --git a/.github/skills/ai-summary-comment/scripts/post-write-tests-comment.ps1 b/.github/skills/ai-summary-comment/scripts/post-write-tests-comment.ps1 deleted file mode 100644 index fb3125529c99..000000000000 --- a/.github/skills/ai-summary-comment/scripts/post-write-tests-comment.ps1 +++ /dev/null @@ -1,487 +0,0 @@ -#!/usr/bin/env pwsh -<# -.SYNOPSIS - Posts or updates a write-tests comment on a GitHub Issue or Pull Request. - -.DESCRIPTION - Creates ONE comment for all test-writing attempts with each attempt in a collapsible section. - Uses HTML marker for identification. - - **NEW: Auto-loads from CustomAgentLogsTmp/PRState/{IssueNumber}/write-tests/** - - If an existing write-tests comment exists, it will be EDITED with the new attempt added. - Otherwise, a new comment will be created. - - Format: - ## 🧪 Test Writing for Issue #XXXXX - - -
- Attempt 1: Test description ✅ Verified - - ... test details ... -
- -
- Attempt 2: Different approach ❌ Failed - - ... test details ... -
- -.PARAMETER IssueNumber - The issue number to post comment on (required unless TestDir provided) - -.PARAMETER AttemptNumber - The attempt number (1, 2, 3, etc.) - auto-detected from TestDir if not specified - -.PARAMETER TestDir - Path to test attempt directory (e.g., CustomAgentLogsTmp/PRState/27246/write-tests/attempt-1) - If provided, all parameters are auto-loaded from files in this directory - -.PARAMETER TestDescription - Brief description of what the test verifies (required unless loading from TestDir) - -.PARAMETER HostAppFile - Path to the HostApp test page file (required unless loading from TestDir) - -.PARAMETER TestFile - Path to the NUnit test file (required unless loading from TestDir) - -.PARAMETER TestMethod - Name of the test method (required unless loading from TestDir) - -.PARAMETER Category - UITestCategories category used (required unless loading from TestDir) - -.PARAMETER VerificationStatus - Status: "Verified" (tests fail without fix), "Failed" (tests don't catch bug), "Unverified" (not yet run) (required unless loading from TestDir) - -.PARAMETER Platforms - Platforms the test runs on (e.g., "All", "iOS, Android") (optional) - -.PARAMETER Notes - Additional notes about the test (optional) - -.PARAMETER DryRun - Print comment instead of posting - -.EXAMPLE - # Simplest: Just provide test directory (all info auto-loaded) - ./post-write-tests-comment.ps1 -TestDir CustomAgentLogsTmp/PRState/27246/write-tests/attempt-1 - -.EXAMPLE - # Or just provide issue number (auto-discovers latest attempt) - ./post-write-tests-comment.ps1 -IssueNumber 27246 - -.EXAMPLE - # Manual parameters (legacy) - ./post-write-tests-comment.ps1 -IssueNumber 33331 -AttemptNumber 1 ` - -TestDescription "Verifies Picker.IsOpen property changes correctly" ` - -HostAppFile "src/Controls/tests/TestCases.HostApp/Issues/Issue33331.cs" ` - -TestFile "src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue33331.cs" ` - -TestMethod "PickerIsOpenPropertyChanges" ` - -Category "Picker" ` - -VerificationStatus "Verified" -#> - -param( - [Parameter(Mandatory=$false)] - [int]$IssueNumber, - - [Parameter(Mandatory=$false)] - [int]$AttemptNumber, - - [Parameter(Mandatory=$false)] - [string]$TestDir, - - [Parameter(Mandatory=$false)] - [string]$TestDescription, - - [Parameter(Mandatory=$false)] - [string]$HostAppFile, - - [Parameter(Mandatory=$false)] - [string]$TestFile, - - [Parameter(Mandatory=$false)] - [string]$TestMethod, - - [Parameter(Mandatory=$false)] - [string]$Category, - - [Parameter(Mandatory=$false)] - [ValidateSet("Verified", "Failed", "Unverified", "")] - [string]$VerificationStatus, - - [Parameter(Mandatory=$false)] - [string]$Platforms = "All", - - [Parameter(Mandatory=$false)] - [string]$Notes, - - [Parameter(Mandatory=$false)] - [switch]$DryRun, - - [Parameter(Mandatory=$false)] - [string]$PreviewFile -) - -$ErrorActionPreference = "Stop" - -Write-Host "╔═══════════════════════════════════════════════════════════╗" -ForegroundColor Cyan -Write-Host "║ Write-Tests Comment (Post/Update) ║" -ForegroundColor Cyan -Write-Host "╚═══════════════════════════════════════════════════════════╝" -ForegroundColor Cyan - -# ============================================================================ -# AUTO-DISCOVERY FROM DIRECTORIES -# ============================================================================ - -# If TestDir provided, load everything from there -if (-not [string]::IsNullOrWhiteSpace($TestDir)) { - if (-not (Test-Path $TestDir)) { - throw "Test directory not found: $TestDir" - } - - # Extract IssueNumber from path (e.g., CustomAgentLogsTmp/PRState/27246/write-tests/attempt-1) - if ($TestDir -match '[/\\](\d+)[/\\]write-tests') { - if ($IssueNumber -eq 0) { - $IssueNumber = [int]$Matches[1] - Write-Host "ℹ️ Auto-detected IssueNumber: $IssueNumber from path" -ForegroundColor Cyan - } - } - - # Extract AttemptNumber from path (e.g., attempt-1) - if ($TestDir -match 'attempt-(\d+)$') { - if ($AttemptNumber -eq 0) { - $AttemptNumber = [int]$Matches[1] - Write-Host "ℹ️ Auto-detected AttemptNumber: $AttemptNumber from path" -ForegroundColor Cyan - } - } - - # Load test description from description.md or description.txt - if ([string]::IsNullOrWhiteSpace($TestDescription)) { - $descFile = Join-Path $TestDir "description.md" - if (-not (Test-Path $descFile)) { - $descFile = Join-Path $TestDir "description.txt" - } - if (Test-Path $descFile) { - $TestDescription = (Get-Content $descFile -Raw -Encoding UTF8).Trim() - Write-Host "ℹ️ Loaded description from: $descFile" -ForegroundColor Cyan - } - } - - # Load test info from test-info.json or test-info.txt - $infoFile = Join-Path $TestDir "test-info.json" - if (Test-Path $infoFile) { - $info = Get-Content $infoFile -Raw | ConvertFrom-Json - if ([string]::IsNullOrWhiteSpace($HostAppFile) -and $info.HostAppFile) { $HostAppFile = $info.HostAppFile } - if ([string]::IsNullOrWhiteSpace($TestFile) -and $info.TestFile) { $TestFile = $info.TestFile } - if ([string]::IsNullOrWhiteSpace($TestMethod) -and $info.TestMethod) { $TestMethod = $info.TestMethod } - if ([string]::IsNullOrWhiteSpace($Category) -and $info.Category) { $Category = $info.Category } - Write-Host "ℹ️ Loaded test info from: $infoFile" -ForegroundColor Cyan - } - - # Load verification status from result.txt - if ([string]::IsNullOrWhiteSpace($VerificationStatus)) { - $resultFile = Join-Path $TestDir "result.txt" - if (Test-Path $resultFile) { - $resultContent = (Get-Content $resultFile -Raw -Encoding UTF8).Trim().ToUpper() - $VerificationStatus = switch -Regex ($resultContent) { - 'VERIFIED' { "Verified" } - 'PASS' { "Verified" } - 'FAILED' { "Failed" } - 'FAIL' { "Failed" } - default { "Unverified" } - } - Write-Host "ℹ️ Loaded status: $VerificationStatus from result.txt" -ForegroundColor Cyan - } - } - - # Load notes from notes.md - if ([string]::IsNullOrWhiteSpace($Notes)) { - $notesFile = Join-Path $TestDir "notes.md" - if (Test-Path $notesFile) { - $Notes = Get-Content $notesFile -Raw -Encoding UTF8 - Write-Host "ℹ️ Loaded notes from: $notesFile" -ForegroundColor Cyan - } - } -} - -# If IssueNumber provided but no TestDir, try to find all attempts -if ($IssueNumber -gt 0 -and [string]::IsNullOrWhiteSpace($TestDir) -and [string]::IsNullOrWhiteSpace($TestDescription)) { - $testBase = "CustomAgentLogsTmp/PRState/$IssueNumber/write-tests" - if (-not (Test-Path $testBase)) { - $repoRoot = git rev-parse --show-toplevel 2>$null - if ($repoRoot) { - $testBase = Join-Path $repoRoot "CustomAgentLogsTmp/PRState/$IssueNumber/write-tests" - } - } - - if (Test-Path $testBase) { - $attemptDirs = Get-ChildItem -Path $testBase -Directory | Where-Object { $_.Name -match '^attempt-\d+$' } | Sort-Object Name - if ($attemptDirs.Count -gt 0) { - Write-Host "ℹ️ Found $($attemptDirs.Count) attempt(s) in $testBase" -ForegroundColor Cyan - Write-Host " Use -TestDir to post a specific attempt, or posting latest..." -ForegroundColor Cyan - - # Post the latest attempt - $latestAttempt = $attemptDirs | Sort-Object { [int]($_.Name -replace 'attempt-', '') } | Select-Object -Last 1 - $TestDir = $latestAttempt.FullName - - # Recurse with the discovered directory - & $PSCommandPath -TestDir $TestDir -DryRun:$DryRun - exit 0 - } - } -} - -# Validate required parameters -if ($IssueNumber -eq 0) { - throw "IssueNumber is required. Provide via -IssueNumber or use -TestDir with path containing issue number" -} - -if ($AttemptNumber -eq 0) { - throw "AttemptNumber is required. Provide via -AttemptNumber or use -TestDir with path like attempt-N" -} - -if ([string]::IsNullOrWhiteSpace($TestDescription)) { - throw "TestDescription is required. Provide via -TestDescription or create description.md in TestDir" -} - -if ([string]::IsNullOrWhiteSpace($HostAppFile)) { - throw "HostAppFile is required. Provide via -HostAppFile or add to test-info.json in TestDir" -} - -if ([string]::IsNullOrWhiteSpace($TestFile)) { - throw "TestFile is required. Provide via -TestFile or add to test-info.json in TestDir" -} - -if ([string]::IsNullOrWhiteSpace($TestMethod)) { - throw "TestMethod is required. Provide via -TestMethod or add to test-info.json in TestDir" -} - -if ([string]::IsNullOrWhiteSpace($Category)) { - throw "Category is required. Provide via -Category or add to test-info.json in TestDir" -} - -if ([string]::IsNullOrWhiteSpace($VerificationStatus)) { - $VerificationStatus = "Unverified" -} - -# Status emoji mapping -$statusEmoji = switch ($VerificationStatus) { - "Verified" { "✅ Verified" } - "Failed" { "❌ Failed" } - "Unverified" { "⏳ Unverified" } - default { $VerificationStatus } -} - -# Build the new attempt section (collapsible) -$attemptSection = @" -
-Attempt $AttemptNumber`: $TestDescription $statusEmoji - - -### Test Details - -| Property | Value | -|----------|-------| -| **Test Method** | ``$TestMethod`` | -| **Category** | ``UITestCategories.$Category`` | -| **Platforms** | $Platforms | -| **Status** | $statusEmoji | - -### Files Created - -
-📄 HostApp Test Page - Click to expand code - -**File:** ``$HostAppFile`` - -``````csharp -$(if (Test-Path $HostAppFile) { Get-Content $HostAppFile -Raw } else { "File not found: $HostAppFile" }) -`````` - -
- -
-🧪 NUnit Test - Click to expand code - -**File:** ``$TestFile`` - -``````csharp -$(if (Test-Path $TestFile) { Get-Content $TestFile -Raw } else { "File not found: $TestFile" }) -`````` - -
- -"@ - -if (-not [string]::IsNullOrWhiteSpace($Notes)) { - $attemptSection += @" - -### Notes - -$Notes - -"@ -} - -$attemptSection += @" -
-"@ - -# ============================================================================ -# UNIFIED COMMENT HANDLING -# Uses single comment with section markers -# ============================================================================ - -$MAIN_MARKER = "" -$SECTION_START = "" -$SECTION_END = "" - -Write-Host "`nChecking for existing AI Summary comment on #$IssueNumber..." -ForegroundColor Yellow -$existingComment = $null -$existingBody = "" - -try { - $commentsJson = gh api "repos/dotnet/maui/issues/$IssueNumber/comments" 2>$null - $comments = $commentsJson | ConvertFrom-Json - - foreach ($comment in $comments) { - if ($comment.body -match [regex]::Escape($MAIN_MARKER)) { - $existingComment = $comment - $existingBody = $comment.body - Write-Host "✓ Found existing AI Summary comment (ID: $($comment.id))" -ForegroundColor Green - break - } - } - - if (-not $existingComment) { - Write-Host "✓ No existing AI Summary comment found - will create new" -ForegroundColor Yellow - } -} catch { - Write-Host "✓ No existing AI Summary comment found - will create new" -ForegroundColor Yellow -} - -# Build the write-tests section content -$writeTestsHeader = "### 🧪 Test Writing`n`n" - -# Extract existing write-tests section to preserve previous attempts -$existingWriteTestsContent = "" -$startPattern = [regex]::Escape($SECTION_START) -$endPattern = [regex]::Escape($SECTION_END) -if ($existingBody -match "(?s)$startPattern(.*?)$endPattern") { - $existingWriteTestsContent = $Matches[1].Trim() -} - -# Check if this attempt number already exists and replace it, or add new -$attemptPattern = "(?s)
\s*Attempt $AttemptNumber`:.*?
" -if ($existingWriteTestsContent -match $attemptPattern) { - Write-Host "Replacing existing Attempt $AttemptNumber..." -ForegroundColor Yellow - $tryFixContent = $existingWriteTestsContent -replace $attemptPattern, $attemptSection -} elseif (-not [string]::IsNullOrWhiteSpace($existingWriteTestsContent)) { - Write-Host "Adding new Attempt $AttemptNumber..." -ForegroundColor Yellow - # Remove header if present to avoid duplication - $existingWriteTestsContent = $existingWriteTestsContent -replace "^### 🧪 Test Writing\s*`n*", "" - $writeTestsContent = $writeTestsHeader + $existingWriteTestsContent.TrimEnd() + "`n`n" + $attemptSection -} else { - Write-Host "Creating first attempt..." -ForegroundColor Yellow - $writeTestsContent = $writeTestsHeader + $attemptSection -} - -# Build the section with markers -$writeTestsSection = @" -$SECTION_START -$writeTestsContent -$SECTION_END -"@ - -if ($existingComment) { - # Update existing comment - replace or add write-tests section - if ($existingBody -match "(?s)$startPattern.*?$endPattern") { - # Replace existing write-tests section - $commentBody = $existingBody -replace "(?s)$startPattern.*?$endPattern", $writeTestsSection - } else { - # Add write-tests section before footer - $footerPattern = "(?s)(---\s*\n+.*?\s*)$" - if ($existingBody -match $footerPattern) { - $commentBody = $existingBody -replace $footerPattern, "`n$writeTestsSection`n`n`$1" - } else { - $commentBody = $existingBody.TrimEnd() + "`n`n$writeTestsSection" - } - } -} else { - # Create new unified comment - $commentBody = @" -$MAIN_MARKER - -## 🤖 AI Summary - -$writeTestsSection -"@ -} - -if ($DryRun) { - # File-based DryRun: mirrors GitHub comment behavior using a local file - if ([string]::IsNullOrWhiteSpace($PreviewFile)) { - $PreviewFile = "CustomAgentLogsTmp/PRState/$IssueNumber/ai-summary-comment-preview.md" - } - - # Ensure directory exists - $previewDir = Split-Path $PreviewFile -Parent - if (-not (Test-Path $previewDir)) { - New-Item -ItemType Directory -Path $previewDir -Force | Out-Null - } - - # Read existing preview file - $existingPreview = "" - if (Test-Path $PreviewFile) { - $existingPreview = Get-Content $PreviewFile -Raw -Encoding UTF8 - Write-Host "ℹ️ Updating existing preview file: $PreviewFile" -ForegroundColor Cyan - } else { - Write-Host "ℹ️ Creating new preview file: $PreviewFile" -ForegroundColor Cyan - } - - # Update or insert the WRITE-TESTS section - $sectionMarker = "" - $sectionEndMarker = "" - $wrappedSection = "$sectionMarker`n$writeTestsSection`n$sectionEndMarker" - - if ($existingPreview -match [regex]::Escape($sectionMarker)) { - # Replace existing WRITE-TESTS section - $pattern = [regex]::Escape($sectionMarker) + "[\s\S]*?" + [regex]::Escape($sectionEndMarker) - $finalComment = $existingPreview -replace $pattern, $wrappedSection - } elseif (-not [string]::IsNullOrWhiteSpace($existingPreview)) { - # Append WRITE-TESTS section to existing content - $finalComment = $existingPreview.TrimEnd() + "`n`n" + $wrappedSection - } else { - # New file - use full comment body with section markers - $finalComment = $commentBody.Replace($writeTestsSection, $wrappedSection) - } - - # Write to preview file - Set-Content -Path $PreviewFile -Value "$($finalComment.TrimEnd())`n" -Encoding UTF8 -NoNewline - - Write-Host "`n=== COMMENT PREVIEW ===" -ForegroundColor Yellow - Write-Host $finalComment - Write-Host "`n=== END PREVIEW ===" -ForegroundColor Yellow - Write-Host "`n✅ Preview saved to: $PreviewFile" -ForegroundColor Green - Write-Host " Run 'open $PreviewFile' to view in editor" -ForegroundColor Gray - exit 0 -} - -# Write to temp file to avoid shell escaping issues -$tempFile = [System.IO.Path]::GetTempFileName() -@{ body = $commentBody } | ConvertTo-Json -Depth 10 | Set-Content -Path $tempFile -Encoding UTF8 - -if ($existingComment) { - Write-Host "Updating comment ID $($existingComment.id)..." -ForegroundColor Yellow - $result = gh api --method PATCH "repos/dotnet/maui/issues/comments/$($existingComment.id)" --input $tempFile --jq '.html_url' - Write-Host "✅ Comment updated: $result" -ForegroundColor Green -} else { - Write-Host "Posting new comment to issue #$IssueNumber..." -ForegroundColor Yellow - $result = gh api --method POST "repos/dotnet/maui/issues/$IssueNumber/comments" --input $tempFile --jq '.html_url' - Write-Host "✅ Comment posted: $result" -ForegroundColor Green -} - -Remove-Item $tempFile diff --git a/.github/skills/azdo-build-investigator/SKILL.md b/.github/skills/azdo-build-investigator/SKILL.md new file mode 100644 index 000000000000..bd3ddc905332 --- /dev/null +++ b/.github/skills/azdo-build-investigator/SKILL.md @@ -0,0 +1,71 @@ +--- +name: azdo-build-investigator +description: "Investigate CI failures for dotnet/maui PRs — build errors, Helix test logs, and binlog analysis. Use when asked about failing checks, CI status, test failures, 'why is CI red', 'build failed', 'what's failing on PR', Helix failures, or device test failures." +metadata: + author: dotnet-maui + version: "2.0" +--- + +# dotnet/maui CI Investigation Context + +This skill provides MAUI-specific context for CI investigation. Use it together with the `ci-analysis` skill (loaded from the `dotnet-dnceng@dotnet-arcade-skills` plugin via `.github/copilot/settings.json`). + +> **First**: invoke the `ci-analysis` skill — it handles the core investigation workflow using `Get-CIStatus.ps1` and `gh` CLI (with MCP tools as optional enhancements if available). This skill provides MAUI-specific corrections and context on top of that. + +## Script Location + +The `ci-analysis` skill and its `Get-CIStatus.ps1` script are loaded automatically from the `dotnet/arcade-skills` plugin (configured in `.github/copilot/settings.json` via `enabledPlugins`). The CLI caches scripts to `~/.copilot/installed-plugins/dotnet-arcade-skills/`. No manual download is needed. + +## MAUI CI Pipelines + +> ⚠️ The `ci-analysis` skill's reference doc lists `maui-public` as the MAUI pipeline — **this is outdated**. The correct pipeline names are below. + +| Pipeline Name | Definition ID | Purpose | +|---------------|---------------|---------| +| `maui-pr` | **302** | Main build — check this first | +| `maui-pr-devicetests` | **314** | Helix device tests (iOS, Android, Windows, MacCatalyst) | +| `maui-pr-uitests` | **313** | Appium-based UI tests | + +**Organization**: `dnceng-public` / project `public` + +**Investigation priority order**: `maui-pr` → `maui-pr-devicetests` → `maui-pr-uitests` + +Most failures are in `maui-pr`. Device test failures appear in `maui-pr-devicetests`. Focus on the first failing pipeline before checking others. + +## MAUI-Specific Quirks + +### XHarness Exit-0 Blind Spot + +XHarness (used for iOS/Android device tests in `maui-pr-devicetests`) **exits with code 0 even when tests fail**. This means: +- The ADO job shows ✅ "Succeeded" +- `ci-analysis` may report no failures +- But actual test failures are hidden inside the Helix work items + +**How to detect hidden test failures**: Query the `ResultSummaryByBuild` Helix API endpoint: +``` +GET https://helix.dot.net/api/2019-06-17/jobs/{correlationId}/aggregated +``` +Look for `Failed` > 0 in the response even when the ADO build job shows green. + +When `ci-analysis` reports a `maui-pr-devicetests` build as passing but the PR has a `s/agent-gate-failed` label or the user suspects device test failures, always cross-check Helix `ResultSummaryByBuild`. + +### Container Artifact Binlogs + +MAUI build artifacts are **Container type**, not `PipelineArtifact`. This means: +- `az pipelines runs artifact download` does **not** work for binlogs +- Artifact names are like `Windows_NT_Build Windows (Debug)_Attempt1` (not `binlog`) +- Download requires a Bearer token from `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, use the `mcp-binlog-tool` MCP server to analyze downloaded `.binlog` files. This is optional — the core investigation workflow works without it via `gh` CLI and REST APIs. + +## Common MAUI Failure Patterns + +| Pattern | Where | Notes | +|---------|-------|-------| +| `error CS####` | `maui-pr` | C# compiler error — check file/line | +| `error XA####` | `maui-pr` | Android build error | +| `XamlC` | `maui-pr` | XAML compiler — usually missing type or bad binding | +| `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 | diff --git a/.github/skills/evaluate-pr-tests/SKILL.md b/.github/skills/evaluate-pr-tests/SKILL.md new file mode 100644 index 000000000000..35c8f2533b4d --- /dev/null +++ b/.github/skills/evaluate-pr-tests/SKILL.md @@ -0,0 +1,329 @@ +--- +name: evaluate-pr-tests +description: "Evaluates tests added in a PR for coverage, quality, edge cases, and test type appropriateness. Checks if tests cover the fix, finds gaps, and recommends lighter test types when possible. Prefer unit tests over device tests over UI tests. Triggers on: 'evaluate tests in PR', 'review test quality', 'are these tests good enough', 'check test coverage', 'is this test adequate', 'assess test coverage for PR'." +metadata: + author: dotnet-maui + version: "1.0" +compatibility: Requires git, PowerShell, and gh CLI for PR context. +--- + +# Evaluate PR Tests + +Evaluates the quality, coverage, and appropriateness of tests added in a PR. Produces a structured report with actionable findings. + +## When to Use + +- ✅ PR has tests and you want to evaluate their quality +- ⚠️ PR has no test files -- output a ❌ Fix Coverage verdict noting no tests were added; skip remaining criteria +- ✅ Reviewing whether tests adequately cover the fix +- ✅ Checking if a lighter test type could be used instead +- ✅ Before merging a PR, as part of review + +## Quick Start + +```bash +# Auto-detect PR and base branch +pwsh .github/skills/evaluate-pr-tests/scripts/Gather-TestContext.ps1 + +# With explicit base branch +pwsh .github/skills/evaluate-pr-tests/scripts/Gather-TestContext.ps1 -BaseBranch "origin/main" +``` + +## Workflow + +### Step 1: Gather Automated Context + +Run the script to get file categorization, convention checks, and anti-pattern detection: + +```bash +pwsh .github/skills/evaluate-pr-tests/scripts/Gather-TestContext.ps1 +``` + +This produces a report at `CustomAgentLogsTmp/TestEvaluation/context.md` with: +- File categorization (fix files vs test files by type) +- Convention compliance checks (naming, attributes, anti-patterns) +- AutomationId consistency (HostApp ↔ test) +- Existing similar tests +- Platform scope analysis + +### Step 2: Understand the Fix + +Read the fix files to understand: +- **What changed** — which code paths were modified +- **Why it changed** — the bug being fixed (from PR description or linked issue) +- **Edge cases** — what boundary conditions exist in the changed code + +### Step 3: Evaluate the Tests + +Read each test file and evaluate against **all criteria** below. For each criterion, provide a verdict (✅ Pass, ⚠️ Concern, ❌ Fail) with explanation. + +### Step 4: Produce the Report + +Output a structured evaluation report (see Output Format below). + +--- + +## Evaluation Criteria + +### 1. Fix Coverage + +**Question:** Does the test exercise the actual code paths changed by the fix? + +**How to check:** +- Trace the test's actions through the code to the fix location +- Would the test fail if the fix were reverted? +- Does the test assert on the specific behavior that was broken? + +**Red flags:** +- Test only checks that a page loads (doesn't exercise the fix) +- Test asserts on a different property/behavior than what was fixed +- Test interacts with the control but doesn't trigger the buggy code path + +**Example — Good:** +```csharp +// Fix: CollectionView.SelectedItem setter now clears selection when set to null +// Test: Sets SelectedItem to null and verifies selection is cleared +App.Tap("SelectItem"); +App.Tap("ClearSelection"); // Sets SelectedItem = null +var text = App.FindElement("SelectionStatus").GetText(); +Assert.That(text, Is.EqualTo("None")); // Directly tests the fix +``` + +**Example — Bad:** +```csharp +// Fix: CollectionView.SelectedItem setter +// Test: Just checks CollectionView renders (doesn't test selection clearing) +App.WaitForElement("MyCollectionView"); +Assert.That(true); // Proves nothing about the fix +``` + +### 2. Edge Cases & Gaps + +**Question:** Does the test cover boundary conditions, or only the happy path? + +**Check for these common gaps:** + +| Gap Type | What to Look For | +|----------|-----------------| +| **Null/empty** | Does the fix handle null? Is it tested? | +| **Boundary values** | Min, max, zero, negative, very large | +| **Repeated actions** | Does calling the action twice cause issues? | +| **Platform-specific** | Does the bug only occur on certain platforms? | +| **Async/timing** | Does the fix involve async code? Race conditions? | +| **State transitions** | Does the test cover before→after state changes? | +| **Error paths** | What happens when the operation fails? | +| **Combination effects** | Does the fix interact with other properties/features? | + +**How to suggest missing edge cases:** +- Read the fix code and identify every conditional branch +- For each branch, check if the test covers it +- Look for `if (x == null)`, `if (x <= 0)`, try/catch blocks +- Consider: "What inputs would make this fix NOT work?" + +### 3. Test Type Appropriateness + +**Question:** Is this the lightest test type that can verify the fix? + +**Preference order (lightest → heaviest):** + +| Priority | Type | When Appropriate | Project | +|----------|------|-----------------|---------| +| ⭐ 1st | **Unit Test** | Pure logic, property changes, data transformations, binding behavior, event wiring | `*.UnitTests.csproj` | +| ⭐ 1st | **XAML Test** | XAML parsing, XamlC compilation, source generation, markup extensions | `Controls.Xaml.UnitTests` | +| ⭐⭐ 2nd | **Device Test** | Platform-specific rendering, native API interaction, handler mapping | `*.DeviceTests.csproj` | +| ⭐⭐⭐ 3rd | **UI Test** | User interaction flows, visual layout, screenshot comparison, end-to-end scenarios | `TestCases.Shared.Tests` | + +**Decision tree:** + +``` +Does the test need to interact with visual UI elements? + YES → Is it checking visual layout/appearance? + YES → UI test (VerifyScreenshot) ✅ + NO → Could the interaction be tested via handler/control API? + YES → Device test ⭐⭐ + NO → UI test ✅ + NO → Does it need a platform/native context? + YES → Device test ⭐⭐ + NO → Does it test XAML parsing/compilation? + YES → XAML test ⭐ + NO → Unit test ⭐ +``` + +**Common "could be lighter" patterns:** + +| Current Test Does | Could Be Instead | Why | +|-------------------|-----------------|-----| +| UI test: sets property, checks label text | Unit test | Property logic doesn't need UI | +| UI test: verifies event fires | Unit test | Event wiring is testable in isolation | +| UI test: checks control doesn't crash | Device test | Don't need Appium for crash testing | +| UI test: validates XAML binding | XAML test | Binding resolution is compile-time | +| Device test: checks property default | Unit test | Defaults don't need platform context | + +### 4. Convention Compliance + +**Automated by the script.** Review the script output for: + +**UI Tests:** +- File naming: `IssueXXXXX.cs` +- `[Issue()]` attribute on HostApp page +- `[Category()]` attribute — exactly ONE per test class (on the class or method, not both) +- `_IssuesUITest` base class +- `WaitForElement` before interactions +- No `Task.Delay`/`Thread.Sleep` +- No inline `#if ANDROID`/`#if IOS` +- No obsolete APIs (`Application.MainPage`, `Frame`, `Device.BeginInvokeOnMainThread`) +- `UITestEntry`/`UITestEditor` for screenshot tests + +**Unit Tests:** +- `[Fact]` or `[Theory]` attributes (xUnit) + +**XAML Tests:** +- `[Test]` with `[Values] XamlInflator` parameter +- Issue naming: `MauiXXXXX` + +### 5. Flakiness Risk + +**Question:** Is this test likely to be flaky in CI? + +| Risk Factor | Detection | Mitigation | +|-------------|-----------|------------| +| Arbitrary delays | `Task.Delay`, `Thread.Sleep` | Use `WaitForElement`, `retryTimeout` | +| Missing waits | `App.Tap` without prior `WaitForElement` | Add explicit waits | +| Screenshot timing | `VerifyScreenshot()` without `retryTimeout` | Add `retryTimeout: TimeSpan.FromSeconds(2)` | +| Cursor blink | `Entry`/`Editor` in screenshot test | Use `UITestEntry`/`UITestEditor` | +| External URLs | WebView loading remote content | Use mock URLs or local content | +| Animation timing | Visual check after animation | Use `retryTimeout` | +| Global state | Test modifies `Application.Current` | Ensure cleanup in teardown | + +### 6. Duplicate Coverage + +**Question:** Does a similar test already exist? + +Check the "Existing Similar Tests" section of the script output. If similar tests exist: +- Is the new test covering a **different scenario**? → OK +- Is the new test **redundant**? → Flag as concern +- Could the new test be **merged** with an existing one? → Suggest consolidation + +### 7. Platform Scope + +**Question:** Does the test run on all platforms affected by the fix? + +Check the "Platform Scope Analysis" from the script: +- Cross-platform fix → tests should run on all platforms +- Platform-specific fix → test on that platform is sufficient +- Fix affects iOS + MacCatalyst → both should be tested (`.ios.cs` compiles for both) + +### 8. Assertion Quality + +**Question:** Are the assertions specific enough to catch regressions? + +| Assertion Quality | Example | Verdict | +|-------------------|---------|---------| +| ✅ Specific | `Assert.That(label.Text, Is.EqualTo("Expected Value"))` | Catches regression | +| ⚠️ Vague | `Assert.That(label.Text, Is.Not.Null)` | Too permissive | +| ❌ Meaningless | `Assert.That(true)` or no assertion | Proves nothing | +| ✅ Positional | `Assert.That(rect.Y, Is.GreaterThan(safeAreaTop))` | Specific to layout fix | +| ⚠️ Brittle | `Assert.That(rect.Y, Is.EqualTo(47))` | Magic number, will break | + +### 9. Fix-Test Alignment + +**Question:** Do the files changed by the fix align with what the test exercises? + +- Map the fix files to the controls/features they affect +- Map the test to the controls/features it exercises +- Flag if test exercises a different control than the fix changes +- Flag if test only covers one platform when fix touches multiple + +**Red flags:** +- Test class is named `Issue12345` for a fix in `CollectionView` but only exercises `Label` rendering +- Fix changes `Shell.cs` but test only navigates a `ContentPage` + +--- + +## Output Format + +Produce the evaluation report in this format: + +```markdown +## PR Test Evaluation Report + +**PR:** #XXXXX — [Title] +**Test files evaluated:** [count] +**Fix files:** [count] + +--- + +### Overall Verdict + +[One of: ✅ Tests are adequate | ⚠️ Tests need improvement | ❌ Tests are insufficient] + +[1-2 sentence summary of the most important finding] + +--- + +### 1. Fix Coverage — [✅/⚠️/❌] + +[Does the test exercise the code paths changed by the fix?] + +### 2. Edge Cases & Gaps — [✅/⚠️/❌] + +**Covered:** +- [edge case 1] +- [edge case 2] + +**Missing:** +- [gap 1 — describe what should be tested and why] +- [gap 2] + +### 3. Test Type Appropriateness — [✅/⚠️/❌] + +**Current:** [UI Test / Device Test / Unit Test / XAML Test] +**Recommendation:** [Same / Could be lighter — explain why] + +### 4. Convention Compliance — [✅/⚠️/❌] + +[Summary from automated checks — list only issues found] + +### 5. Flakiness Risk — [✅ Low / ⚠️ Medium / ❌ High] + +[Specific risk factors identified] + +### 6. Duplicate Coverage — [✅ No duplicates / ⚠️ Potential overlap] + +[Similar existing tests found, if any] + +### 7. Platform Scope — [✅/⚠️/❌] + +[Does test coverage match the platforms affected by the fix?] + +### 8. Assertion Quality — [✅/⚠️/❌] + +[Are assertions specific enough to catch the actual bug?] + +### 9. Fix-Test Alignment — [✅/⚠️/❌] + +[Do the test and fix target the same code paths?] + +--- + +### Recommendations + +1. [Most important actionable recommendation] +2. [Second recommendation] +3. [...] +``` + +## Output Files + +| File | Description | +|------|-------------| +| `CustomAgentLogsTmp/TestEvaluation/context.md` | Automated context report from script | + +## Troubleshooting + +| Problem | Cause | Solution | +|---------|-------|----------| +| No changed files detected | Wrong base branch | Use `-BaseBranch` explicitly | +| No fix files detected | All changes are tests | Expected for test-only PRs | +| AutomationId mismatch | HostApp and test out of sync | Update one to match the other | +| Convention check false positive | Script regex too broad | Ignore and note in report | diff --git a/.github/skills/evaluate-pr-tests/scripts/Gather-TestContext.ps1 b/.github/skills/evaluate-pr-tests/scripts/Gather-TestContext.ps1 new file mode 100644 index 000000000000..8b8805b5596b --- /dev/null +++ b/.github/skills/evaluate-pr-tests/scripts/Gather-TestContext.ps1 @@ -0,0 +1,560 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Gathers context for evaluating tests in a PR. + +.DESCRIPTION + Analyzes changed files in a PR to: + - Categorize files (fix vs test, test type classification) + - Check test conventions (naming, attributes, base classes) + - Detect anti-patterns (delays, obsolete APIs, missing waits) + - Verify AutomationId consistency between HostApp and test files + - Find existing similar tests + - Assess platform scope + +.PARAMETER BaseBranch + Base branch to diff against. Auto-detected from PR if not specified. + +.PARAMETER OutputDir + Directory to write the context report to. + +.EXAMPLE + ./Gather-TestContext.ps1 + +.EXAMPLE + ./Gather-TestContext.ps1 -BaseBranch "origin/main" +#> + +param( + [Parameter(Mandatory = $false)] + [string]$BaseBranch, + + [Parameter(Mandatory = $false)] + [string]$OutputDir = "CustomAgentLogsTmp/TestEvaluation" +) + +$ErrorActionPreference = "Continue" +$RepoRoot = git rev-parse --show-toplevel 2>$null +if (-not $RepoRoot) { $RepoRoot = Get-Location } + +# --- Output setup --- +New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null +$reportPath = Join-Path $OutputDir "context.md" + +# --- 1. Detect base branch --- +if (-not $BaseBranch) { + try { + $prJson = gh pr view --json baseRefName 2>$null + if ($prJson) { + $prInfo = $prJson | ConvertFrom-Json + $BaseBranch = "origin/$($prInfo.baseRefName)" + } + } catch { } + + if (-not $BaseBranch) { + $BaseBranch = "origin/main" + } +} + +Write-Host "📋 Base branch: $BaseBranch" +git fetch origin --quiet 2>$null + +# --- 2. Get changed files --- +$changedFiles = @() +$diffOutput = git diff --name-only "$BaseBranch...HEAD" 2>$null +if ($diffOutput) { + $changedFiles = $diffOutput -split "`r?`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne "" } +} else { + $diffOutput = git diff --name-only "$BaseBranch" 2>$null + if ($diffOutput) { + $changedFiles = $diffOutput -split "`r?`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne "" } + } +} + +if ($changedFiles.Count -eq 0) { + Write-Host "⚠️ No changed files detected. Check your branch and base branch." + exit 1 +} + +Write-Host "📁 Found $($changedFiles.Count) changed files" + +# --- 3. Categorize files --- +$uiTestFiles = @() +$uiHostAppFiles = @() +$deviceTestFiles = @() +$unitTestFiles = @() +$xamlTestFiles = @() +$fixFiles = @() +$otherFiles = @() + +foreach ($file in $changedFiles) { + if ($file -match "TestCases\.Shared\.Tests" -and $file -match "\.cs$") { + $uiTestFiles += $file + } + elseif ($file -match "TestCases\.HostApp" -and $file -match "\.(cs|xaml)$") { + $uiHostAppFiles += $file + } + elseif ($file -match "DeviceTests" -and $file -match "\.cs$") { + $deviceTestFiles += $file + } + elseif ($file -match "Xaml\.UnitTests" -and $file -match "\.(cs|xaml)$") { + $xamlTestFiles += $file + } + elseif ($file -match "[./]UnitTests" -and $file -match "\.cs$") { + $unitTestFiles += $file + } + elseif ($file -match "\.(cs|xaml)$" -and $file -notmatch "[Tt]est") { + $fixFiles += $file + } + else { + $otherFiles += $file + } +} + +# --- 4. Convention checks --- +function Test-UITestConventions { + param( + [string]$TestFile, + [string[]]$HostAppFiles + ) + + $issues = @() + $info = @() + + if (-not (Test-Path $TestFile)) { return @{ Issues = @("File not found: $TestFile"); Info = @() } } + + $content = Get-Content $TestFile -Raw + + # --- Naming (only flag files in Issues/ directory that look like issue tests) --- + $fileName = [System.IO.Path]::GetFileNameWithoutExtension($TestFile) + if ($TestFile -match "Issues/" -and $fileName -match "^Issue" -and $fileName -notmatch "^Issue\d+$") { + $issues += "Issue test file name ``$fileName`` should follow ``IssueXXXXX`` pattern" + } + + # --- Inheritance --- + if ($content -notmatch ":\s*_IssuesUITest") { + $issues += "Missing ``_IssuesUITest`` base class inheritance" + } + + # --- Attributes --- + $testMethods = [regex]::Matches($content, '\[Test\]') + $categories = [regex]::Matches($content, '\[Category\([^\)]+\)\]') | ForEach-Object { $_.Value } | Select-Object -Unique + if ($categories.Count -eq 0) { + $issues += "Missing ``[Category]`` attribute — exactly ONE required (on class or method)" + } + elseif ($categories.Count -gt 1) { + $issues += "Found $($categories.Count) ``[Category]`` attributes — must have exactly ONE (on class or method, not both)" + } + $info += "Test methods: $($testMethods.Count), Category attributes: $($categories.Count)" + + # --- Anti-patterns --- + if ($content -match "Task\.Delay|Thread\.Sleep") { + $issues += "Contains ``Task.Delay``/``Thread.Sleep`` — use ``WaitForElement`` or ``retryTimeout`` instead" + } + if ($content -match "Application\.MainPage") { + $issues += "Uses obsolete ``Application.MainPage`` — use ``Window.Page`` instead" + } + if ($content -match "#if\s+!?(ANDROID|IOS|MACCATALYST|WINDOWS)\b") { + $issues += "Contains inline ``#if`` platform directives — move to extension methods" + } + + # --- Wait patterns (per-interaction check) --- + # Extract all App.Tap/Click/FindElement calls and their target IDs (literals or identifiers) + # Also detects fluent chains: App.WaitForElement("Id").Tap() + $interactionRegex = 'App\.(Tap|Click|FindElement)\(\s*(?:"([^"]+)"|([A-Za-z_][A-Za-z0-9_.]*))' + $interactions = [regex]::Matches($content, $interactionRegex) + + # Match WaitForElement with any number of arguments (timeout overloads etc.) + $waitRegex = 'App\.WaitForElement\(\s*(?:"([^"]+)"|([A-Za-z_][A-Za-z0-9_.]*))' + $waits = [regex]::Matches($content, $waitRegex) | ForEach-Object { + if ($_.Groups[1].Success) { $_.Groups[1].Value } else { $_.Groups[2].Value } + } + + # Also detect fluent: App.WaitForElement("Id").Tap() — extract IDs from fluent chains + $fluentRegex = 'App\.WaitForElement\(\s*"([^"]+)"\s*\)\s*\.(Tap|Click|FindElement)\(\)' + $fluentWaits = [regex]::Matches($content, $fluentRegex) | ForEach-Object { $_.Groups[1].Value } + $waits = @($waits) + @($fluentWaits) | Select-Object -Unique + + $missingWaits = @() + foreach ($interaction in $interactions) { + $targetId = if ($interaction.Groups[2].Success) { $interaction.Groups[2].Value } else { $interaction.Groups[3].Value } + if ($targetId -notin $waits) { + $missingWaits += $targetId + } + } + if ($missingWaits.Count -gt 0) { + $uniqueMissing = $missingWaits | Select-Object -Unique + $issues += "Elements interacted with but no ``WaitForElement`` call: $($uniqueMissing -join ', ')" + } + + # --- Screenshot hygiene --- + if ($content -match "VerifyScreenshot") { + $info += "Uses ``VerifyScreenshot()``" + # Tighten scope: only flag delay within the same method block (not across method boundaries) + # Split into lines and check for delay on a line within 20 lines before VerifyScreenshot + $lines = $content -split "`r?`n" + $screenshotDelay = $false + for ($i = 0; $i -lt $lines.Count; $i++) { + if ($lines[$i] -match "VerifyScreenshot") { + $start = [Math]::Max(0, $i - 20) + $window = $lines[$start..$i] -join "`n" + # Only flag if in same method: stop at first closing brace boundary + $methodWindow = $window + if ($methodWindow -match "Task\.Delay|Thread\.Sleep") { + $screenshotDelay = $true + break + } + } + } + if ($screenshotDelay) { + $issues += "Uses delay before ``VerifyScreenshot`` — use ``retryTimeout`` parameter instead" + } + } + + # --- AutomationId consistency (bidirectional) --- + # Check test references IDs that exist in HostApp + $testReferencedIds = [regex]::Matches($content, 'App\.\w+\("([^"]+)"\)') | ForEach-Object { $_.Groups[1].Value } | Select-Object -Unique + + $allHostIds = @() + + foreach ($hostFile in $HostAppFiles) { + if (-not (Test-Path $hostFile)) { continue } + $hostContent = Get-Content $hostFile -Raw + + # Check HostApp conventions + # For .xaml files, skip C# attribute checks (they live in code-behind) + if ($hostFile -notmatch "\.xaml$") { + if ($hostContent -notmatch "\[Issue\(") { + $issues += "HostApp page ``$([System.IO.Path]::GetFileName($hostFile))`` missing ``[Issue()]`` attribute" + } + } + if ($hostContent -match "new\s+Frame\b") { + $issues += "HostApp uses obsolete ``Frame`` control — use ``Border`` instead" + } + if ($hostContent -match "Device\.BeginInvokeOnMainThread") { + $issues += "HostApp uses obsolete ``Device.BeginInvokeOnMainThread`` — use ``Dispatcher.Dispatch``" + } + + # Extract HostApp AutomationIds + $hostIds = [regex]::Matches($hostContent, 'AutomationId\s*=\s*"([^"]+)"') | ForEach-Object { $_.Groups[1].Value } + $allHostIds += $hostIds + + # Check for UITest controls in screenshot tests + if ($content -match "VerifyScreenshot") { + # Detect Entry/Editor in both C# (new Entry) and XAML (]" + $hasEditor = $hostContent -match "new\s+Editor\b" -or $hostContent -match "]" + $hasUITestEntry = $hostContent -match "new\s+UITestEntry\b" -or $hostContent -match "]" + $hasUITestEditor = $hostContent -match "new\s+UITestEditor\b" -or $hostContent -match "]" + if ($hasEntry -and -not $hasUITestEntry) { + $issues += "HostApp uses ``Entry`` in screenshot test — use ``UITestEntry`` to prevent cursor blink flakiness" + } + if ($hasEditor -and -not $hasUITestEditor) { + $issues += "HostApp uses ``Editor`` in screenshot test — use ``UITestEditor`` to prevent cursor blink flakiness" + } + } + } + + # Consolidated ID checks + $allHostIds = $allHostIds | Select-Object -Unique + + # Forward check: test references IDs not in HostApp (will fail at runtime) + # Only check IDs that look like AutomationIds (PascalCase, no spaces) — skip text selectors + foreach ($id in $testReferencedIds) { + if ($allHostIds.Count -gt 0 -and $id -match "^[A-Z]\w+$" -and $id -notin $allHostIds) { + $issues += "Test references AutomationId ``$id`` not found in HostApp" + } + } + + # Reverse check: HostApp IDs not used in test (informational, not an issue) + $unusedIds = $allHostIds | Where-Object { $_ -notin $testReferencedIds } + if ($unusedIds.Count -gt 0) { + $info += "HostApp AutomationIds not referenced in test: $($unusedIds -join ', ')" + } + + return @{ Issues = $issues; Info = $info } +} + +function Test-UnitTestConventions { + param([string]$TestFile) + + $issues = @() + $info = @() + + if (-not (Test-Path $TestFile)) { return @{ Issues = @("File not found: $TestFile"); Info = @() } } + + $content = Get-Content $TestFile -Raw + + # xUnit patterns + $facts = [regex]::Matches($content, '\[Fact\]') + $theories = [regex]::Matches($content, '\[Theory\]') + $info += "Fact methods: $($facts.Count), Theory methods: $($theories.Count)" + + if ($facts.Count -eq 0 -and $theories.Count -eq 0) { + # Check for NUnit [Test] (might be in wrong project) + if ($content -match "\[Test\]") { + $info += "Uses NUnit ``[Test]`` attribute (verify this is correct for the project)" + } + else { + # Only flag as issue if the file name ends with Test/Tests (actual test class) + $utFileName = [System.IO.Path]::GetFileNameWithoutExtension($TestFile) + if ($utFileName -match "Tests?$") { + $issues += "No test methods found (no ``[Fact]``, ``[Theory]``, or ``[Test]`` attributes)" + } + else { + $info += "No test attributes found — may be a helper/utility class" + } + } + } + + return @{ Issues = $issues; Info = $info } +} + +function Test-XamlTestConventions { + param([string]$TestFile) + + $issues = @() + $info = @() + + if (-not (Test-Path $TestFile)) { return @{ Issues = @("File not found: $TestFile"); Info = @() } } + + $content = Get-Content $TestFile -Raw + + $tests = [regex]::Matches($content, '\[Test\]') + $info += "Test methods: $($tests.Count)" + + # Check for XamlInflator parameter + if ($content -match "\[Values\]\s*XamlInflator") { + $info += "Uses ``[Values] XamlInflator`` (tests all inflator variants)" + } + elseif ($tests.Count -gt 0) { + $issues += "Missing ``[Values] XamlInflator`` parameter — test should cover Runtime, XamlC, and SourceGen" + } + + # File naming for issues + $fileName = [System.IO.Path]::GetFileNameWithoutExtension($TestFile) + if ($TestFile -match "Issues/" -and $fileName -notmatch "^Maui\d+$") { + $issues += "Issue test file name ``$fileName`` doesn't follow ``MauiXXXXX`` pattern" + } + + return @{ Issues = $issues; Info = $info } +} + +# --- 5. Build the report --- +$report = @() +$report += "# PR Test Evaluation Context" +$report += "" +$report += "Generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" +$report += "" + +# File summary +$report += "## Changed Files Summary" +$report += "" +$report += "| Category | Count | Files |" +$report += "|----------|-------|-------|" + +function Format-FileList { param([string[]]$files) if ($files.Count -eq 0) { return "_none_" } return ($files | ForEach-Object { "``$_``" }) -join ", " } + +$report += "| **Fix files** | $($fixFiles.Count) | $(Format-FileList $fixFiles) |" +$report += "| **UI Tests (NUnit)** | $($uiTestFiles.Count) | $(Format-FileList $uiTestFiles) |" +$report += "| **UI Tests (HostApp)** | $($uiHostAppFiles.Count) | $(Format-FileList $uiHostAppFiles) |" +$report += "| **Device Tests** | $($deviceTestFiles.Count) | $(Format-FileList $deviceTestFiles) |" +$report += "| **Unit Tests** | $($unitTestFiles.Count) | $(Format-FileList $unitTestFiles) |" +$report += "| **XAML Tests** | $($xamlTestFiles.Count) | $(Format-FileList $xamlTestFiles) |" +$report += "| **Other** | $($otherFiles.Count) | $(Format-FileList $otherFiles) |" +$report += "" + +# Test type preference +$totalTests = $uiTestFiles.Count + $deviceTestFiles.Count + $unitTestFiles.Count + $xamlTestFiles.Count +$report += "## Test Type Distribution" +$report += "" +if ($totalTests -eq 0) { + $report += "⚠️ **No test files detected in this PR.**" +} +else { + $report += "| Type | Count | Preference |" + $report += "|------|-------|------------|" + $report += "| Unit Tests | $($unitTestFiles.Count) | ⭐ Most preferred (fast, isolated) |" + $report += "| XAML Tests | $($xamlTestFiles.Count) | ⭐ Most preferred (compile-time) |" + $report += "| Device Tests | $($deviceTestFiles.Count) | ⭐⭐ Good (platform-specific) |" + $report += "| UI Tests | $($uiTestFiles.Count) | ⭐⭐⭐ When needed (slow, Appium) |" +} +$report += "" + +# Convention checks +$conventionIssueCount = 0 + +if ($uiTestFiles.Count -gt 0) { + $report += "## UI Test Convention Checks" + $report += "" + foreach ($testFile in $uiTestFiles) { + $baseName = [System.IO.Path]::GetFileNameWithoutExtension($testFile) + $matchingHostFiles = $uiHostAppFiles | Where-Object { + # Handle .xaml.cs: GetFileNameWithoutExtension("Issue12345.xaml.cs") → "Issue12345.xaml" + $hostName = [System.IO.Path]::GetFileNameWithoutExtension($_) + $hostName = $hostName -replace '\.xaml$', '' + $hostName -eq $baseName + } + $result = Test-UITestConventions -TestFile $testFile -HostAppFiles $matchingHostFiles + $report += "### ``$baseName``" + if ($result.Info.Count -gt 0) { + foreach ($i in $result.Info) { $report += "- ℹ️ $i" } + } + if ($result.Issues.Count -eq 0) { + $report += "- ✅ No convention issues found" + } + else { + foreach ($issue in $result.Issues) { + $report += "- ⚠️ $issue" + $conventionIssueCount++ + } + } + $report += "" + } +} + +if ($unitTestFiles.Count -gt 0) { + $report += "## Unit Test Convention Checks" + $report += "" + foreach ($testFile in $unitTestFiles) { + $baseName = [System.IO.Path]::GetFileNameWithoutExtension($testFile) + $result = Test-UnitTestConventions -TestFile $testFile + $report += "### ``$baseName``" + if ($result.Info.Count -gt 0) { + foreach ($i in $result.Info) { $report += "- ℹ️ $i" } + } + if ($result.Issues.Count -eq 0) { + $report += "- ✅ No convention issues found" + } + else { + foreach ($issue in $result.Issues) { + $report += "- ⚠️ $issue" + $conventionIssueCount++ + } + } + $report += "" + } +} + +if ($xamlTestFiles.Count -gt 0) { + $report += "## XAML Test Convention Checks" + $report += "" + foreach ($testFile in ($xamlTestFiles | Where-Object { $_ -match "\.cs$" })) { + $baseName = [System.IO.Path]::GetFileNameWithoutExtension($testFile) + $result = Test-XamlTestConventions -TestFile $testFile + $report += "### ``$baseName``" + if ($result.Info.Count -gt 0) { + foreach ($i in $result.Info) { $report += "- ℹ️ $i" } + } + if ($result.Issues.Count -eq 0) { + $report += "- ✅ No convention issues found" + } + else { + foreach ($issue in $result.Issues) { + $report += "- ⚠️ $issue" + $conventionIssueCount++ + } + } + $report += "" + } +} + +# --- 6. Find existing similar tests --- +$report += "## Existing Similar Tests" +$report += "" + +# Extract control/feature names from fix files +$controlNames = @() +foreach ($file in $fixFiles) { + # Extract from path patterns like Controls/src/Core/Button/ or Handlers/Entry/ + if ($file -match "Controls/src/Core/(\w+)/" ) { $controlNames += $Matches[1] } + if ($file -match "Handlers/(\w+)/" ) { $controlNames += $Matches[1] } + if ($file -match "Platform/\w+/(\w+)\." ) { $controlNames += $Matches[1] } + + # Extract from file name (e.g., ButtonHandler.Android.cs → Button) + $stem = [System.IO.Path]::GetFileNameWithoutExtension($file) + $stem = $stem -replace "\.(Android|iOS|Windows|MacCatalyst|Tizen|Standard)$", "" + if ($stem -match "^(\w+?)Handler$") { $controlNames += $Matches[1] } +} +$controlNames = $controlNames | Select-Object -Unique | Where-Object { $_.Length -gt 2 } + +if ($controlNames.Count -gt 0) { + foreach ($control in $controlNames) { + $existing = @() + # Search in UI tests + $uiMatches = Get-ChildItem -Path "$RepoRoot/src/Controls/tests/TestCases.Shared.Tests/Tests" -Recurse -Filter "*.cs" -ErrorAction SilentlyContinue | + Select-String -Pattern "\b$control\b" -List -ErrorAction SilentlyContinue | + Select-Object -ExpandProperty Path -First 5 + if ($uiMatches) { $existing += $uiMatches | ForEach-Object { $_ -replace [regex]::Escape("$RepoRoot/"), "" } } + + # Search in unit tests + $unitMatches = Get-ChildItem -Path "$RepoRoot/src/Controls/tests/Core.UnitTests" -Recurse -Filter "*$control*" -ErrorAction SilentlyContinue | + Select-Object -ExpandProperty FullName -First 5 + if ($unitMatches) { $existing += $unitMatches | ForEach-Object { $_ -replace [regex]::Escape("$RepoRoot/"), "" } } + + if ($existing.Count -gt 0) { + $report += "### Existing tests for ``$control``" + foreach ($f in $existing) { + $report += "- ``$f``" + } + $report += "" + } + } +} +else { + $report += "_Could not detect control/feature names from fix files._" + $report += "" +} + +# --- 7. Platform scope analysis --- +$report += "## Platform Scope Analysis" +$report += "" + +$platformIndicators = @{ + "Android" = ($fixFiles | Where-Object { $_ -match "Android|\.android\." }).Count + "iOS" = ($fixFiles | Where-Object { $_ -match "\.ios\." -or ($_ -match "/iOS/" -and $_ -notmatch "MacCatalyst") }).Count + "MacCatalyst" = ($fixFiles | Where-Object { $_ -match "MacCatalyst|\.maccatalyst\." -or $_ -match "\.ios\." }).Count + "Windows" = ($fixFiles | Where-Object { $_ -match "Windows|\.windows\." }).Count + "Cross-platform" = ($fixFiles | Where-Object { $_ -notmatch "Android|\.android\.|\.ios\.|/iOS/|MacCatalyst|\.maccatalyst\.|Windows|\.windows\." }).Count +} + +$report += "| Platform | Fix Files Touching |" +$report += "|----------|--------------------|" +foreach ($kv in $platformIndicators.GetEnumerator()) { + $indicator = if ($kv.Value -gt 0) { "✅ $($kv.Value) file(s)" } else { "—" } + $report += "| $($kv.Key) | $indicator |" +} +$report += "" + +if ($platformIndicators["Cross-platform"] -gt 0) { + $report += "ℹ️ Cross-platform fix files detected — tests should ideally run on **all platforms**." + $report += "" +} + +# --- 8. Summary --- +$report += "## Summary" +$report += "" +$report += "| Metric | Value |" +$report += "|--------|-------|" +$report += "| Total changed files | $($changedFiles.Count) |" +$report += "| Fix files | $($fixFiles.Count) |" +$report += "| Test files | $totalTests |" +$report += "| Convention issues | $conventionIssueCount |" + +$testRatio = if ($fixFiles.Count -gt 0 -and $totalTests -gt 0) { "✅ Tests present" } elseif ($fixFiles.Count -gt 0) { "⚠️ No tests for fix" } else { "ℹ️ No fix files" } +$report += "| Test coverage | $testRatio |" +$report += "" + +# --- Write output --- +$report | Out-File -FilePath $reportPath -Encoding utf8 + +Write-Host "" +Write-Host "═══════════════════════════════════════════════════" +Write-Host " Context report: $reportPath" +Write-Host " Convention issues: $conventionIssueCount" +Write-Host " Test files: $totalTests | Fix files: $($fixFiles.Count)" +Write-Host "═══════════════════════════════════════════════════" +Write-Host "" + +# Also print the report to stdout for agent consumption +$report | ForEach-Object { Write-Host $_ } diff --git a/.github/skills/evaluate-pr-tests/tests/eval.yaml b/.github/skills/evaluate-pr-tests/tests/eval.yaml new file mode 100644 index 000000000000..6d86eaf3d554 --- /dev/null +++ b/.github/skills/evaluate-pr-tests/tests/eval.yaml @@ -0,0 +1,277 @@ +scenarios: + - name: "Happy path - evaluate PR tests and produce structured report" + prompt: | + Evaluate the tests added in PR #34324. Check their quality, coverage, and whether the test type is appropriate. + assertions: + - type: "output_contains" + value: "PR Test Evaluation Report" + - type: "output_contains" + value: "Fix Coverage" + - type: "output_matches" + pattern: "(✅|⚠️|❌)" + - type: "output_contains" + value: "Test Type Appropriateness" + - type: "output_contains" + value: "Recommendations" + rubric: + - "The agent runs the Gather-TestContext.ps1 script to gather automated context before evaluating" + - "The report covers all major criteria: Fix Coverage, Edge Cases, Test Type, Convention Compliance, Flakiness Risk" + - "Each criterion has a verdict (pass/concern/fail) with a specific explanation, not just generic text" + - "The Overall Verdict section summarizes the most important finding in 1-2 sentences" + timeout: 180 + + - name: "Negative trigger - general code review should not produce test evaluation report" + prompt: | + Do a code review of the changes in the latest commit on this branch. Look for code quality issues, style, and potential bugs. + assertions: + - type: "output_not_contains" + value: "PR Test Evaluation Report" + - type: "output_not_contains" + value: "Gather-TestContext.ps1" + - type: "output_not_contains" + value: "Fix Coverage —" + rubric: + - "The agent performs a general code review without invoking the evaluate-pr-tests skill workflow" + - "The agent does not produce the 9-criteria evaluation structure from evaluate-pr-tests" + timeout: 120 + + - name: "Anti-pattern detection - Thread.Sleep and obsolete APIs" + prompt: | + Evaluate the tests in this PR. The added test file contains the following code: + + ```csharp + [Test] + [Category(UITestCategories.Layout)] + public void VerifyLabelPadding() + { + App.WaitForElement("MyLabel"); + App.Tap("TriggerButton"); + Thread.Sleep(2000); + VerifyScreenshot(); + } + ``` + + The HostApp page uses `Application.MainPage` to navigate and the test class doesn't call `WaitForElement` before every `App.Tap`. Evaluate whether these tests are adequate. + assertions: + - type: "output_contains" + value: "Thread.Sleep" + - type: "output_not_contains" + value: "Thread.Sleep is fine" + - type: "output_matches" + pattern: "(retryTimeout|WaitForElement)" + - type: "output_matches" + pattern: "(Application\\.MainPage|obsolete)" + rubric: + - "The agent explicitly flags Thread.Sleep as an anti-pattern and recommends retryTimeout on VerifyScreenshot instead" + - "The agent flags Application.MainPage as an obsolete API and recommends the modern equivalent" + - "The flakiness risk section marks this test as medium or high risk with specific reasons" + - "The convention compliance section lists all violations found in the code snippet" + timeout: 120 + + - name: "Test type downgrade recommendation - UI test for pure property logic" + prompt: | + Evaluate the tests for this PR. The fix changes a property setter in `Entry.cs` (cross-platform code) so that setting `IsReadOnly = true` also disables text input programmatically. The only test added is a full UI test: + + ```csharp + public class Issue99999 : _IssuesUITest + { + public override string Issue => "IsReadOnly disables input"; + public Issue99999(TestDevice device) : base(device) { } + + [Test] + [Category(UITestCategories.Entry)] + public void IsReadOnlyDisablesInput() + { + App.WaitForElement("TestEntry"); + App.Tap("SetReadOnlyButton"); + var text = App.FindElement("TestEntry").GetText(); + Assert.That(text, Is.EqualTo("")); + } + } + ``` + + Is this the right test type? + assertions: + - type: "output_matches" + pattern: "(unit test|Unit [Tt]est|UnitTest)" + - type: "output_contains" + value: "Test Type Appropriateness" + - type: "output_not_contains" + value: "UI test is appropriate here" + rubric: + - "The agent identifies that a unit test or device test would be lighter and sufficient for testing a property setter" + - "The agent explains WHY a lighter test type is appropriate (property logic doesn't require Appium/visual UI)" + - "The recommendation is actionable, not just 'consider a unit test' — it explains what project to use or what the unit test would look like" + timeout: 120 + + - name: "Weak assertion detection - meaningless test assertions" + prompt: | + The PR adds these tests. Are the assertions adequate to catch regressions? + + ```csharp + [Test] + [Category(UITestCategories.CollectionView)] + public void SelectionClearsOnNull() + { + App.WaitForElement("MyCollectionView"); + App.Tap("ClearSelectionButton"); + App.WaitForElement("MyCollectionView"); + Assert.That(true); // just checking no crash + } + ``` + + And in a second test: + + ```csharp + [Test] + public void CollectionViewLoads() + { + App.WaitForElement("MyCollectionView"); + var elem = App.FindElement("StatusLabel"); + Assert.That(elem, Is.Not.Null); + } + ``` + assertions: + - type: "output_matches" + pattern: "(meaningless|proves nothing|Assert\\.That\\(true\\)|vague|insufficient)" + - type: "output_contains" + value: "Assertion Quality" + - type: "output_matches" + pattern: "(❌|⚠️)" + rubric: + - "The agent correctly identifies Assert.That(true) as a meaningless assertion that proves nothing about the fix" + - "The agent identifies that checking Is.Not.Null on a UI element is too vague to catch actual regressions" + - "The agent provides concrete examples of what specific assertions SHOULD look like to catch the regression" + - "The overall verdict reflects that the assertions are insufficient, not just a minor concern" + timeout: 120 + + - name: "Edge case gaps analysis - fix with multiple branches untested" + prompt: | + The PR fixes a bug in CollectionView where SelectedItems returns null instead of an empty list when no items are selected. The fix adds a null-check: + + ```csharp + public IList SelectedItems + { + get => _selectedItems ?? (_selectedItems = new List()); + } + ``` + + The only test added verifies that after tapping an item and then clearing selection, the label shows "Count: 0". Evaluate whether this test covers the fix adequately. + assertions: + - type: "output_contains" + value: "Edge Cases" + - type: "output_matches" + pattern: "(null|initial state|never selected|without selecting)" + rubric: + - "The agent identifies the missing edge case: testing SelectedItems when no item was EVER selected (initial state), not just after clearing" + - "The agent traces the test's actions through the fix code and notes the test only covers one branch of the null-check" + - "The agent's gap analysis is derived from reading the fix code, not just generic advice" + timeout: 120 + + - name: "Near-miss recall - informal test quality check" + prompt: | + Look at the test files in PR #34324 and tell me if they seem reasonable. + assertions: + - type: "output_contains" + value: "PR Test Evaluation Report" + - type: "output_contains" + value: "Fix Coverage" + rubric: + - "The agent invokes the evaluate-pr-tests structured workflow even for informal phrasing" + - "The agent produces the structured 9-criteria report, not just a casual opinion" + timeout: 180 + + - name: "No tests added - PR only has fix files" + prompt: | + Evaluate the tests in this PR. The only files changed are: + - src/Controls/src/Core/CollectionView.cs + - src/Controls/src/Core/Handlers/CollectionViewHandler.cs + No test files were added. + assertions: + - type: "output_contains" + value: "Fix Coverage" + - type: "output_matches" + pattern: "❌" + - type: "output_not_contains" + value: "Tests are adequate" + rubric: + - "The agent flags the absence of tests as a Fix Coverage failure" + - "The overall verdict reflects that no tests were added" + timeout: 120 + + - name: "Fix-test alignment - test exercises wrong control" + prompt: | + The PR fixes a crash in Shell navigation when popping to the root. The fix changes: + - src/Controls/src/Core/Shell/Shell.cs + - src/Controls/src/Core/Shell/ShellNavigationManager.cs + + The only test added is: + + ```csharp + [Issue(IssueTracker.Github, 99998, "Shell navigation crash on PopToRoot", PlatformAffected.All)] + public class Issue99998 : ContentPage + { + public Issue99998() + { + Content = new VerticalStackLayout + { + Children = + { + new Label { Text = "Hello", AutomationId = "WelcomeLabel" } + } + }; + } + } + ``` + + And the NUnit test just does: + ```csharp + [Test] + [Category(UITestCategories.Shell)] + public void ShellPageLoads() + { + App.WaitForElement("WelcomeLabel"); + Assert.That(App.FindElement("WelcomeLabel").GetText(), Is.EqualTo("Hello")); + } + ``` + + Evaluate the test quality. + assertions: + - type: "output_contains" + value: "Fix-Test Alignment" + - type: "output_matches" + pattern: "(wrong control|Label|doesn't exercise|navigation|PopToRoot|misalign)" + - type: "output_matches" + pattern: "(⚠️|❌)" + rubric: + - "The agent identifies that the test only exercises a Label on a ContentPage, not Shell navigation or PopToRoot" + - "The Fix-Test Alignment criterion flags that the test doesn't trace back to the changed Shell code paths" + - "The agent recommends a test that actually triggers Shell navigation (e.g., pushing and popping pages)" + timeout: 120 + + - name: "Fluent chain wait pattern should not trigger missing-wait warning" + prompt: | + Evaluate this test code for convention compliance. Does it correctly use WaitForElement before interactions? + + ```csharp + [Test] + [Category(UITestCategories.Button)] + public void ButtonUpdatesLabel() + { + App.WaitForElement("TestButton").Tap(); + App.WaitForElement("ResultLabel"); + var text = App.FindElement("ResultLabel").GetText(); + Assert.That(text, Is.EqualTo("Clicked")); + } + ``` + assertions: + - type: "output_not_contains" + value: "missing WaitForElement" + - type: "output_not_contains" + value: "App.Tap without prior WaitForElement" + - type: "output_matches" + pattern: "(Convention Compliance|fluent|✅)" + rubric: + - "The agent does NOT flag the fluent App.WaitForElement().Tap() chain as a missing-wait violation" + - "The convention compliance check passes or has no wait-related warnings for this code" + timeout: 120 diff --git a/.github/skills/learn-from-pr/SKILL.md b/.github/skills/learn-from-pr/SKILL.md index 18e3a0d3f700..2a21ef5dc0c1 100644 --- a/.github/skills/learn-from-pr/SKILL.md +++ b/.github/skills/learn-from-pr/SKILL.md @@ -16,7 +16,6 @@ Extracts lessons learned from a completed PR to improve repository documentation | Input | Required | Source | |-------|----------|--------| | PR number or Issue number | Yes | User provides (e.g., "PR #33352" or "issue 33352") | -| Session markdown | Optional | `CustomAgentLogsTmp/PRState/issue-XXXXX.md` or `pr-XXXXX.md` | ## Outputs @@ -61,19 +60,10 @@ The skill is complete when you have: # Required: Get PR info gh pr view XXXXX --json title,body,files gh pr diff XXXXX - -# Check for session markdown -ls CustomAgentLogsTmp/PRState/issue-XXXXX.md CustomAgentLogsTmp/PRState/pr-XXXXX.md 2>/dev/null ``` -**If session markdown exists, extract:** -- Fix Candidates table (what was tried) -- Files each attempt targeted -- Why attempts failed - -**Analyzing without session markdown:** +**Analyze the PR to extract learning:** -When no session file exists, you can still learn from: 1. **PR discussion** - Comments reveal what was tried 2. **Commit history** - Multiple commits may show iteration 3. **Code complexity** - Non-obvious fixes suggest learning opportunities @@ -88,8 +78,6 @@ Focus on: "What would have helped an agent find this fix faster?" ```bash # Where did final fix go? gh pr view XXXXX --json files --jq '.files[].path' | grep -v test - -# If session markdown exists, compare to attempted files ``` | Scenario | Implication | @@ -209,7 +197,6 @@ Present your analysis covering: | Situation | Action | |-----------|--------| | PR not found | Ask user to verify PR number | -| No session markdown | Analyze PR diff only, note limited context | | No agent involvement evident | Ask user if they still want analysis | | Can't determine failure mode | State "insufficient data" and what's missing | diff --git a/.github/skills/pr-build-status/SKILL.md b/.github/skills/pr-build-status/SKILL.md deleted file mode 100644 index c4a542b8b9a3..000000000000 --- a/.github/skills/pr-build-status/SKILL.md +++ /dev/null @@ -1,114 +0,0 @@ ---- -name: pr-build-status -description: "Retrieve Azure DevOps build information for GitHub Pull Requests, including build IDs, stage status, failed jobs, and Helix console logs for any Helix-based test failures." -metadata: - author: dotnet-maui - version: "1.1" -compatibility: Requires GitHub CLI (gh) authenticated with access to dotnet/maui repository. ---- - -# PR Build Status Skill - -Retrieve Azure DevOps build information for GitHub Pull Requests, including Helix test logs. - -## Tools Required - -This skill uses `bash` together with `pwsh` (PowerShell 7+) to run the PowerShell scripts. No file editing or other tools are required. - -## When to Use - -- User asks about CI/CD status for a PR -- User asks about failed checks or builds -- User asks "what's failing on PR #XXXXX" -- User wants to see test results -- **User asks about Helix failures (device tests, integration tests, etc.)** -- **User needs to debug why tests are failing on Helix infrastructure** - -## Scripts - -All scripts are in `.github/skills/pr-build-status/scripts/` - -### 1. Get Build IDs for a PR -```bash -pwsh .github/skills/pr-build-status/scripts/Get-PrBuildIds.ps1 -PrNumber -``` - -### 2. Get Build Status -```bash -pwsh .github/skills/pr-build-status/scripts/Get-BuildInfo.ps1 -BuildId -# For failed jobs only: -pwsh .github/skills/pr-build-status/scripts/Get-BuildInfo.ps1 -BuildId -FailedOnly -``` - -### 3. Get Build Errors and Test Failures -```bash -# Get all errors (build errors + test failures) -pwsh .github/skills/pr-build-status/scripts/Get-BuildErrors.ps1 -BuildId - -# Get only build/compilation errors -pwsh .github/skills/pr-build-status/scripts/Get-BuildErrors.ps1 -BuildId -ErrorsOnly - -# Get only test failures -pwsh .github/skills/pr-build-status/scripts/Get-BuildErrors.ps1 -BuildId -TestsOnly -``` - -### 4. Get Helix Console Logs -```bash -# List all Helix work items and their status -pwsh .github/skills/pr-build-status/scripts/Get-HelixLogs.ps1 -BuildId - -# Filter by platform -pwsh .github/skills/pr-build-status/scripts/Get-HelixLogs.ps1 -BuildId -Platform Windows - -# Show console log content for failed work items -pwsh .github/skills/pr-build-status/scripts/Get-HelixLogs.ps1 -BuildId -ShowConsoleLog - -# Filter by work item name and show more log lines -pwsh .github/skills/pr-build-status/scripts/Get-HelixLogs.ps1 -BuildId -WorkItem "*Lifecycle*" -ShowConsoleLog -TailLines 200 -``` - -## Workflow - -### Standard Build Failures -1. Get build IDs: `Get-PrBuildIds.ps1 -PrNumber XXXXX` -2. For each build, get status: `Get-BuildInfo.ps1 -BuildId YYYYY -FailedOnly` -3. For failed builds, get errors: `Get-BuildErrors.ps1 -BuildId YYYYY` - -### Helix Test Failures -1. Get build IDs: `Get-PrBuildIds.ps1 -PrNumber XXXXX` -2. Find the build with Helix jobs (e.g., `maui-pr-devicetests`, `maui-integration-tests`) -3. Get Helix logs: `Get-HelixLogs.ps1 -BuildId YYYYY -ShowConsoleLog` -4. For specific platform: `Get-HelixLogs.ps1 -BuildId YYYYY -Platform Windows -ShowConsoleLog` - -## Understanding Helix Logs - -Helix is the .NET engineering infrastructure that runs tests across multiple platforms and device types. Tests that run on Helix include: -- **Device tests** - Run on real devices/emulators (iOS, Android, Windows, MacCatalyst) -- **Integration tests** - Run on various OS configurations -- **Other distributed tests** - Any test scenario that requires Helix infrastructure - -When Helix tests fail: - -1. **Build stage** - Compiles and packages the test app/harness -2. **Helix submission** - Sends the work items to Helix queues -3. **Work item execution** - Helix runs the tests on target machines/devices -4. **Console log** - Contains stdout/stderr from the test execution - -The `Get-HelixLogs.ps1` script retrieves the console logs which show: -- Test execution output -- Any crashes or errors -- Infrastructure issues (timeouts, installation failures, etc.) - -## Common Helix Failure Patterns - -| Pattern in Console Log | Meaning | -|------------------------|---------| -| "XHarness timeout" | Test took too long, killed by infrastructure | -| "No test result files found" | Tests never ran or process crashed | -| "error MT..." or "error BL..." | Build/linking error (check build logs instead) | -| Exit code non-zero | Test failures or infrastructure issues | - -## Prerequisites - -- `gh` (GitHub CLI) - authenticated -- `pwsh` (PowerShell 7+) diff --git a/.github/skills/pr-build-status/scripts/Get-BuildErrors.ps1 b/.github/skills/pr-build-status/scripts/Get-BuildErrors.ps1 deleted file mode 100644 index 2ad34c5e521e..000000000000 --- a/.github/skills/pr-build-status/scripts/Get-BuildErrors.ps1 +++ /dev/null @@ -1,208 +0,0 @@ -<# -.SYNOPSIS - Retrieves build errors and test failures from an Azure DevOps build. - -.DESCRIPTION - Queries the Azure DevOps build timeline to find failed jobs and tasks, - then extracts build errors (MSBuild errors, compilation failures) and - test failures with their details. - -.PARAMETER BuildId - The Azure DevOps build ID. - -.PARAMETER Org - The Azure DevOps organization. Defaults to 'dnceng-public'. - -.PARAMETER Project - The Azure DevOps project. Defaults to 'public'. - -.PARAMETER TestsOnly - If specified, only returns test results (no build errors). - -.PARAMETER ErrorsOnly - If specified, only returns build errors (no test results). - -.PARAMETER JobFilter - Optional filter to match job/task names (supports wildcards). - -.EXAMPLE - ./Get-BuildErrors.ps1 -BuildId 1240456 - -.EXAMPLE - ./Get-BuildErrors.ps1 -BuildId 1240456 -ErrorsOnly - -.EXAMPLE - ./Get-BuildErrors.ps1 -BuildId 1240456 -TestsOnly -JobFilter "*SafeArea*" - -.OUTPUTS - Objects with Type (BuildError/TestFailure), Source, Message, and Details properties. -#> - -[CmdletBinding()] -param( - [Parameter(Mandatory = $true, Position = 0)] - [string]$BuildId, - - [Parameter(Mandatory = $false)] - [string]$Org = "dnceng-public", - - [Parameter(Mandatory = $false)] - [string]$Project = "public", - - [Parameter(Mandatory = $false)] - [switch]$TestsOnly, - - [Parameter(Mandatory = $false)] - [switch]$ErrorsOnly, - - [Parameter(Mandatory = $false)] - [string]$JobFilter -) - -$ErrorActionPreference = "Stop" - -# Get build timeline -$timelineUrl = "https://dev.azure.com/$Org/$Project/_apis/build/builds/${BuildId}/timeline?api-version=7.0" - -try { - $timeline = Invoke-RestMethod -Uri $timelineUrl -Method Get -ContentType "application/json" -} -catch { - Write-Error "Failed to query Azure DevOps timeline API: $_" - exit 1 -} - -$allResults = @() - -# --- SECTION 1: Find Build Errors from Failed Tasks --- -if (-not $TestsOnly) { - $failedTasks = $timeline.records | Where-Object { - $_.type -eq "Task" -and - $_.result -eq "failed" -and - $_.log.url -and - (-not $JobFilter -or $_.name -like $JobFilter) - } - - foreach ($task in $failedTasks) { - Write-Host "Analyzing failed task: $($task.name)" -ForegroundColor Red - - try { - $log = Invoke-RestMethod -Uri $task.log.url -Method Get - $lines = $log -split "`n" - - # Find MSBuild errors and ##[error] markers - $errorLines = $lines | Where-Object { - $_ -match ": error [A-Z]+\d*:" -or # MSBuild errors (CS1234, MT1234, etc.) - $_ -match ": Error :" -or # Xamarin.Shared.Sdk errors - $_ -match "##\[error\]" # Azure DevOps error markers - } - - foreach ($errorLine in $errorLines) { - # Clean up the line - $cleanLine = $errorLine -replace "^\d{4}-\d{2}-\d{2}T[\d:.]+Z\s*", "" - $cleanLine = $cleanLine -replace "##\[error\]", "" - - # Skip generic "exited with code" errors - we want the actual error - if ($cleanLine -match "exited with code") { - continue - } - - $allResults += [PSCustomObject]@{ - Type = "BuildError" - Source = $task.name - Message = $cleanLine.Trim() - Details = "" - } - } - } - catch { - Write-Warning "Failed to fetch log for task $($task.name): $_" - } - } -} - -# --- SECTION 2: Find Test Failures from Jobs --- -if (-not $ErrorsOnly) { - $jobs = $timeline.records | Where-Object { - $_.type -eq "Job" -and - $_.log.url -and - $_.state -eq "completed" -and - $_.result -eq "failed" -and - (-not $JobFilter -or $_.name -like $JobFilter) - } - - foreach ($job in $jobs) { - Write-Host "Analyzing job for test failures: $($job.name)" -ForegroundColor Yellow - - try { - $logContent = Invoke-RestMethod -Uri $job.log.url -Method Get - $lines = $logContent -split "`n" - - # Find test result lines: "Failed [duration]" - for ($i = 0; $i -lt $lines.Count; $i++) { - if ($lines[$i] -match "^\d{4}-\d{2}-\d{2}.*\s+Failed\s+(\S+)\s+\[([^\]]+)\]") { - $testName = $matches[1] - $duration = $matches[2] - - $errorMessage = "" - $stackTrace = "" - - # Look ahead for error message and stack trace - for ($j = $i + 1; $j -lt $lines.Count; $j++) { - $line = $lines[$j] - $cleanLine = $line -replace "^\d{4}-\d{2}-\d{2}T[\d:.]+Z\s*", "" - - if ($cleanLine -match "^\s*Error Message:") { - for ($k = $j + 1; $k -lt [Math]::Min($j + 10, $lines.Count); $k++) { - $msgLine = $lines[$k] -replace "^\d{4}-\d{2}-\d{2}T[\d:.]+Z\s*", "" - if ($msgLine -match "^\s*Stack Trace:" -or [string]::IsNullOrWhiteSpace($msgLine)) { - break - } - $errorMessage += $msgLine.Trim() + " " - } - } - - if ($cleanLine -match "^\s*Stack Trace:") { - for ($k = $j + 1; $k -lt [Math]::Min($j + 5, $lines.Count); $k++) { - $stLine = $lines[$k] -replace "^\d{4}-\d{2}-\d{2}T[\d:.]+Z\s*", "" - if ($stLine -match "at .+ in .+:line \d+") { - $stackTrace = $stLine.Trim() - break - } - } - break - } - - # Stop if we hit the next test - if ($cleanLine -match "^\s*(Passed|Failed|Skipped)\s+\S+\s+\[") { - break - } - } - - $allResults += [PSCustomObject]@{ - Type = "TestFailure" - Source = $job.name - Message = $testName - Details = if ($errorMessage) { "$errorMessage`n$stackTrace".Trim() } else { $stackTrace } - } - } - } - } - catch { - Write-Warning "Failed to fetch log for job $($job.name): $_" - } - } -} - -# Remove duplicate errors (same message from same source) -$uniqueResults = $allResults | Group-Object -Property Type, Source, Message | ForEach-Object { - $_.Group | Select-Object -First 1 -} - -# Summary -$buildErrors = ($uniqueResults | Where-Object { $_.Type -eq "BuildError" }).Count -$testFailures = ($uniqueResults | Where-Object { $_.Type -eq "TestFailure" }).Count - -Write-Host "`nSummary: $buildErrors build error(s), $testFailures test failure(s)" -ForegroundColor Cyan - -$uniqueResults diff --git a/.github/skills/pr-build-status/scripts/Get-BuildInfo.ps1 b/.github/skills/pr-build-status/scripts/Get-BuildInfo.ps1 deleted file mode 100644 index 12fbc89a5541..000000000000 --- a/.github/skills/pr-build-status/scripts/Get-BuildInfo.ps1 +++ /dev/null @@ -1,104 +0,0 @@ -<# -.SYNOPSIS - Retrieves detailed status information for an Azure DevOps build. - -.DESCRIPTION - Queries the Azure DevOps build timeline API and returns comprehensive - information about the build including all stages, their status, and - any failed or canceled jobs. - -.PARAMETER BuildId - The Azure DevOps build ID. - -.PARAMETER Org - The Azure DevOps organization. Defaults to 'dnceng-public'. - -.PARAMETER Project - The Azure DevOps project. Defaults to 'public'. - -.PARAMETER FailedOnly - If specified, only returns failed or canceled stages and jobs. - -.EXAMPLE - ./Get-BuildInfo.ps1 -BuildId 1240455 - -.EXAMPLE - ./Get-BuildInfo.ps1 -BuildId 1240455 -FailedOnly - -.EXAMPLE - ./Get-BuildInfo.ps1 -BuildId 1240455 -Org "dnceng-public" -Project "public" - -.OUTPUTS - Object with BuildId, Status, Result, Stages, and FailedJobs properties. -#> - -[CmdletBinding()] -param( - [Parameter(Mandatory = $true, Position = 0)] - [string]$BuildId, - - [Parameter(Mandatory = $false)] - [string]$Org = "dnceng-public", - - [Parameter(Mandatory = $false)] - [string]$Project = "public", - - [Parameter(Mandatory = $false)] - [switch]$FailedOnly -) - -$ErrorActionPreference = "Stop" - -# Get build info -$buildUrl = "https://dev.azure.com/$Org/$Project/_apis/build/builds/${BuildId}?api-version=7.0" -$timelineUrl = "https://dev.azure.com/$Org/$Project/_apis/build/builds/$BuildId/timeline?api-version=7.0" - -try { - $build = Invoke-RestMethod -Uri $buildUrl -Method Get -ContentType "application/json" - $timeline = Invoke-RestMethod -Uri $timelineUrl -Method Get -ContentType "application/json" -} -catch { - Write-Error "Failed to query Azure DevOps API: $_" - exit 1 -} - -# Extract stages -$stages = $timeline.records | Where-Object { $_.type -eq "Stage" } | ForEach-Object { - [PSCustomObject]@{ - Name = $_.name - State = $_.state - Result = $_.result - } -} | Sort-Object -Property { $_.State -eq "completed" }, { $_.State -eq "inProgress" } - -# Extract failed/canceled jobs -$failedJobs = $timeline.records | - Where-Object { - ($_.type -eq "Stage" -or $_.type -eq "Job") -and - ($_.result -eq "failed" -or $_.result -eq "canceled") - } | - ForEach-Object { - [PSCustomObject]@{ - Name = $_.name - Type = $_.type - Result = $_.result - } - } | Sort-Object -Property Type, Name - -if ($FailedOnly) { - $failedJobs -} -else { - [PSCustomObject]@{ - BuildId = $BuildId - BuildNumber = $build.buildNumber - Status = $build.status - Result = $build.result - Pipeline = $build.definition.name - StartTime = $build.startTime - FinishTime = $build.finishTime - Stages = $stages - FailedJobs = $failedJobs - Link = "https://dev.azure.com/$Org/$Project/_build/results?buildId=$BuildId" - } -} diff --git a/.github/skills/pr-build-status/scripts/Get-HelixLogs.ps1 b/.github/skills/pr-build-status/scripts/Get-HelixLogs.ps1 deleted file mode 100644 index 64a2801aabeb..000000000000 --- a/.github/skills/pr-build-status/scripts/Get-HelixLogs.ps1 +++ /dev/null @@ -1,247 +0,0 @@ -<# -.SYNOPSIS - Retrieves Helix console logs for failed work items from an Azure DevOps build. - -.DESCRIPTION - Parses Azure DevOps build logs to extract Helix job IDs, then queries the Helix API - to get console logs for failed work items. This is useful for debugging any test - failures that run on Helix infrastructure (device tests, integration tests, etc.). - -.PARAMETER BuildId - The Azure DevOps build ID. - -.PARAMETER Org - The Azure DevOps organization. Defaults to 'dnceng-public'. - -.PARAMETER Project - The Azure DevOps project. Defaults to 'public'. - -.PARAMETER Platform - Optional filter for platform (e.g., 'Windows', 'iOS', 'Android', 'MacCatalyst'). - -.PARAMETER WorkItem - Optional filter for specific work item name (supports wildcards). - -.PARAMETER ShowConsoleLog - If specified, displays the full console log content for each failed work item. - -.PARAMETER TailLines - Number of lines to show from the end of console logs. Default is 100. - -.EXAMPLE - ./Get-HelixLogs.ps1 -BuildId 1255952 - -.EXAMPLE - ./Get-HelixLogs.ps1 -BuildId 1255952 -Platform Windows -ShowConsoleLog - -.EXAMPLE - ./Get-HelixLogs.ps1 -BuildId 1255952 -WorkItem "*Lifecycle*" -ShowConsoleLog -TailLines 200 - -.OUTPUTS - Objects with JobId, WorkItem, Queue, ConsoleUrl, and optionally ConsoleLog properties. -#> - -[CmdletBinding()] -param( - [Parameter(Mandatory = $true, Position = 0)] - [string]$BuildId, - - [Parameter(Mandatory = $false)] - [string]$Org = "dnceng-public", - - [Parameter(Mandatory = $false)] - [string]$Project = "public", - - [Parameter(Mandatory = $false)] - [string]$Platform, - - [Parameter(Mandatory = $false)] - [string]$WorkItem, - - [Parameter(Mandatory = $false)] - [switch]$ShowConsoleLog, - - [Parameter(Mandatory = $false)] - [int]$TailLines = 100 -) - -$ErrorActionPreference = "Stop" - -# Get build timeline -$timelineUrl = "https://dev.azure.com/$Org/$Project/_apis/build/builds/${BuildId}/timeline?api-version=7.0" - -try { - $timeline = Invoke-RestMethod -Uri $timelineUrl -Method Get -ContentType "application/json" -} -catch { - Write-Error "Failed to query Azure DevOps timeline API: $_" - exit 1 -} - -# Build platform filter pattern -$platformPattern = if ($Platform) { - switch ($Platform.ToLower()) { - "windows" { "*Windows*" } - "ios" { "*iOS*" } - "android" { "*Android*" } - "maccatalyst" { "*MacCatalyst*|*Catalyst*" } - default { "*$Platform*" } - } -} else { - "*" -} - -# Find Helix-related tasks with logs (looking for DeviceTests tasks that submit to Helix) -$helixTasks = $timeline.records | Where-Object { - $_.name -like "*DeviceTests*" -and - $_.log.url -and - ($_.result -eq "failed" -or $_.result -eq "succeeded") -and - (-not $Platform -or $_.name -like $platformPattern) -} - -if (-not $helixTasks) { - Write-Host "No Helix-related tasks found in build $BuildId" -ForegroundColor Yellow - exit 0 -} - -$allHelixJobs = @{} -$allResults = @() - -foreach ($task in $helixTasks) { - Write-Host "Scanning task: $($task.name) [$($task.result)]" -ForegroundColor $(if ($task.result -eq "failed") { "Red" } else { "Gray" }) - - try { - $logContent = Invoke-RestMethod -Uri $task.log.url -Method Get - - # Extract Helix job IDs from log (pattern: "jobs/{guid}/workitems") - $jobMatches = [regex]::Matches($logContent, "jobs/([a-f0-9-]{36})/workitems") - - foreach ($match in $jobMatches) { - $jobId = $match.Groups[1].Value - if (-not $allHelixJobs.ContainsKey($jobId)) { - $allHelixJobs[$jobId] = @{ - Task = $task.name - Result = $task.result - } - } - } - } - catch { - Write-Warning "Failed to fetch log for task $($task.name): $_" - } -} - -if ($allHelixJobs.Count -eq 0) { - Write-Host "No Helix job IDs found in build logs" -ForegroundColor Yellow - exit 0 -} - -Write-Host "`nFound $($allHelixJobs.Count) Helix job(s)" -ForegroundColor Cyan - -# Query each Helix job for work items -foreach ($jobId in $allHelixJobs.Keys) { - $jobInfo = $allHelixJobs[$jobId] - Write-Host "`n--- Helix Job: $jobId ---" -ForegroundColor Yellow - Write-Host "From task: $($jobInfo.Task)" -ForegroundColor Gray - - try { - # Get job details for queue info - $jobDetailsUrl = "https://helix.dot.net/api/jobs/${jobId}/details?api-version=2019-06-17" - $jobDetails = Invoke-RestMethod -Uri $jobDetailsUrl -Method Get - $queue = $jobDetails.QueueId - - Write-Host "Queue: $queue" -ForegroundColor Gray - - # Get work items - $workItemsUrl = "https://helix.dot.net/api/jobs/${jobId}/workitems?api-version=2019-06-17" - $workItems = Invoke-RestMethod -Uri $workItemsUrl -Method Get - - foreach ($wi in $workItems) { - # Skip the controller work item - if ($wi.Name -eq "HelixController Work Queueing") { - continue - } - - # Apply work item filter if specified - if ($WorkItem -and $wi.Name -notlike $WorkItem) { - continue - } - - $consoleUrl = "https://helix.dot.net/api/2019-06-17/jobs/${jobId}/workitems/$($wi.Name)/console" - - # Determine if this is a failure - # Check: 1) ExitCode non-zero, 2) Parent task failed - $isFailed = $false - if ($wi.ExitCode -and $wi.ExitCode -ne 0) { - $isFailed = $true - } - elseif ($jobInfo.Result -eq "failed") { - $isFailed = $true - } - - # For work items from failed tasks, always try to get console log to check for errors - $shouldFetchLog = $ShowConsoleLog -and ($isFailed -or $jobInfo.Result -eq "failed") - - $consoleLogContent = $null - if ($shouldFetchLog) { - Write-Host "`nFetching console log for: $($wi.Name)" -ForegroundColor Cyan - - try { - $consoleLog = Invoke-RestMethod -Uri $consoleUrl -Method Get - - # Check if the log indicates a failure (even if ExitCode wasn't set) - if ($consoleLog -match "exited with (code )?1\]|ERROR:|FAILED|exception|crash") { - $isFailed = $true - } - - # Get tail lines - $lines = $consoleLog -split "`n" - if ($TailLines -gt 0 -and $lines.Count -gt $TailLines) { - $consoleLogContent = ($lines | Select-Object -Last $TailLines) -join "`n" - Write-Host "... (showing last $TailLines of $($lines.Count) lines)" -ForegroundColor Gray - } - else { - $consoleLogContent = $consoleLog - } - - Write-Host $consoleLogContent - } - catch { - Write-Warning "Failed to fetch console log: $_" - } - } - - $result = [PSCustomObject]@{ - JobId = $jobId - WorkItem = $wi.Name - State = $wi.State - ExitCode = $wi.ExitCode - Queue = $queue - IsFailed = $isFailed - ConsoleUrl = $consoleUrl - ConsoleLog = $consoleLogContent - } - - $allResults += $result - - # Print summary line - $statusColor = if ($isFailed) { "Red" } else { "Green" } - $statusSymbol = if ($isFailed) { "X" } else { "√" } - Write-Host " [$statusSymbol] $($wi.Name) (Exit: $($wi.ExitCode))" -ForegroundColor $statusColor - } - } - catch { - Write-Warning "Failed to query Helix job $jobId`: $_" - } -} - -# Summary -$failedCount = ($allResults | Where-Object { $_.IsFailed }).Count -$totalCount = $allResults.Count - -Write-Host "`n========================================" -ForegroundColor Cyan -Write-Host "Summary: $failedCount failed / $totalCount total work items" -ForegroundColor $(if ($failedCount -gt 0) { "Red" } else { "Green" }) -Write-Host "========================================" -ForegroundColor Cyan - -# Output results -$allResults diff --git a/.github/skills/pr-build-status/scripts/Get-PrBuildIds.ps1 b/.github/skills/pr-build-status/scripts/Get-PrBuildIds.ps1 deleted file mode 100644 index 8df3966a3577..000000000000 --- a/.github/skills/pr-build-status/scripts/Get-PrBuildIds.ps1 +++ /dev/null @@ -1,65 +0,0 @@ -<# -.SYNOPSIS - Retrieves Azure DevOps build IDs associated with a GitHub PR. - -.DESCRIPTION - Queries GitHub PR checks and extracts the Azure DevOps build IDs, - pipeline names, states, and links for each unique build. - -.PARAMETER PrNumber - The GitHub Pull Request number. - -.PARAMETER Repo - The GitHub repository in 'owner/repo' format. Defaults to 'dotnet/maui'. - -.EXAMPLE - ./Get-PrBuildIds.ps1 -PrNumber 33251 - -.EXAMPLE - ./Get-PrBuildIds.ps1 -PrNumber 33251 -Repo "dotnet/maui" - -.OUTPUTS - Array of objects with Pipeline, BuildId, State, and Link properties. -#> - -[CmdletBinding()] -param( - [Parameter(Mandatory = $true, Position = 0)] - [int]$PrNumber, - - [Parameter(Mandatory = $false)] - [string]$Repo = "dotnet/maui" -) - -$ErrorActionPreference = "Stop" - -# Validate prerequisites -if (-not (Get-Command "gh" -ErrorAction SilentlyContinue)) { - Write-Error "GitHub CLI (gh) is not installed. Install from https://cli.github.com/" - exit 1 -} - -# Get PR checks from GitHub -$checksJson = gh pr checks $PrNumber --repo $Repo --json name,link,state 2>&1 - -if ($LASTEXITCODE -ne 0) { - Write-Error "Failed to get PR checks: $checksJson" - exit 1 -} - -$checks = $checksJson | ConvertFrom-Json - -# Filter to Azure DevOps checks and extract build IDs -$builds = $checks | Where-Object { $_.link -match "dev\.azure\.com" } | ForEach-Object { - $buildId = if ($_.link -match "buildId=(\d+)") { $matches[1] } else { $null } - $pipeline = ($_.name -split " ")[0] - - [PSCustomObject]@{ - Pipeline = $pipeline - BuildId = $buildId - State = $_.state - Link = $_.link - } -} | Sort-Object -Property Pipeline, BuildId -Unique - -$builds diff --git a/.github/skills/pr-finalize/SKILL.md b/.github/skills/pr-finalize/SKILL.md index c1c8af7144de..28ba6916dd8b 100644 --- a/.github/skills/pr-finalize/SKILL.md +++ b/.github/skills/pr-finalize/SKILL.md @@ -1,13 +1,13 @@ --- name: pr-finalize -description: Finalizes any PR for merge by verifying title/description match implementation AND performing code review for best practices. Use when asked to "finalize PR", "check PR description", "review commit message", before merging any PR, or when PR implementation changed during review. Do NOT use for extracting lessons (use learn-from-pr), writing tests (use write-tests-agent), or investigating build failures (use pr-build-status). +description: Finalizes any PR for merge by verifying title/description match implementation AND performing code review for best practices. Use when asked to "finalize PR", "check PR description", "review commit message", before merging any PR, or when PR implementation changed during review. Do NOT use for extracting lessons (use learn-from-pr), writing tests (use write-tests-agent), or investigating build failures (use azdo-build-investigator and ci-analysis). --- # PR Finalize Ensures PR title and description accurately reflect the implementation, and performs a **code review** for best practices before merge. -**Standalone skill** - Can be used on any PR, not just PRs created by the pr agent. +**Standalone skill** - Can be used on any PR, not just PRs reviewed by the pr-review skill. ## Two-Phase Workflow @@ -33,13 +33,13 @@ Ensures PR title and description accurately reflect the implementation, and perf | Action | Allowed? | Why | |--------|----------|-----| -| `gh pr review --comment` | ❌ **NEVER** | Use ai-summary-comment skill instead | -| `gh pr comment` | ❌ **NEVER** | Use ai-summary-comment skill instead | +| `gh pr review --comment` | ❌ **NEVER** | Review-PR.ps1 handles posting via scripts | +| `gh pr comment` | ❌ **NEVER** | Review-PR.ps1 handles posting via scripts | | Analyze and report findings | ✅ **YES** | This is the skill's purpose | **Correct workflow:** -1. **This skill**: Analyze PR, produce findings in your response to the user -2. **User explicitly asks to post comment**: Then invoke `ai-summary-comment` skill +1. **This skill**: Analyze PR, produce findings and write to `pr-finalize-summary.md` +2. **Review-PR.ps1** calls `post-pr-finalize-comment.ps1` to post the summary **Only humans control when comments are posted.** Your job is to analyze and present findings. @@ -371,13 +371,13 @@ gh pr diff XXXXX -- path/to/file.cs | Action | Allowed? | Why | |--------|----------|-----| -| `gh pr review --comment` | ❌ **NEVER** | Use ai-summary-comment skill instead | -| `gh pr comment` | ❌ **NEVER** | Use ai-summary-comment skill instead | +| `gh pr review --comment` | ❌ **NEVER** | Review-PR.ps1 handles posting via scripts | +| `gh pr comment` | ❌ **NEVER** | Review-PR.ps1 handles posting via scripts | | Analyze and report findings | ✅ **YES** | This is the skill's purpose | **Workflow:** -1. **This skill**: Analyze PR, produce findings in your response -2. **User asks to post**: Then invoke `ai-summary-comment` skill to post +1. **This skill**: Analyze PR, produce findings and write to `pr-finalize-summary.md` +2. **Review-PR.ps1** calls `post-pr-finalize-comment.ps1` to post the summary The user controls when comments are posted. Your job is to analyze and present findings. diff --git a/.github/skills/pr-review/SKILL.md b/.github/skills/pr-review/SKILL.md new file mode 100644 index 000000000000..a3772b0dbd8c --- /dev/null +++ b/.github/skills/pr-review/SKILL.md @@ -0,0 +1,255 @@ +--- +name: pr-review +description: "End-to-end PR reviewer for dotnet/maui. Orchestrates 4 phases — Pre-Flight, Gate, Try-Fix, Report. Use when asked to 'review PR #XXXXX', 'work on PR #XXXXX', or 'fix issue #XXXXX'." +--- + +# PR Review — 4-Phase Orchestrator + +End-to-end PR review workflow that orchestrates phases to verify tests, explore independent fix alternatives, and produce a recommendation. + +**Trigger phrases:** "review PR #XXXXX", "work on PR #XXXXX", "fix issue #XXXXX" + +> 🚨 **NEVER** use `gh pr review --approve` or `--request-changes`. AI agents must NEVER post review comments. +> 🚨 **DO NOT post any comments to the PR.** This skill only produces output files in `CustomAgentLogsTmp/PRState/`. + +--- + +## Overview + +``` +Phase 1: Pre-Flight → Gather context, classify files → .github/pr-review/pr-preflight.md +Phase 2: Gate → ⛔ MUST PASS — verify tests FAIL/PASS → .github/pr-review/pr-gate.md +Phase 3: Try-Fix → ⚠️ MANDATORY multi-model exploration → invoke try-fix skill (×4 models) +Phase 4: Report → Write review recommendation → .github/pr-review/pr-report.md +``` + +> **Branch setup** is handled by `Review-PR.ps1` before this skill is invoked. By the time this skill runs, the review branch already exists with the PR commits cherry-picked and squashed. + +**All phases write output to:** `CustomAgentLogsTmp/PRState/{PRNumber}/PRAgent/{phase}/content.md` + +--- + +## Critical Rules + +- ❌ Never run `git checkout` or `git switch` to change branches — stay on the review branch set up by the caller +- ❌ Never stop and ask the user — use best judgment to skip blocked phases and continue +- ❌ Never mark a phase complete with pending fields +- ❌ **Never skip Phase 3 multi-model exploration — it is MANDATORY for every review, no exceptions** +- ❌ Never run git commands that change branch state during Phases 2-3 (scripts handle file manipulation) +- ✅ Always create `CustomAgentLogsTmp/` output files for every phase +- ✅ Always include `Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>` in any commits +- ✅ Always use skills' scripts — don't bypass with manual commands + +### Multi-Model Configuration + +Phase 3 uses these 4 AI models (run SEQUENTIALLY — they modify the same files): + +| Order | Model | +|-------|-------| +| 1 | `claude-opus-4.6` | +| 2 | `claude-sonnet-4.6` | +| 3 | `gpt-5.3-codex` | +| 4 | `gemini-3-pro-preview` | + +**🚨 MANDATORY: Use `mode: "sync"` for ALL try-fix task invocations.** Never use `mode: "background"`. Background mode causes the orchestrator to move on before the attempt finishes, which means `try-fix/content.md` is never written and try-fix results are lost from the PR comment. Each try-fix task MUST complete and return its result before you proceed to the next attempt or to the Phase 3 completion checklist. + +### Environment Blockers + +| Blocker Type | Max Retries | Then Do | +|--------------|-------------|---------| +| Missing tool/driver | 1 install attempt | Skip phase, continue | +| Server errors (500, timeout) | 1 retry | Skip phase, continue | +| Port conflicts | 1 (kill process) | Skip phase, continue | +| Build failures in try-fix | 2 attempts | Skip remaining models, proceed to Report | +| Configuration issues | 1 fix attempt | Skip phase, continue | + +--- + +## Phase 1: Pre-Flight + +> Read and follow `.github/pr-review/pr-preflight.md` + +Gather context from the issue, PR, comments, and classify changed files. + +**Gate:** None — always runs. + +--- + +## Phase 2: Gate + +> Read and follow `.github/pr-review/pr-gate.md` + +Verify that the PR's tests actually catch the bug (FAIL without fix, PASS with fix). + +**Gate:** Pre-Flight must be ✅ COMPLETE. + +**If Gate fails:** +- Tests PASS without fix → Tests don't catch the bug. Proceed to Try-Fix anyway. +- Tests FAIL with fix → PR's fix doesn't work. Skip Try-Fix, proceed to Report. + +--- + +## Phase 3: Try-Fix → Invoke `try-fix` Skill (×4 Models) + +> Read and follow `.github/skills/try-fix/SKILL.md` + +> **⚠️ THIS PHASE IS MANDATORY. YOU MUST NEVER SKIP IT. NO EXCEPTIONS.** + +Even if the PR's fix looks correct and Gate passed, you MUST still run all 4 models to explore alternative approaches. The purpose is to find the BEST fix, not just validate one. + +### 🚨 CRITICAL: try-fix is Independent of PR's Fix + +The purpose 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 + +### Checklist (you MUST complete ALL of these) + +- [ ] Attempt 1 launched with claude-opus-4.6 +- [ ] `try-fix/content.md` updated with attempt 1 result +- [ ] Attempt 2 launched with claude-sonnet-4.6 +- [ ] `try-fix/content.md` updated with attempt 2 result +- [ ] Attempt 3 launched with gpt-5.3-codex +- [ ] `try-fix/content.md` updated with attempt 3 result +- [ ] Attempt 4 launched with gemini-3-pro-preview +- [ ] `try-fix/content.md` updated with attempt 4 result +- [ ] Cross-pollination round completed (all models queried) +- [ ] Best fix selected with comparison table + +### Round 1: Independent Exploration + +For each model, invoke `try-fix` skill via a `general-purpose` task agent with that model: + +``` +prompt: | + Invoke the try-fix skill for PR #XXXXX: + - problem: {bug description from Pre-Flight} + - platform: {platform from Platform Selection} + - test_command: pwsh .github/scripts/BuildAndRunHostApp.ps1 -Platform {platform} -TestFilter "IssueXXXXX" + - target_files: + - src/{area}/{file1}.cs + - src/{area}/{file2}.cs + + 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.** + +**🧹 MANDATORY: Clean up between attempts:** + +```bash +# Restore baseline from previous attempt — this is the ONLY way to restore. +# Do NOT use manual git checkout/restore/reset commands. +pwsh .github/scripts/EstablishBrokenBaseline.ps1 -Restore +``` + +**📝 MANDATORY: Update `try-fix/content.md` after EVERY attempt.** Do not wait until all attempts are done. After each try-fix attempt completes (pass or fail), immediately write/update `CustomAgentLogsTmp/PRState/{PRNumber}/PRAgent/try-fix/content.md` with all results so far. This ensures the PR comment always reflects the latest try-fix state, even if a later attempt times out or the agent is interrupted. + +### Round 2+: Cross-Pollination (MANDATORY) + +After Round 1, invoke EACH model via task agent: +``` +"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'" +``` + +Run any new ideas as additional try-fix attempts. Repeat until all say "NO NEW IDEAS" (max 3 rounds). + +### Selecting the Best Fix + +Compare all passing candidates on: +1. **Must pass tests** — Only consider ✅ PASS candidates +2. **Simplest solution** — Fewer files, fewer lines +3. **Most robust** — Handles edge cases +4. **Matches codebase style** — Consistent with existing patterns + +### Output File + +```bash +mkdir -p CustomAgentLogsTmp/PRState/{PRNumber}/PRAgent/try-fix +``` + +Write `content.md`: +```markdown +### Fix Candidates +| # | Source | Approach | Test Result | Files Changed | Notes | +|---|--------|----------|-------------|---------------|-------| +| 1 | try-fix | {approach} | ✅/❌ | 1 file | {insight} | +| ... | ... | ... | ... | ... | ... | +| PR | PR #XXXXX | {approach} | ✅ PASSED (Gate) | 2 files | Original PR | + +### Cross-Pollination +| Model | Round | New Ideas? | Details | +|-------|-------|------------|---------| +| ... | 2 | Yes/No | {idea or "NO NEW IDEAS"} | + +**Exhausted:** {Yes/No} +**Selected Fix:** {PR's fix / Candidate #N} — {Reason} +``` + +### Common Mistakes + +- ❌ Looking at PR's fix before generating ideas — generate independently first +- ❌ Running try-fix in parallel — SEQUENTIAL ONLY, always `mode: "sync"` +- ❌ Using `mode: "background"` for try-fix tasks — results will be lost +- ❌ Skipping cleanup between attempts — ALWAYS run cleanup commands +- ❌ Declaring exhaustion without querying all 4 models + +--- + +## Phase 4: Report + +> Read and follow `.github/pr-review/pr-report.md` + +Deliver the final review recommendation. + +> 🚨 **DO NOT post any comments.** All output goes to `CustomAgentLogsTmp/PRState/`. + +**Gate:** Phases 1-3 must be complete. + +--- + +## Output Directory Structure (MANDATORY) + +``` +CustomAgentLogsTmp/PRState/{PRNumber}/PRAgent/ +├── pre-flight/ +│ └── content.md # Phase 1 output (pr-preflight) +├── gate/ +│ └── content.md # Phase 2 output (pr-gate) +├── try-fix/ +│ ├── content.md # Phase 3 summary +│ └── attempt-{N}/ # Per-model attempt +│ ├── approach.md # What was tried +│ ├── result.txt # Pass / Fail / Blocked +│ ├── fix.diff # git diff of changes +│ └── analysis.md # Why it worked/failed +└── report/ + └── content.md # Phase 4 output (pr-report) +``` + +--- + +## Quick Reference + +| Phase | Instructions | Key Action | If Blocked | +|-------|--------------|------------|------------| +| 1. Pre-Flight | `pr-preflight.md` | Read issue + PR context | Skip missing info, continue | +| 2. Gate | `pr-gate.md` | Verify tests via task agent | Document, continue to Try-Fix | +| 3. Try-Fix | `try-fix` skill (×4) | **4-model exploration (MANDATORY)** | Skip failing models, continue | +| 4. Report | `pr-report.md` | Write review recommendation | Never skip | + +--- + +## Common Errors and Recovery + +| Error | Cause | Fix | +|-------|-------|-----| +| `ENOENT: no such file` on skill | Dirty working tree from prior attempt | Run cleanup: `-Restore` + `git checkout HEAD -- .` + `git clean -fd --exclude=CustomAgentLogsTmp/` | +| Dirty working tree before attempt | Prior attempt didn't restore | Same cleanup as above | +| Build errors in unmodified files | Stale state | Cleanup + retry; if still fails, treat as environment blocker | diff --git a/.github/skills/run-device-tests/SKILL.md b/.github/skills/run-device-tests/SKILL.md index bd8d7b842ffa..5ad415535a11 100644 --- a/.github/skills/run-device-tests/SKILL.md +++ b/.github/skills/run-device-tests/SKILL.md @@ -54,6 +54,7 @@ These are automatically loaded by the Run-DeviceTests.ps1 script. | Essentials | `src/Essentials/test/DeviceTests/Essentials.DeviceTests.csproj` | | Graphics | `src/Graphics/tests/DeviceTests/Graphics.DeviceTests.csproj` | | BlazorWebView | `src/BlazorWebView/tests/DeviceTests/MauiBlazorWebView.DeviceTests.csproj` | +| AI | `src/AI/tests/Essentials.AI.DeviceTests/Essentials.AI.DeviceTests.csproj` | ## Scripts diff --git a/.github/skills/run-device-tests/scripts/Run-DeviceTests.ps1 b/.github/skills/run-device-tests/scripts/Run-DeviceTests.ps1 index f94b2d5b044e..3f5620b79684 100644 --- a/.github/skills/run-device-tests/scripts/Run-DeviceTests.ps1 +++ b/.github/skills/run-device-tests/scripts/Run-DeviceTests.ps1 @@ -12,7 +12,7 @@ - Windows: android, windows .PARAMETER Project - The device test project to run. Valid values: Controls, Core, Essentials, Graphics, BlazorWebView + The device test project to run. Valid values: Controls, Core, Essentials, Graphics, BlazorWebView, AI .PARAMETER Platform Target platform. Valid values depend on OS: @@ -65,7 +65,7 @@ [CmdletBinding()] param( [Parameter(Mandatory = $true, Position = 0)] - [ValidateSet("Controls", "Core", "Essentials", "Graphics", "BlazorWebView")] + [ValidateSet("Controls", "Core", "Essentials", "Graphics", "BlazorWebView", "AI")] [string]$Project, [Parameter(Mandatory = $false)] @@ -128,6 +128,7 @@ $ProjectPaths = @{ "Essentials" = "src/Essentials/test/DeviceTests/Essentials.DeviceTests.csproj" "Graphics" = "src/Graphics/tests/DeviceTests/Graphics.DeviceTests.csproj" "BlazorWebView" = "src/BlazorWebView/tests/DeviceTests/MauiBlazorWebView.DeviceTests.csproj" + "AI" = "src/AI/tests/Essentials.AI.DeviceTests/Essentials.AI.DeviceTests.csproj" } $AppNames = @{ @@ -136,6 +137,7 @@ $AppNames = @{ "Essentials" = "Microsoft.Maui.Essentials.DeviceTests" "Graphics" = "Microsoft.Maui.Graphics.DeviceTests" "BlazorWebView" = "Microsoft.Maui.MauiBlazorWebView.DeviceTests" + "AI" = "Microsoft.Maui.Essentials.AI.DeviceTests" } # Android package names (lowercase) @@ -145,6 +147,7 @@ $AndroidPackageNames = @{ "Essentials" = "com.microsoft.maui.essentials.devicetests" "Graphics" = "com.microsoft.maui.graphics.devicetests" "BlazorWebView" = "com.microsoft.maui.mauiblazorwebview.devicetests" + "AI" = "com.microsoft.maui.ai.devicetests" } # Platform-specific configurations @@ -239,6 +242,8 @@ try { $projectPath = $ProjectPaths[$Project] $appName = $AppNames[$Project] + # Derive artifact folder name from the project file name (e.g., "Essentials.AI.DeviceTests" from the .csproj) + $artifactName = [System.IO.Path]::GetFileNameWithoutExtension($projectPath) Write-Host "" Write-Host "Project: $Project" -ForegroundColor Yellow @@ -316,11 +321,11 @@ try { # Construct app path based on platform switch ($Platform) { "ios" { - $appPath = "artifacts/bin/$Project.DeviceTests/$Configuration/$tfmFolder/$ridFolder/$appName.app" + $appPath = "artifacts/bin/$artifactName/$Configuration/$tfmFolder/$ridFolder/$appName.app" } "maccatalyst" { # MacCatalyst apps may have different names - search for .app bundle - $appSearchPath = "artifacts/bin/$Project.DeviceTests/$Configuration/$tfmFolder/$ridFolder" + $appSearchPath = "artifacts/bin/$artifactName/$Configuration/$tfmFolder/$ridFolder" $appBundle = Get-ChildItem -Path $appSearchPath -Filter "*.app" -Directory -ErrorAction SilentlyContinue | Select-Object -First 1 if ($appBundle) { $appPath = $appBundle.FullName @@ -330,7 +335,7 @@ try { } "android" { # Android APK path - look for signed APK - $apkSearchPath = "artifacts/bin/$Project.DeviceTests/$Configuration/$tfmFolder" + $apkSearchPath = "artifacts/bin/$artifactName/$Configuration/$tfmFolder" $apkFile = Get-ChildItem -Path $apkSearchPath -Filter "*-Signed.apk" -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 if ($apkFile) { $appPath = $apkFile.FullName @@ -345,14 +350,14 @@ try { } } "windows" { - $appPath = "artifacts/bin/$Project.DeviceTests/$Configuration/$tfmFolder/$ridFolder/$appName.exe" + $appPath = "artifacts/bin/$artifactName/$Configuration/$tfmFolder/$ridFolder/$appName.exe" } } if (-not (Test-Path $appPath)) { Write-Error "Built app not found at: $appPath" Write-Info "Searching for app in artifacts..." - Get-ChildItem -Path "artifacts/bin/$Project.DeviceTests" -Recurse -ErrorAction SilentlyContinue | + Get-ChildItem -Path "artifacts/bin/$artifactName" -Recurse -ErrorAction SilentlyContinue | Where-Object { $_.Name -match "$appName" } | ForEach-Object { Write-Host " Found: $($_.FullName)" } exit 1 diff --git a/.github/skills/try-fix/SKILL.md b/.github/skills/try-fix/SKILL.md index 291283981e3c..03ed7e57644e 100644 --- a/.github/skills/try-fix/SKILL.md +++ b/.github/skills/try-fix/SKILL.md @@ -47,7 +47,6 @@ All inputs are provided by the invoker (CI, agent, or user). | Platform | Yes | Target platform (`android`, `ios`, `windows`, `maccatalyst`) | | Hints | Optional | Suggested approaches, prior attempts, or areas to focus on | | Baseline | Optional | Git ref or instructions for establishing broken state (default: current state) | -| state_file | Optional | Path to PR agent state file (e.g., `CustomAgentLogsTmp/PRState/pr-12345.md`). If provided, try-fix will append its results to the Fix Candidates table. | ## Outputs @@ -70,7 +69,7 @@ Results reported back to the invoker: $IssueNumber = "" # Replace with actual number # Find next attempt number -$tryFixDir = "CustomAgentLogsTmp/PRState/$IssueNumber/try-fix" +$tryFixDir = "CustomAgentLogsTmp/PRState/$IssueNumber/PRAgent/try-fix" $existingAttempts = (Get-ChildItem "$tryFixDir/attempt-*" -Directory -ErrorAction SilentlyContinue).Count $attemptNum = $existingAttempts + 1 @@ -163,9 +162,8 @@ The skill is complete when: - Review what files were changed - Read the actual code changes to understand the current fix approach -2. **If state_file provided, review prior attempts:** - - Read the Fix Candidates table - - Note which approaches failed and WHY (the Notes column) +2. **Review prior attempts if any are known:** + - Note which approaches failed and WHY - Note which approaches partially succeeded 3. **Identify what makes your approach DIFFERENT:** @@ -188,22 +186,21 @@ The skill is complete when: ### Step 2: Establish Baseline (MANDATORY) -🚨 **ALWAYS use EstablishBrokenBaseline.ps1 - NEVER manually revert files.** +🚨 **ONLY use EstablishBrokenBaseline.ps1 — NEVER use `git checkout`, `git restore`, or `git reset` to revert fix files.** + +The script auto-restores any previous baseline, tracks state, and prevents loops. +Manual git commands bypass all of this and WILL cause infinite loops in CI. ```powershell -# Capture baseline output as proof it was run pwsh .github/scripts/EstablishBrokenBaseline.ps1 *>&1 | Tee-Object -FilePath "$OUTPUT_DIR/baseline.log" ``` -The script auto-detects and reverts fix files to merge-base state while preserving test files. **Will fail fast if no fix files detected** - you must be on the actual PR branch. Optional flags: `-BaseBranch main`, `-DryRun`. - **Verify baseline was established:** ```powershell -# baseline.log should contain "Baseline established" and list of reverted files Select-String -Path "$OUTPUT_DIR/baseline.log" -Pattern "Baseline established" ``` -**If the script fails with "No fix files detected":** You're likely on the wrong branch. Checkout the actual PR branch with `gh pr checkout ` and try again. +**If the script fails with "No fix files detected":** Report as `Blocked` — do NOT switch branches. **If something fails mid-attempt:** `pwsh .github/scripts/EstablishBrokenBaseline.ps1 -Restore` @@ -318,9 +315,10 @@ git diff | Set-Content "$OUTPUT_DIR/fix.diff" ```bash pwsh .github/scripts/EstablishBrokenBaseline.ps1 -Restore -git checkout HEAD -- . ``` +🚨 Do NOT use `git checkout HEAD -- .` or `git clean` to restore — use the script. + ### Step 9: Report Results Provide structured output to the invoker: @@ -349,29 +347,6 @@ Provide structured output to the invoker: **Determining Status:** Set `Done` when you've completed testing this approach (whether it passed or failed). Set `NeedsRetry` only if you hit a transient error (network timeout, flaky test) and want to retry the same approach. -### Step 10: Update State File (if provided) - -If `state_file` input was provided and file exists: - -1. **Read current Fix Candidates table** from state file -2. **Determine next attempt number** (count existing try-fix rows + 1) -3. **Append new row** with this attempt's results: - -| # | Source | Approach | Test Result | Files Changed | Notes | -|---|--------|----------|-------------|---------------|-------| -| N | try-fix #N | [approach] | ✅ PASS / ❌ FAIL | [files] | [analysis] | - -**If no state file provided:** Skip this step (results returned to invoker only). - -**⚠️ Do NOT `git add` or `git commit` the state file.** It lives in `CustomAgentLogsTmp/` which is `.gitignore`d. Committing it with `git add -f` would cause `git checkout HEAD -- .` (used between phases) to revert it, losing data. - -**⚠️ IMPORTANT: Do NOT set any "Exhausted" field.** Cross-pollination exhaustion is determined by the pr agent after invoking ALL 6 models and confirming none have new ideas. try-fix only reports its own attempt result. - -**Ownership rule:** try-fix updates its own row ONLY. Never modify: -- Phase status fields -- "Selected Fix" field -- Other try-fix rows - ## Error Handling | Situation | Action | diff --git a/.github/skills/verify-tests-fail-without-fix/SKILL.md b/.github/skills/verify-tests-fail-without-fix/SKILL.md index ba3df1d1aaa6..201258220d4d 100644 --- a/.github/skills/verify-tests-fail-without-fix/SKILL.md +++ b/.github/skills/verify-tests-fail-without-fix/SKILL.md @@ -94,7 +94,7 @@ The script auto-detects which mode to use based on whether fix files are present 7. Runs tests (should PASS with fix) 8. **Generates markdown reports**: - `CustomAgentLogsTmp/TestValidation/verification-report.md` - Full detailed report - - `CustomAgentLogsTmp/PRState/verification-report.md` - Gate section for PR agent + - `CustomAgentLogsTmp/PRState/verification-report.md` - Validate section for agent 9. **Updates PR labels** based on result 10. Reports result diff --git a/.github/skills/verify-tests-fail-without-fix/scripts/verify-tests-fail.ps1 b/.github/skills/verify-tests-fail-without-fix/scripts/verify-tests-fail.ps1 index de3bad6fee15..5eacfed3135a 100644 --- a/.github/skills/verify-tests-fail-without-fix/scripts/verify-tests-fail.ps1 +++ b/.github/skills/verify-tests-fail-without-fix/scripts/verify-tests-fail.ps1 @@ -126,14 +126,14 @@ if (-not $PRNumber) { } if (-not $foundPR) { - Write-Host "⚠️ Could not auto-detect PR number - using 'unknown' folder" -ForegroundColor Yellow - $PRNumber = "unknown" + Write-Error "Could not auto-detect PR number. Please provide -PRNumber parameter." + exit 1 } } } # Set output directory based on PR number -$OutputDir = "CustomAgentLogsTmp/PRState/$PRNumber/verify-tests-fail" +$OutputDir = "CustomAgentLogsTmp/PRState/$PRNumber/PRAgent/gate/verify-tests-fail" Write-Host "📁 Output directory: $OutputDir" -ForegroundColor Cyan # ============================================================ @@ -144,59 +144,6 @@ $BaselineScript = Join-Path $RepoRoot ".github/scripts/EstablishBrokenBaseline.p # Import Test-IsTestFile and Find-MergeBase from shared script . $BaselineScript -# ============================================================ -# Label management for verification results -# ============================================================ -$LabelConfirmed = "s/ai-reproduction-confirmed" -$LabelFailed = "s/ai-reproduction-failed" - -function Update-VerificationLabels { - param( - [Parameter(Mandatory = $true)] - [bool]$ReproductionConfirmed, - - [Parameter(Mandatory = $false)] - [string]$PR = $PRNumber - ) - - if ($PR -eq "unknown" -or -not $PR) { - Write-Host "⚠️ Cannot update labels: PR number not available" -ForegroundColor Yellow - return - } - - $labelToAdd = if ($ReproductionConfirmed) { $LabelConfirmed } else { $LabelFailed } - $labelToRemove = if ($ReproductionConfirmed) { $LabelFailed } else { $LabelConfirmed } - - Write-Host "" - Write-Host "🏷️ Updating verification labels on PR #$PR..." -ForegroundColor Cyan - - # Track success for both operations - $removeSuccess = $true - - # Remove the opposite label if it exists (using REST API to avoid GraphQL deprecation issues) - $existingLabels = gh pr view $PR --json labels --jq '.labels[].name' 2>$null - if ($existingLabels -contains $labelToRemove) { - Write-Host " Removing: $labelToRemove" -ForegroundColor Yellow - gh api "repos/dotnet/maui/issues/$PR/labels/$labelToRemove" --method DELETE 2>$null | Out-Null - if ($LASTEXITCODE -ne 0) { - $removeSuccess = $false - Write-Host " ⚠️ Failed to remove label: $labelToRemove" -ForegroundColor Yellow - } - } - - # Add the appropriate label (using REST API to avoid GraphQL deprecation issues) - Write-Host " Adding: $labelToAdd" -ForegroundColor Green - $result = gh api "repos/dotnet/maui/issues/$PR/labels" --method POST -f "labels[]=$labelToAdd" 2>&1 - $addSuccess = $LASTEXITCODE -eq 0 - - if ($addSuccess -and $removeSuccess) { - Write-Host "✅ Labels updated successfully" -ForegroundColor Green - } elseif ($addSuccess) { - Write-Host "⚠️ Label added but failed to remove old label" -ForegroundColor Yellow - } else { - Write-Host "⚠️ Failed to update labels: $result" -ForegroundColor Yellow - } -} # ============================================================ # Auto-detect test filter from changed files @@ -466,7 +413,6 @@ if ($DetectedFixFiles.Count -eq 0) { Write-Host "╚═══════════════════════════════════════════════════════════╝" -ForegroundColor Green Write-Host "" Write-Host "Failed tests: $($testResult.FailCount)" -ForegroundColor Yellow - Update-VerificationLabels -ReproductionConfirmed $true exit 0 } else { # Tests PASSED - this is bad! @@ -487,7 +433,6 @@ if ($DetectedFixFiles.Count -eq 0) { Write-Host "╚═══════════════════════════════════════════════════════════╝" -ForegroundColor Red Write-Host "" Write-Host "Passed tests: $($testResult.PassCount)" -ForegroundColor Yellow - Update-VerificationLabels -ReproductionConfirmed $false exit 1 } } @@ -882,9 +827,7 @@ if ($verificationPassed) { Write-Host "╠═══════════════════════════════════════════════════════════╣" -ForegroundColor Green Write-Host "║ Tests correctly detect the issue: ║" -ForegroundColor Green Write-Host "║ - FAIL without fix (as expected) ║" -ForegroundColor Green - Write-Host "║ - PASS with fix (as expected) ║" -ForegroundColor Green Write-Host "╚═══════════════════════════════════════════════════════════╝" -ForegroundColor Green - Update-VerificationLabels -ReproductionConfirmed $true exit 0 } else { Write-Host "" @@ -904,8 +847,6 @@ if ($verificationPassed) { Write-Host "║ 1. Wrong fix files specified ║" -ForegroundColor Red Write-Host "║ 2. Tests don't actually test the fixed behavior ║" -ForegroundColor Red Write-Host "║ 3. The issue was already fixed in base branch ║" -ForegroundColor Red - Write-Host "║ 4. Build caching - try clean rebuild ║" -ForegroundColor Red Write-Host "╚═══════════════════════════════════════════════════════════╝" -ForegroundColor Red - Update-VerificationLabels -ReproductionConfirmed $false exit 1 } diff --git a/.github/workflows/bump-global-json.yml b/.github/workflows/bump-global-json.yml new file mode 100644 index 000000000000..48ee3df11bce --- /dev/null +++ b/.github/workflows/bump-global-json.yml @@ -0,0 +1,43 @@ +name: Bump global.json for dotnet/dotnet bumps +on: pull_request_target + +jobs: + bump-global-json: + name: Bump global.json + runs-on: ubuntu-latest + # GITHUB_TOKEN change from read-write to read-only on 2024-02-01 requires permissions block + # https://docs.opensource.microsoft.com/github/apps/permission-changes/ + # https://docs.github.com/en/actions/using-jobs/assigning-permissions-to-jobs + permissions: + contents: write + if: contains(github.event.pull_request.title, 'Update dependencies from dotnet/') && github.actor == 'dotnet-maestro[bot]' + steps: + - name: 'Checkout repo' + uses: actions/checkout@v6 + with: + fetch-depth: 0 + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.sha }} + + - name: 'Update global.json' + env: + PR_NUMBER: ${{ github.event.number }} + PR_TITLE: ${{ github.event.pull_request.title }} + run: | + set -exo pipefail + + sudo apt-get install libxml2-utils + DOTNET_VERSION=$(xmllint --xpath '/Project/PropertyGroup/MicrosoftNETSdkPackageVersion/text()' eng/Versions.props) + + jq '.tools.dotnet = "'$DOTNET_VERSION'"' global.json > global.json.tmp + mv global.json.tmp global.json + if git diff --exit-code -- global.json; then + echo "No global.json update necessary" + exit 0 + fi + git add -- global.json + git config --global user.email "github-actions@xamarin.com" + git config --global user.name "GitHub Actions" + git checkout "$GITHUB_HEAD_REF" + git commit -m "Re-generate global.json for PR #$PR_NUMBER: $PR_TITLE" + git push diff --git a/.github/workflows/dogfood-comment.yml b/.github/workflows/dogfood-comment.yml index b2a823d75d64..c79b361dacb5 100644 --- a/.github/workflows/dogfood-comment.yml +++ b/.github/workflows/dogfood-comment.yml @@ -1,9 +1,16 @@ name: Add Dogfooding Comment on: - # Trigger when the maui-pr build check completes - check_run: - types: [completed] + # Use pull_request_target to run in the context of the base branch + # This allows commenting on PRs from forks + # Note: check_run trigger doesn't work because Azure DevOps check runs + # don't populate the pull_requests[] field, so we can't get the PR number. + pull_request_target: + types: [opened, reopened, synchronize] + branches: + - 'main' + - 'net*' + - 'release/**' # Allow manual triggering workflow_dispatch: @@ -15,23 +22,13 @@ on: # Ensure only one instance runs at a time per PR to prevent duplicate comments concurrency: - group: dogfood-comment-${{ github.event.check_run.pull_requests[0].number || github.event.inputs.pr_number || 'unknown' }} + group: dogfood-comment-${{ github.event.pull_request.number || github.event.inputs.pr_number }} cancel-in-progress: true jobs: add-dogfood-comment: - # Only run on the dotnet org, for the maui-pr check, when it completes successfully - if: | - github.repository_owner == 'dotnet' && - ( - github.event_name == 'workflow_dispatch' || - ( - github.event_name == 'check_run' && - github.event.check_run.name == 'maui-pr (Pack .NET MAUI Pack Windows)' && - github.event.check_run.conclusion == 'success' && - github.event.check_run.pull_requests[0] != null - ) - ) + # Only run on the dotnet org to avoid running on forks + if: ${{ github.repository_owner == 'dotnet' }} runs-on: ubuntu-latest permissions: pull-requests: write @@ -41,8 +38,8 @@ jobs: uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 with: script: | - // Get PR number from either the check_run event or manual input - const prNumber = context.payload.check_run?.pull_requests?.[0]?.number || context.payload.inputs?.pr_number; + // Get PR number from either the PR event or manual input + const prNumber = context.payload.pull_request?.number || context.payload.inputs?.pr_number; const bashScript = 'https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh'; const psScript = 'https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.ps1'; diff --git a/.gitignore b/.gitignore index 598def77e641..daafde085962 100644 --- a/.gitignore +++ b/.gitignore @@ -388,3 +388,6 @@ temp # TypeScript source map files (generated artifacts) # Note: CSS map files in templates (e.g., bootstrap) are intentionally tracked *.js.map + +# Gradle build reports +src/Core/AndroidNative/build/reports/ diff --git a/README.md b/README.md index 8686c2bca7cd..fce9648bdbf0 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,5 @@ # .NET Multi-platform App UI (.NET MAUI) -[![Build Status](https://dev.azure.com/dnceng-public/public/_apis/build/status%2Fdotnet%2Fmaui%2Fmaui-pr?branchName=main&label=Public)](https://dev.azure.com/dnceng-public/public/_build?definitionId=302) [![Build Status](https://devdiv.visualstudio.com/DevDiv/_apis/build/status/MAUI?repoName=dotnet%2Fmaui&branchName=main&label=Private)](https://devdiv.visualstudio.com/DevDiv/_build/latest?definitionId=13330&repoName=dotnet%2Fmaui&branchName=main) - [.NET Multi-platform App UI (.NET MAUI)](https://dotnet.microsoft.com/en-us/apps/maui) is a cross-platform framework for creating mobile and desktop apps with C# and XAML. Using .NET MAUI, you can develop apps that can run on Android, iOS, iPadOS, macOS, and Windows from a single shared codebase. ## Getting Started ## diff --git a/docs/design/cli.md b/docs/design/cli.md index d2477d8a6370..c61fbb07a61f 100644 --- a/docs/design/cli.md +++ b/docs/design/cli.md @@ -1,18 +1,21 @@ --- -description: "Design document for the dotnet-maui CLI tool for AI-assisted development" +description: "Design document for the maui CLI tool" date: 2026-01-07 +updated: 2026-02-26 --- -# dotnet-maui CLI Design Document +# `maui` CLI Design Document ## Overview -The `dotnet-maui` CLI is a command-line tool that provides simple -commands for capturing screenshots, viewing logs, and inspecting the -visual tree of running .NET MAUI applications. While designed to -enable AI agents to iteratively develop and validate applications, -these commands are equally useful for developers who want quick access -to debugging and inspection capabilities from the terminal. +The `maui` CLI is a command-line tool for .NET MAUI development that provides two main capabilities: + +1. **Environment setup** — manages Android SDK/JDK, Xcode runtimes, simulators, and emulators +2. **App inspection** — captures screenshots, streams logs, and inspects the visual tree of running apps + +It is designed for three consumers: **AI agents**, **CI/CD pipelines**, and **humans**. + +**Full specification**: [PR #33865](https://github.com/dotnet/maui/pull/33865) — covers architecture, error contracts, IDE integration, JSON schemas, and vNext roadmap. ## Motivation @@ -24,47 +27,49 @@ simctl io booted screenshot`, while Android uses `adb exec-out screencap`. Similarly, log access, visual tree inspection, and device management all have platform-specific implementations. -The `dotnet-maui` CLI provides a unified interface across Android, +The `maui` CLI provides a unified interface across Android, iOS, macOS, Windows, and Mac Catalyst, making these operations simple and consistent for both developers and AI agents. +### Design Principles + +1. **Delegate to native toolchains** — wraps `sdkmanager`, `adb`, `xcrun simctl`, etc. +2. **Reuse shared libraries** — leverages [`dotnet/android-tools`](https://github.com/dotnet/android-tools) (`Xamarin.Android.Tools.AndroidSdk`) for SDK/JDK discovery, and contributes new capabilities (JDK installation, SDK bootstrap, license acceptance) back to it. +3. **Machine-first output** — every command supports `--json` +4. **Stateless** — each command reads state, acts, and exits +5. **Complement `dotnet run`** — uses the same device identifiers and framework options as [`dotnet run` for .NET MAUI][dotnet-run-spec] + ## Goals -1. **Screenshot capture**: Enable AI agents to capture screenshots of +1. **Environment setup**: Manage Android SDK/JDK, Xcode runtimes, + simulators, and emulators from a single tool + +2. **Screenshot capture**: Enable AI agents to capture screenshots of running .NET MAUI applications to validate visual changes -2. **Log access**: Provide unified access to platform-specific device +3. **Log access**: Provide unified access to platform-specific device logs (logcat, Console, etc.) -3. **Visual tree inspection**: Allow agents to inspect the runtime +4. **Visual tree inspection**: Allow agents to inspect the runtime visual tree structure and properties (.NET MAUI visual tree) -4. **Developer experience**: Integrate seamlessly with existing +5. **Developer experience**: Integrate seamlessly with existing `dotnet` CLI workflows, this should fit in with `dotnet run`, `dotnet watch`, etc. ## Installation and Invocation -The CLI will be available through multiple invocation methods to -support different workflows: - -### Method 1: Direct Tool Invocation +The CLI is available through multiple invocation methods: ```bash -dotnet-maui screenshot -o screenshot.png -``` +# Direct tool invocation (after install) +maui screenshot -o screenshot.png -### Method 2: .NET CLI - -```bash +# Via the .NET CLI dotnet maui screenshot -o screenshot.png -``` - -### Method 3: `dotnet tool exec` or `dnx` -```bash +# Inline install and invocation (no prior install needed) dotnet tool exec -y Microsoft.Maui.Cli screenshot -o screenshot.png -dnx -y Microsoft.Maui.Cli screenshot -o screenshot.png ``` ### Installation @@ -78,12 +83,10 @@ dotnet tool install Microsoft.Maui.Cli # Restore local tools dotnet tool restore - -# Inline install and invocation -dotnet tool exec -y Microsoft.Maui.Cli screenshot -o screenshot.png -dnx -y Microsoft.Maui.Cli screenshot -o screenshot.png ``` +The tool installs as `maui` on PATH. All commands in this document use the `maui` form. + The .NET workload specification includes support for automatically installing tools from workloads via the `tools-packs` feature (see [workload manifest specification][workload-spec]). However, this @@ -92,15 +95,115 @@ could be automatically installed when the `maui` workload is installed, eliminating the need for manual tool installation. Until then, manual installation via `dotnet tool install` will be how -we prove out the `dotnet-maui` CLI. +we prove out the `maui` CLI. [workload-spec]: https://github.com/dotnet/designs/blob/566ad4cafcc578d6389c215c61924ee9e07dcb29/accepted/2020/workloads/workload-manifest.md#tools-packs -## Command Structure +## Global Options + +All commands support: + +| Flag | Description | +|------|-------------| +| `--json` | Structured JSON output | +| `--verbose` | Detailed logging | +| `--interactive` | Control interactive prompts (default: `true` for terminals, `false` in CI or when output is redirected) | +| `--dry-run` | Preview actions without executing | +| `--platform

` | Filter by platform: `android`, `ios`, `maccatalyst`, `windows` | -### Global Options +**Interactivity detection** follows the same pattern as `dotnet` CLI — auto-detects CI environments (`TF_BUILD`, `GITHUB_ACTIONS`, `CI`, etc.) and checks `Console.IsOutputRedirected`. -The `dotnet-maui` CLI follows the conventions established by [`dotnet +## Environment Setup Commands + +### Android + +| Command | Description | +|---------|-------------| +| `maui android install` | Install JDK + SDK + recommended packages | +| `maui android install --accept-licenses` | Non-interactive install | +| `maui android install --packages ` | Install specific packages | +| `maui android jdk check` | Check JDK status | +| `maui android jdk install` | Install OpenJDK 21 | +| `maui android jdk list` | List installed JDKs | +| `maui android sdk list` | List installed packages | +| `maui android sdk list --available` | Show available packages | +| `maui android sdk install ` | Install package(s) | +| `maui android sdk accept-licenses` | Accept all licenses | +| `maui android sdk uninstall ` | Uninstall a package | +| `maui android emulator list` | List emulators | +| `maui android emulator create ` | Create emulator (auto-detects system image) | +| `maui android emulator start ` | Start emulator | +| `maui android emulator stop ` | Stop emulator | +| `maui android emulator delete ` | Delete emulator | + +Install paths and defaults are handled by [`dotnet/android-tools`](https://github.com/dotnet/android-tools). + +### Apple (macOS only) + +| Command | Description | +|---------|-------------| +| `maui apple install [--accept-license] [--runtime ]` | Optionally accepts Xcode license and installs simulator runtimes. Could prompt user to install Xcode in the future | +| `maui apple check` | Check Xcode, runtimes, and environment status | +| `maui apple xcode check` | Check Xcode installation and license | +| `maui apple xcode list` | List Xcode installations | +| `maui apple xcode select ` | Switch active Xcode | +| `maui apple xcode accept-license` | Accept Xcode license | +| `maui apple simulator list` | List simulators | +| `maui apple simulator create ` | Create simulator | +| `maui apple simulator start ` | Start simulator | +| `maui apple simulator stop ` | Stop simulator | +| `maui apple simulator delete ` | Delete simulator | +| `maui apple runtime check` | Check runtime status | +| `maui apple runtime list` | List installed runtimes | +| `maui apple runtime list --all` | List all runtimes (installed and downloadable) | +| `maui apple runtime install ` | Install an iOS runtime | + +> **License flag naming**: Android uses `accept-licenses` (plural) because `sdkmanager` requires accepting multiple SDK component licenses. Apple uses `accept-license` (singular) because `xcodebuild -license accept` accepts one unified Xcode license agreement. + +### Implementation References + +The `maui` CLI delegates to shared libraries for platform operations: + +**Android** — [`dotnet/android-tools`](https://github.com/dotnet/android-tools) (`Xamarin.Android.Tools.AndroidSdk`): + +| Feature | Implementation | +|---------|---------------| +| SDK discovery, bootstrap & license acceptance | [`SdkManager`](https://github.com/dotnet/android-tools/pull/275) | +| JDK discovery & installation | [`JdkInstaller`](https://github.com/dotnet/android-tools/pull/274) | +| ADB device management | [`AdbRunner`](https://github.com/dotnet/android-tools/pull/282) | +| AVD / Emulator management | [`AvdManagerRunner`](https://github.com/dotnet/android-tools/pull/283), [`EmulatorRunner`](https://github.com/dotnet/android-tools/pull/284) | + +**Apple** — wraps native toolchains directly: + +| Feature | Native tool | +|---------|------------| +| Simulator management | `xcrun simctl` (list, create, boot, shutdown, delete) | +| Runtime management | `xcrun simctl runtime` (list, add) | +| Xcode management | `xcode-select`, `xcodebuild -license` | +| Device detection | `xcrun devicectl list devices` (physical), `xcrun simctl list` (simulators) | + +Apple operations use [AppleDev.Tools][appledev-tools] for `simctl` and `devicectl` wrappers. + +### Exit Codes + +All commands use consistent exit codes: + +| Code | Meaning | +|------|---------| +| 0 | Success | +| 1 | General error | +| 2 | Environment/configuration error | +| 3 | Permission denied (elevation required) | +| 4 | Network error (download failed) | +| 5 | Resource not found | + +## App Inspection Commands (Future) + +> **Note**: App inspection commands are planned for a future release. The initial release focuses on environment setup and device management. + +### Device Selection Options + +App inspection commands will follow the conventions established by [`dotnet run` for .NET MAUI][dotnet-run-spec], using the same device selection and framework options: @@ -146,7 +249,7 @@ Captures a screenshot of the currently running .NET MAUI application. **Usage:** ```bash -dotnet maui screenshot [options] +maui screenshot [options] ``` **Options:** @@ -158,17 +261,17 @@ dotnet maui screenshot [options] Initial implementation targets Android and iOS/Mac Catalyst, with Windows and macOS support planned as described below. -- **Android**: Uses `adb screencap` -- **iOS/Mac Catalyst**: Uses `simctl io screenshot` for simulator, and device capture via Xcode tooling (future implementation) +- **Android**: Uses `adb exec-out screencap -p` +- **iOS/Mac Catalyst**: Uses `xcrun simctl io booted screenshot ` for simulator; physical device capture via Xcode tooling (future) - **Windows** (planned): Uses Windows screen capture APIs to capture the active app window or full screen. - **macOS** (planned): Uses macOS screen capture APIs or command-line tooling to capture the active app window or full screen. ### Future Commands -To keep scope small for initial version, future commands are: - -- `dotnet maui log` or `logs` -- `dotnet maui tree` for displaying the visual tree +- `maui device list` for unified device/emulator/simulator listing across platforms +- `maui screenshot` for capturing screenshots of running apps +- `maui logs` for streaming device logs +- `maui tree` for inspecting the visual tree ## Integration with `dotnet run` and `dotnet watch` @@ -180,12 +283,10 @@ The CLI is designed to work seamlessly with existing .NET workflows: # Terminal 1: Run application with hot reload dotnet watch run -# Terminal 2: Monitor logs -dotnet maui logs --follow --filter "MyApp" - -# Terminal 3: Inspect application -dotnet maui screenshot --output iteration1.png -dotnet maui tree --format json +# Terminal 2: Inspect application +maui screenshot --output iteration1.png +maui logs --follow --filter "MyApp" # future +maui tree --json # future ``` ### AI Agent Workflow @@ -198,13 +299,13 @@ dotnet maui tree --format json sleep 2 # 3. Capture screenshot -dotnet maui screenshot -o current.png +maui screenshot -o current.png -# 4. Analyze visual tree -dotnet maui tree --format json +# 4. Analyze visual tree (future) +maui tree --json -# 5. Check logs for errors -dotnet maui logs --level error +# 5. Check logs for errors (future) +maui logs --level error # 6. Agent analyzes outputs and decides next steps ``` @@ -220,10 +321,10 @@ dotnet maui logs --level error ### iOS / Mac Catalyst - **Device Detection**: `xcrun simctl list devices` (simulators), - `xcrun devicectl list devices` (physical devices) + `xcrun devicectl list devices` (physical devices) — via [AppleDev.Tools][appledev-tools] - **Screenshots**: `xcrun simctl io booted screenshot ` - (simulators), iOS devices (future implementation) + (simulators), iOS physical devices (future) - **Logs**: `xcrun simctl spawn booted log stream` or Console.app (simulators), `mlaunch --logdev` (physical devices) @@ -243,6 +344,72 @@ The CLI is designed for development and debugging scenarios only: (like screenshots and logs), the CLI should not be usable against production applications +## IDE Integration + +The `maui` CLI and its underlying libraries are designed to be the shared backend for IDE extensions, eliminating duplicate environment detection and setup logic across tools. + +### Architecture + +``` +┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ +│ VS Code ext │ │ Visual Studio │ │ AI Agent │ +│ (vscode-maui) │ │ extension │ │ (Copilot, etc.) │ +└────────┬─────────┘ └────────┬──────────┘ └────────┬─────────┘ + │ │ │ + spawns CLI references NuGet spawns CLI + │ library directly │ + │ │ │ + ▼ ▼ ▼ + ┌──────────────┐ ┌────────────────────┐ ┌──────────────┐ + │ maui CLI │ │ android-tools │ │ maui CLI │ + │ (process) │ │ (in-process) │ │ (--json) │ + └──────┬───────┘ └────────┬───────────┘ └──────┬───────┘ + │ │ │ + └─────────┬───────────┴───────────────────────┘ + │ spawns native tools + ┌───────────┼───────────┐ + ▼ ▼ ▼ + ┌───────────┐ ┌──────────┐ ┌──────────┐ + │ adb │ │ xcrun │ │ Windows │ + │ sdkmanager│ │ simctl │ │ SDK │ + └───────────┘ └──────────┘ └──────────┘ +``` + +### Integration Modes + +| Consumer | Integration | Rationale | +|----------|------------|-----------| +| **Visual Studio** extension | References `android-tools` NuGet package directly (in-process) | .NET extension — no serialization overhead, direct API access | +| **VS Code** (`vscode-maui`) | Spawns `maui` CLI process, parses `--json` stdout | TypeScript extension — CLI is the natural process boundary | +| **AI agents / CI** | Invokes `maui` CLI with `--json` | Process-based, language-agnostic | +| **Terminal** (human) | Invokes `maui` CLI directly | Human-readable output by default, `--json` when needed | + +Visual Studio consumes the `Xamarin.Android.Tools.AndroidSdk` NuGet package from [`dotnet/android-tools`](https://github.com/dotnet/android-tools) directly — the same library the CLI uses internally. This avoids process overhead and gives the VS extension full API access. Non-.NET consumers (VS Code, AI agents, CI) use the CLI as the canonical interface. + +### How IDEs Use It + +| Workflow | CLI command | IDE behavior | +|----------|------------|--------------| +| Workspace open | `maui apple check --json`, `maui android jdk check --json` | Show environment status in status bar / problems panel | +| Environment fix | `maui android install --json` | Display progress bar, stream `type: "progress"` messages | +| Device picker | `maui device list --json` (future) | Populate device dropdown / selection UI | +| Emulator launch | `maui android emulator start --json` | Show notification, update device list on completion | + +### Benefits + +- **Consistent behavior** — VS, VS Code, and CLI all use the same detection and setup logic (via shared libraries) +- **Single maintenance point** — bug fixes in `android-tools` propagate to all consumers +- **AI-ready** — agents use the same `--json` output that VS Code consumes +- **Flexible integration** — .NET consumers go in-process, others use the CLI + +### Current Status + +| Integration | Status | +|-------------|--------| +| VS Code extension (`vscode-maui`) | ✅ In progress | +| Visual Studio extension | Planned (vNext) | +| GitHub Copilot / AI agents | ✅ Supported via `--json` output | + ## Future Goals ### MCP Server @@ -262,7 +429,8 @@ if there's demonstrated need. ### More Subcommands -There are other .NET MAUI CLI tools such as: +Environment setup commands (Android SDK/JDK, Xcode, emulators, +simulators) are now included above. These were inspired by: - .NET MAUI "Check" / "Doctor" - https://github.com/Redth/dotnet-maui-check @@ -270,22 +438,22 @@ There are other .NET MAUI CLI tools such as: - Android SDK Management - https://github.com/Redth/AndroidSdk.Tools -These could easily be added down the road. - Future commands: -- `dotnet maui log` or `logs` for viewing console output -- `dotnet maui tree` for displaying the visual tree +- `maui device list` for unified device listing +- `maui logs` for viewing console output +- `maui tree` for displaying the visual tree +- `maui screenshot` for capturing screenshots -**Decision**: Start with just a few subcommands and expand in the -future. +**Decision**: Environment setup ships first. Device listing and app inspection commands +follow in a future release. ## References - [vibe-wpf experiment][vibe-wpf] - [dotnet run for .NET MAUI specification][dotnet-run-spec] - [Workload manifest specification][workload-spec] -- [AppleDev.Tools][appledev-tools] - Wraps simctl and xcdevice commands +- [AppleDev.Tools][appledev-tools] - Wraps simctl and devicectl commands - [System.CommandLine documentation](https://learn.microsoft.com/dotnet/standard/commandline/) - [Android Debug Bridge (ADB)](https://developer.android.com/studio/command-line/adb) - [simctl command-line tool](https://nshipster.com/simctl/) diff --git a/eng/Build.props b/eng/Build.props index 691d93a5904d..26f2232be3dd 100644 --- a/eng/Build.props +++ b/eng/Build.props @@ -31,5 +31,6 @@ CodesignRequireProvisioningProfile=false + diff --git a/eng/Versions.props b/eng/Versions.props index 3fca01616d12..0a13eb47889b 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -55,11 +55,14 @@ 11.0.0-preview.3.26166.111 11.0.0-preview.3.26166.111 11.0.0-preview.3.26166.111 - 10.0.1 - 10.0.1 + 10.3.0 + 10.3.0 11.0.0-preview.2.26103.111 - 1.0.0-preview.251204.1 + 1.0.0-rc2 + 1.0.0-rc2 + 1.0.0-rc2 + 1.0.0-preview.260225.1 36.1.99-ci.main.217 36.1.43 diff --git a/eng/cake/dotnet.cake b/eng/cake/dotnet.cake index e8f0d2089c00..084a098460c9 100644 --- a/eng/cake/dotnet.cake +++ b/eng/cake/dotnet.cake @@ -269,6 +269,7 @@ Task("dotnet-test") "**/Controls.BindingSourceGen.UnitTests.csproj", "**/Core.UnitTests.csproj", "**/Essentials.UnitTests.csproj", + "**/Essentials.AI.UnitTests.csproj", "**/Resizetizer.UnitTests.csproj", "**/Graphics.Tests.csproj", "**/Compatibility.Core.UnitTests.csproj", diff --git a/eng/helix.proj b/eng/helix.proj index 5ba803fef9ba..565ce270a50c 100644 --- a/eng/helix.proj +++ b/eng/helix.proj @@ -35,6 +35,7 @@ + diff --git a/eng/helix_xharness.proj b/eng/helix_xharness.proj index c99d5a7526b5..ffba39148bb1 100644 --- a/eng/helix_xharness.proj +++ b/eng/helix_xharness.proj @@ -31,6 +31,11 @@ CollectionView;Shell;HybridWebView + + + + AppleIntelligenceChatClient + @@ -119,6 +124,15 @@ Microsoft.Maui.MauiBlazorWebView.DeviceTests src/BlazorWebView/tests/DeviceTests/MauiBlazorWebView.DeviceTests.csproj + + + Essentials.AI.DeviceTests + $(ScenariosDir)Essentials.AI.DeviceTests + Microsoft.Maui.Essentials.AI.DeviceTests + com.microsoft.maui.ai.devicetests + com.microsoft.maui.ai.devicetests + src/AI/tests/Essentials.AI.DeviceTests/Essentials.AI.DeviceTests.csproj + @@ -173,15 +187,33 @@ 02:00:00 01:00:00 + + ios-simulator-64 + 02:00:00 + 01:00:00 + xharness apple test --target "$target" --app "$app" --output-directory "$output_directory" --timeout "$timeout" --launch-timeout "$launch_timeout" --set-env="TestFilter=SkipCategories=$(AITestCategoriesToSkipOnCI)" + - + - + + <_MAUIScenarioSearchMacCatalyst Include="@(_MAUIScenarioSearch)" /> + <_MAUIScenarioSearchMacCatalyst Remove="EssentialsAI" /> + + + maccatalyst + 02:00:00 + 01:00:00 + %(_MAUIScenarioSearchMacCatalyst.ScenarioDirectoryName) + + + + maccatalyst 02:00:00 01:00:00 - %(_MAUIScenarioSearch.ScenarioDirectoryName) + xharness apple test --target "$target" --app "$app" --output-directory "$output_directory" --timeout "$timeout" --launch-timeout "$launch_timeout" --set-env="TestFilter=SkipCategories=$(AITestCategoriesToSkipOnCI)" diff --git a/eng/pipelines/arcade/stage-device-tests.yml b/eng/pipelines/arcade/stage-device-tests.yml index 8e94406db015..66bcc110990a 100644 --- a/eng/pipelines/arcade/stage-device-tests.yml +++ b/eng/pipelines/arcade/stage-device-tests.yml @@ -79,6 +79,9 @@ parameters: - name: MauiBlazorWebView.DeviceTests path: src/BlazorWebView/tests/DeviceTests/MauiBlazorWebView.DeviceTests.csproj packageId: Microsoft.Maui.MauiBlazorWebView.DeviceTests + - name: Essentials.AI.DeviceTests + path: src/AI/tests/Essentials.AI.DeviceTests/Essentials.AI.DeviceTests.csproj + packageId: com.microsoft.maui.ai.devicetests stages: - stage: devicetests_build @@ -571,7 +574,7 @@ stages: # Save unpackaged publish output before packaged builds overwrite artifacts/bin - pwsh: | - $artifactNames = @("Controls.DeviceTests", "Core.DeviceTests", "Graphics.DeviceTests", "Essentials.DeviceTests", "MauiBlazorWebView.DeviceTests") + $artifactNames = @("Controls.DeviceTests", "Core.DeviceTests", "Graphics.DeviceTests", "Essentials.DeviceTests", "MauiBlazorWebView.DeviceTests", "Essentials.AI.DeviceTests") foreach ($name in $artifactNames) { $publishDir = Get-ChildItem -Path "$(Build.SourcesDirectory)/artifacts/bin/$name" -Filter "publish" -Recurse -Directory | Select-Object -First 1 if ($publishDir) { @@ -604,7 +607,8 @@ stages: @{ Name = "Core.DeviceTests"; ProjectDir = "$(Build.SourcesDirectory)/src/Core/tests/DeviceTests"; ArtifactDir = "$(Build.SourcesDirectory)/artifacts/bin/Core.DeviceTests" }, @{ Name = "Graphics.DeviceTests"; ProjectDir = "$(Build.SourcesDirectory)/src/Graphics/tests/DeviceTests"; ArtifactDir = "$(Build.SourcesDirectory)/artifacts/bin/Graphics.DeviceTests" }, @{ Name = "Essentials.DeviceTests"; ProjectDir = "$(Build.SourcesDirectory)/src/Essentials/test/DeviceTests"; ArtifactDir = "$(Build.SourcesDirectory)/artifacts/bin/Essentials.DeviceTests" }, - @{ Name = "MauiBlazorWebView.DeviceTests"; ProjectDir = "$(Build.SourcesDirectory)/src/BlazorWebView/tests/DeviceTests"; ArtifactDir = "$(Build.SourcesDirectory)/artifacts/bin/MauiBlazorWebView.DeviceTests" } + @{ Name = "MauiBlazorWebView.DeviceTests"; ProjectDir = "$(Build.SourcesDirectory)/src/BlazorWebView/tests/DeviceTests"; ArtifactDir = "$(Build.SourcesDirectory)/artifacts/bin/MauiBlazorWebView.DeviceTests" }, + @{ Name = "Essentials.AI.DeviceTests"; ProjectDir = "$(Build.SourcesDirectory)/src/AI/tests/Essentials.AI.DeviceTests"; ArtifactDir = "$(Build.SourcesDirectory)/artifacts/bin/Essentials.AI.DeviceTests" } ) foreach ($project in $projects) { diff --git a/eng/pipelines/arcade/variables.yml b/eng/pipelines/arcade/variables.yml index d1c838bec435..d574cd0d878f 100644 --- a/eng/pipelines/arcade/variables.yml +++ b/eng/pipelines/arcade/variables.yml @@ -55,7 +55,7 @@ variables: value: >- /p:DotNetPublishUsingPipelines=true - name: _OfficialBuildIdArgs - value: /p:OfficialBuildId=$(BUILD.BUILDNUMBER) /p:_SkipUpdateBuildNumber=true + value: /p:OfficialBuildId=$(_BuildOfficialId) /p:_SkipUpdateBuildNumber=true # Use _BuildOfficialId because Arcade does MSBuild arithmetic on OfficialBuildId and BUILD.BUILDNUMBER may contain '+' which breaks parsing. # -runtimeSourceFeed https://ci.dot.net/internal -runtimeSourceFeedKey '$(dotnetbuilds-internal-container-read-token-base64)' # needed for signing - name: _SignType diff --git a/eng/pipelines/ci-copilot.yml b/eng/pipelines/ci-copilot.yml new file mode 100644 index 000000000000..bbd7fdad5c29 --- /dev/null +++ b/eng/pipelines/ci-copilot.yml @@ -0,0 +1,615 @@ +# Pipeline for running GitHub Copilot PR Reviewer Agent +# This pipeline installs the Copilot CLI and invokes the PR reviewer agent +# to conduct automated code reviews on pull requests. +# +# For more information, see: +# https://github.com/dotnet/maui/wiki/PR-Reviewer-Agent + +trigger: none # Manual trigger only + +pr: none # Not triggered by PRs + +parameters: + - name: PRNumber + displayName: 'Pull Request Number' + type: string + default: '' + + - name: Platform + displayName: 'Target Platform' + type: string + default: 'android' + values: + - android + - ios + + - name: pool + type: object + default: + name: Azure Pipelines + vmImage: ubuntu-22.04 + +variables: + - template: /eng/pipelines/common/variables.yml@self + - name: Codeql.Enabled + value: false + - name: Codeql.SkipTaskAutoInjection + value: true + - name: APPIUM_HOME + value: $(System.DefaultWorkingDirectory)/.appium/ + - name: LogDirectory + value: $(Build.ArtifactStagingDirectory)/logs + +stages: + - stage: ReviewPR + displayName: 'Review Pull Request' + jobs: + - job: CopilotReview + displayName: 'Run Copilot PR Reviewer Agent' + pool: ${{ parameters.pool }} + timeoutInMinutes: 360 + steps: + - checkout: self + fetchDepth: 0 + persistCredentials: true + + - script: | + echo "Validating PR Number parameter..." + if [ -z "${{ parameters.PRNumber }}" ]; then + echo "##vso[task.logissue type=error]PRNumber parameter is required" + exit 1 + fi + echo "PR Number: ${{ parameters.PRNumber }}" + displayName: 'Validate Parameters' + + - script: | + echo "##vso[build.updatebuildnumber]PR ${{ parameters.PRNumber }} ${{ parameters.Platform }}" + displayName: 'Set Pipeline Run Title' + + # Enable KVM for Android emulator on Linux (same as ui-tests-steps.yml / device-tests-steps.yml) + - ${{ if eq(parameters.Platform, 'android') }}: + - template: common/enable-kvm.yml + + # Provision SDKs (same parameters as ui-tests-steps.yml) + - template: common/provision.yml + parameters: + skipXcode: ${{ eq(parameters.Platform, 'android') }} + skipProvisionator: true + skipJdk: ${{ ne(parameters.Platform, 'android') }} + skipAndroidCommonSdks: ${{ ne(parameters.Platform, 'android') }} + skipAndroidPlatformApis: true + onlyAndroidPlatformDefaultApis: true + skipAndroidEmulatorImages: ${{ ne(parameters.Platform, 'android') }} + skipAndroidCreateAvds: true + androidEmulatorApiLevel: '30' + skipSimulatorSetup: ${{ eq(parameters.Platform, 'android') }} + skipCertificates: true + + # Install .NET and workloads via build.ps1 + - pwsh: ./build.ps1 --target=dotnet --configuration="Release" --verbosity=diagnostic + displayName: 'Install .NET and workloads' + retryCountOnTaskFailure: 2 + env: + DOTNET_TOKEN: $(dotnetbuilds-internal-container-read-token) + PRIVATE_BUILD: $(PrivateBuild) + + - pwsh: echo "##vso[task.prependpath]$(DotNet.Dir)" + displayName: 'Add .NET to PATH' + + # Build MSBuild tasks (required for MAUI builds) + - pwsh: ./build.ps1 --target=dotnet-buildtasks --configuration="Release" --verbosity=diagnostic + displayName: 'Build MSBuild Tasks' + retryCountOnTaskFailure: 1 + env: + DOTNET_TOKEN: $(dotnetbuilds-internal-container-read-token) + PRIVATE_BUILD: $(PrivateBuild) + + # Restore .NET tools (includes xharness) + - script: dotnet tool restore + displayName: 'Restore .NET Tools' + + # Create AVD and boot Android Emulator + - ${{ if eq(parameters.Platform, 'android') }}: + # Free disk space on hosted agents (emulator needs ~7GB for userdata partition) + - script: | + echo "=== Disk space before cleanup ===" + df -h /home + echo "Removing unnecessary tools to free space..." + sudo rm -rf /usr/share/dotnet /usr/local/share/powershell /usr/local/share/chromium 2>/dev/null || true + sudo rm -rf /opt/hostedtoolcache/CodeQL /opt/hostedtoolcache/go /opt/hostedtoolcache/Python 2>/dev/null || true + sudo rm -rf /usr/share/swift 2>/dev/null || true + echo "=== Disk space after cleanup ===" + df -h /home + displayName: 'Free Disk Space for Emulator' + + - script: | + export ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT:-/usr/local/lib/android/sdk}" + export PATH="$ANDROID_SDK_ROOT/platform-tools:$ANDROID_SDK_ROOT/emulator:$ANDROID_SDK_ROOT/cmdline-tools/latest/bin:$PATH" + + echo "=== Creating AVD ===" + echo "no" | avdmanager create avd -n Emulator_30 -k "system-images;android-30;google_apis_playstore;x86_64" --device "Nexus 5X" --force + + # Reduce userdata partition to fit on hosted agents (~4.2GB free) + AVD_CONFIG="$HOME/.android/avd/Emulator_30.avd/config.ini" + if [ -f "$AVD_CONFIG" ]; then + sed -i 's/disk.dataPartition.size=.*/disk.dataPartition.size=2048m/' "$AVD_CONFIG" + echo "Updated disk.dataPartition.size to 2048m" + fi + + # Pre-authorize ADB keys (mirrors android.cake HandleVirtualDevice) + echo "=== Pre-authorizing ADB keys ===" + mkdir -p "$HOME/.android" + if [ ! -f "$HOME/.android/adbkey" ]; then + adb keygen "$HOME/.android/adbkey" 2>/dev/null || true + fi + ADB_KEY_PUB="$HOME/.android/adbkey.pub" + AVD_DIR="$HOME/.android/avd/Emulator_30.avd" + if [ -f "$ADB_KEY_PUB" ] && [ -d "$AVD_DIR" ]; then + cp "$ADB_KEY_PUB" "$AVD_DIR/adbkey.pub" + echo "ADB key pre-authorized for emulator" + fi + + echo "=== Starting Emulator ===" + # Kill any stale adb server and restart + adb kill-server 2>/dev/null || true + sleep 1 + adb start-server + + # Retry loop: emulator sometimes fails to connect ADB on first launch + MAX_LAUNCH_ATTEMPTS=2 + EMULATOR_PID="" + for LAUNCH_ATTEMPT in $(seq 1 $MAX_LAUNCH_ATTEMPTS); do + echo "--- Emulator launch attempt $LAUNCH_ATTEMPT of $MAX_LAUNCH_ATTEMPTS ---" + + if [ $LAUNCH_ATTEMPT -gt 1 ]; then + echo "Cleaning up before retry..." + if [ -n "$EMULATOR_PID" ] && kill -0 "$EMULATOR_PID" 2>/dev/null; then + kill "$EMULATOR_PID" 2>/dev/null || true + sleep 2 + kill -0 "$EMULATOR_PID" 2>/dev/null && kill -9 "$EMULATOR_PID" 2>/dev/null || true + fi + sleep 3 + adb kill-server 2>/dev/null || true + sleep 2 + adb start-server + sleep 2 + fi + + nohup emulator -avd Emulator_30 -gpu swiftshader_indirect -no-window -no-snapshot -no-audio -no-boot-anim -partition-size 2048 > /tmp/emulator.log 2>&1 & + EMULATOR_PID=$! + echo "Emulator PID: $EMULATOR_PID" + + echo "Waiting for emulator device (adb wait-for-device, 120s timeout)..." + timeout 120 adb wait-for-device + if [ $? -eq 0 ]; then + echo "Device detected: $(adb devices -l | grep emulator)" + break + fi + + echo "##vso[task.logissue type=warning]adb wait-for-device timed out (attempt $LAUNCH_ATTEMPT)" + adb devices -l + tail -30 /tmp/emulator.log + + if [ $LAUNCH_ATTEMPT -eq $MAX_LAUNCH_ATTEMPTS ]; then + echo "##vso[task.logissue type=error]Emulator failed to connect after $MAX_LAUNCH_ATTEMPTS attempts" + exit 1 + fi + done + + timeout=300 + waited=0 + while [ "$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" != "1" ]; do + sleep 5 + waited=$((waited + 5)) + if [ $waited -ge $timeout ]; then + echo "##vso[task.logissue type=error]Emulator did not finish booting after ${timeout}s" + exit 1 + fi + # At 90 seconds, restart ADB server to recover from auth issues (mirrors android.cake) + if [ $waited -eq 90 ]; then + echo "Boot taking longer than expected (90/${timeout}s). Restarting ADB server..." + adb kill-server 2>/dev/null || true + sleep 2 + adb start-server + sleep 2 + echo "ADB server restarted. Continuing to wait..." + fi + # Re-ensure ADB keys every 60s during boot (mirrors android.cake PrepareDevice) + if [ $((waited % 60)) -eq 0 ] && [ $waited -gt 0 ]; then + if [ -f "$ADB_KEY_PUB" ] && [ -d "$AVD_DIR" ]; then + cp "$ADB_KEY_PUB" "$AVD_DIR/adbkey.pub" 2>/dev/null || true + fi + fi + done + echo "Boot completed, waiting for package manager..." + + timeout=120 + waited=0 + while ! adb shell pm list packages 2>/dev/null | grep -q "package:"; do + sleep 5 + waited=$((waited + 5)) + if [ $waited -ge $timeout ]; then + echo "##vso[task.logissue type=error]Package manager not ready after ${timeout}s" + exit 1 + fi + done + + DEVICE_ID=$(adb devices | grep "emulator.*device" | awk '{print $1}') + echo "✅ Emulator fully booted: $DEVICE_ID" + + # Prepare emulator for CI use — keeps device responsive during idle period + echo "=== Preparing emulator for CI ===" + # Wait for device to stabilize after boot (transient offline state) + for i in $(seq 1 10); do + if adb -s $DEVICE_ID shell echo ok 2>/dev/null | grep -q ok; then + break + fi + echo "Device offline, retrying ($i/10)..." + sleep 3 + done + # Disable all animations (reduces CPU load and flakiness) + adb -s $DEVICE_ID shell settings put global window_animation_scale 0.0 + adb -s $DEVICE_ID shell settings put global transition_animation_scale 0.0 + adb -s $DEVICE_ID shell settings put global animator_duration_scale 0.0 + # Prevent screen from turning off (emulator simulates AC charging) + adb -s $DEVICE_ID shell settings put system screen_off_timeout 2147483647 + adb -s $DEVICE_ID shell svc power stayon true + # Wake screen and dismiss any lock screen + adb -s $DEVICE_ID shell input keyevent 82 + sleep 1 + # Dismiss any "System UI has stopped" or crash dialogs + adb -s $DEVICE_ID shell am broadcast -a android.intent.action.CLOSE_SYSTEM_DIALOGS 2>/dev/null || true + # Clear logcat buffer so agent sees only fresh logs + adb -s $DEVICE_ID logcat -c 2>/dev/null || true + echo "Emulator preparation complete" + + echo "##vso[task.setvariable variable=DEVICE_UDID]$DEVICE_ID" + echo "##vso[task.prependpath]$ANDROID_SDK_ROOT/platform-tools" + echo "##vso[task.prependpath]$ANDROID_SDK_ROOT/emulator" + displayName: 'Create AVD and Boot Android Emulator' + retryCountOnTaskFailure: 1 + timeoutInMinutes: 15 + + # Install Node.js and Appium (same as ui-tests-steps.yml) + - task: UseNode@1 + inputs: + version: "24.x" + displayName: 'Install Node.js' + + - pwsh: | + $skipAppiumDoctor = if ($IsMacOS -or $IsLinux) { "true" } else { "false" } + dotnet build ./src/Provisioning/Provisioning.csproj -t:ProvisionAppium -p:SkipAppiumDoctor="$skipAppiumDoctor" -bl:"$(LogDirectory)/provision-appium.binlog" + displayName: 'Install Appium' + retryCountOnTaskFailure: 2 + timeoutInMinutes: 10 + env: + APPIUM_HOME: $(APPIUM_HOME) + + - script: | + echo "Installing GitHub CLI..." + brew install gh + if ! gh --version; then + echo "##vso[task.logissue type=error]Failed to install GitHub CLI" + exit 1 + fi + echo "GitHub CLI installed successfully" + displayName: 'Install GitHub CLI' + + - script: | + echo "Authenticating with GitHub CLI..." + if [ -z "$(GH_CLI_TOKEN)" ]; then + echo "##vso[task.logissue type=error]GH_CLI_TOKEN is not set. Please configure the pipeline variable." + exit 1 + fi + # Use GH_TOKEN env var to avoid scope validation issues with newer gh versions + export GH_TOKEN="$(GH_CLI_TOKEN)" + gh auth status + if [ $? -ne 0 ]; then + # Fallback: try direct login + echo "$(GH_CLI_TOKEN)" | gh auth login --with-token 2>/dev/null || true + if ! gh auth status; then + echo "##vso[task.logissue type=error]GitHub CLI authentication failed" + exit 1 + fi + fi + echo "GitHub CLI authenticated successfully" + displayName: 'Authenticate GitHub CLI' + env: + GH_CLI_TOKEN: $(GH_CLI_TOKEN) + + - script: | + echo "Installing GitHub Copilot CLI..." + npm install -g @github/copilot + # Ensure npm global bin is on PATH for subsequent steps (Linux UseNode installs to toolcache) + COPILOT_BIN_DIR=$(dirname "$(which copilot)") + echo "Copilot binary at: $COPILOT_BIN_DIR/copilot" + echo "##vso[task.prependpath]$COPILOT_BIN_DIR" + copilot --version || true + echo "Copilot CLI installed successfully" + displayName: 'Install GitHub Copilot CLI' + + + # Boot iOS Simulator (only for iOS platform) + # UI test baseline screenshots are captured on iPhone Xs - must use same device + - script: | + echo "=== Booting iOS Simulator ===" + + # Find the latest stable iOS runtime (prefer 18.x, fallback to 17.x) + RUNTIME=$(xcrun simctl list runtimes available --json | jq -r ' + [.runtimes[] | select(.name | test("iOS 18"))] | sort_by(.version) | last | .identifier // empty + ') + if [ -z "$RUNTIME" ]; then + RUNTIME=$(xcrun simctl list runtimes available --json | jq -r ' + [.runtimes[] | select(.name | test("iOS 17"))] | sort_by(.version) | last | .identifier // empty + ') + fi + echo "Selected iOS runtime: $RUNTIME" + + # Look for iPhone Xs (matches UI test baselines - required for snapshot tests) + UDID=$(xcrun simctl list devices available --json | jq -r --arg rt "$RUNTIME" ' + .devices[$rt] // [] | + map(select(.name == "iPhone Xs")) | + .[0].udid // empty + ') + + # If iPhone Xs doesn't exist, try iPhone 11 Pro (same 1125×2436 resolution) + if [ -z "$UDID" ]; then + UDID=$(xcrun simctl list devices available --json | jq -r --arg rt "$RUNTIME" ' + .devices[$rt] // [] | + map(select(.name == "iPhone 11 Pro")) | + .[0].udid // empty + ') + if [ -n "$UDID" ]; then + echo "Found existing iPhone 11 Pro (same resolution as iPhone Xs): $UDID" + fi + else + echo "Found existing iPhone Xs: $UDID" + fi + + # If neither exists, try to create them + if [ -z "$UDID" ]; then + echo "No matching device found - attempting to create one for runtime $RUNTIME..." + + # Try iPhone Xs first + UDID=$(xcrun simctl create "iPhone Xs" com.apple.CoreSimulator.SimDeviceType.iPhone-Xs "$RUNTIME" 2>&1) + if [ $? -ne 0 ]; then + echo "iPhone Xs device type unavailable: $UDID" + # Try iPhone 11 Pro (same 1125×2436 resolution) + UDID=$(xcrun simctl create "iPhone 11 Pro" com.apple.CoreSimulator.SimDeviceType.iPhone-11-Pro "$RUNTIME" 2>&1) + if [ $? -ne 0 ]; then + echo "##vso[task.logissue type=warning]Failed to create iPhone 11 Pro: $UDID" + # Last resort: first available iPhone + UDID=$(xcrun simctl list devices available --json | jq -r ' + .devices | to_entries | + map(.value) | flatten | + map(select(.name | test("iPhone"))) | + .[0].udid + ') + else + echo "Created iPhone 11 Pro simulator: $UDID" + fi + else + echo "Created iPhone Xs simulator: $UDID" + fi + fi + + if [ -z "$UDID" ]; then + echo "##vso[task.logissue type=error]No iOS simulator found" + exit 1 + fi + + # Shutdown any other booted simulators to avoid Appium connecting to wrong device + xcrun simctl list devices booted --json | jq -r ' + .devices | to_entries | map(.value) | flatten | + map(select(.state == "Booted" and .udid != "'"$UDID"'")) | + .[].udid + ' | while read OTHER_UDID; do + echo "Shutting down other simulator: $OTHER_UDID" + xcrun simctl shutdown "$OTHER_UDID" 2>/dev/null || true + done + + echo "Booting simulator: $UDID" + xcrun simctl boot "$UDID" 2>/dev/null || echo "Simulator may already be booted" + sleep 10 + + echo "Booted simulators:" + xcrun simctl list devices booted + + echo "##vso[task.setvariable variable=DEVICE_UDID]$UDID" + echo "iOS Simulator UDID: $UDID" + displayName: 'Boot iOS Simulator' + condition: eq('${{ parameters.Platform }}', 'ios') + timeoutInMinutes: 5 + + # Warm up the emulator right before the agent runs. + # The emulator may have been idle for 15-30 min while Appium/Node/CLI were installed. + # Without this, SystemUI can ANR when the agent first touches it. + - script: | + set -e + DEVICE_ID="$(DEVICE_UDID)" + if [ -z "$DEVICE_ID" ]; then + echo "No DEVICE_UDID set — skipping warmup" + exit 0 + fi + + echo "=== Emulator warmup before agent ===" + # Verify device is still connected + if ! adb -s "$DEVICE_ID" shell getprop sys.boot_completed 2>/dev/null | grep -q "1"; then + echo "Device not responding. Restarting ADB server..." + adb kill-server 2>/dev/null || true + sleep 2 + adb start-server + sleep 2 + timeout 60 adb wait-for-device + fi + + # Dismiss ANR dialogs and wake screen — run twice for reliability + for PASS in 1 2; do + echo "--- Warmup pass $PASS ---" + adb -s "$DEVICE_ID" shell input keyevent KEYCODE_WAKEUP 2>/dev/null || true + adb -s "$DEVICE_ID" shell input keyevent KEYCODE_MENU 2>/dev/null || true + sleep 1 + + # Dismiss system dialogs (ANR, crash, etc.) + adb -s "$DEVICE_ID" shell am broadcast -a android.intent.action.CLOSE_SYSTEM_DIALOGS 2>/dev/null || true + adb -s "$DEVICE_ID" shell input keyevent KEYCODE_ENTER 2>/dev/null || true + adb -s "$DEVICE_ID" shell input keyevent KEYCODE_BACK 2>/dev/null || true + sleep 1 + done + + # Check for lingering ANR in window state + if adb -s "$DEVICE_ID" shell dumpsys window 2>/dev/null | grep -qi "Application Not Responding\|ANR"; then + echo "⚠️ ANR dialog still present — force-dismissing with HOME + BACK" + adb -s "$DEVICE_ID" shell input keyevent KEYCODE_HOME 2>/dev/null || true + sleep 2 + adb -s "$DEVICE_ID" shell am broadcast -a android.intent.action.CLOSE_SYSTEM_DIALOGS 2>/dev/null || true + adb -s "$DEVICE_ID" shell input keyevent KEYCODE_BACK 2>/dev/null || true + sleep 1 + fi + + # Open and close Settings to exercise the system and confirm responsiveness + adb -s "$DEVICE_ID" shell am start -a android.settings.SETTINGS 2>/dev/null || true + sleep 3 + adb -s "$DEVICE_ID" shell am force-stop com.android.settings 2>/dev/null || true + + # Final dialog sweep + adb -s "$DEVICE_ID" shell am broadcast -a android.intent.action.CLOSE_SYSTEM_DIALOGS 2>/dev/null || true + adb -s "$DEVICE_ID" shell input keyevent KEYCODE_BACK 2>/dev/null || true + + # Clear logcat so agent gets clean logs + adb -s "$DEVICE_ID" logcat -c 2>/dev/null || true + + echo "✅ Emulator warmed up and responsive" + displayName: 'Warm Up Android Emulator' + condition: and(succeeded(), eq('${{ parameters.Platform }}', 'android')) + timeoutInMinutes: 3 + + - script: | + echo "Running Copilot PR Reviewer Agent via Review-PR.ps1..." + echo "Reviewing PR #${{ parameters.PRNumber }}..." + + # Ensure copilot CLI is accessible to pwsh subprocess. + # npm global install on Linux goes to UseNode@1 toolcache path which may not + # be on PATH inside pwsh even when exported from bash. Create a symlink in + # /usr/local/bin which is universally on PATH for all shells. + COPILOT_PATH=$(which copilot 2>/dev/null || find /opt/hostedtoolcache/node -name copilot -type f 2>/dev/null | head -1) + if [ -n "$COPILOT_PATH" ] && [ ! -f /usr/local/bin/copilot ]; then + sudo ln -sf "$COPILOT_PATH" /usr/local/bin/copilot + echo "Symlinked copilot to /usr/local/bin/copilot" + fi + echo "copilot location: $(which copilot 2>/dev/null || echo 'not found')" + # Verify pwsh can find it + pwsh -NoProfile -c 'Write-Host "pwsh sees copilot at: $(Get-Command copilot -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source)"' + + # Configure git identity (required for merge operations on self-hosted agents) + git config user.email "copilot-ci@microsoft.com" + git config user.name "Copilot CI" + echo "Git identity configured" + + # Create Directory.Build.Override.props to skip Xcode version check + # AcesShared agents may have a newer Xcode than the .NET iOS SDK expects + cp Directory.Build.Override.props.in Directory.Build.Override.props + # Insert ValidateXcodeVersion before closing tag + # GNU sed (Linux) uses -i without suffix; BSD sed (macOS) uses -i '' + if [[ "$(uname)" == "Linux" ]]; then + sed -i 's|| false\n|' Directory.Build.Override.props + else + sed -i '' 's|| false\n|' Directory.Build.Override.props + fi + + # Create artifacts directory for Copilot outputs + mkdir -p $(Build.ArtifactStagingDirectory)/copilot-logs + + # Invoke the PR reviewer using our PowerShell script + # The script will merge the PR into the current branch + # -PostSummaryComment and -RunFinalize handle posting comments + set +e + pwsh -NoProfile .github/scripts/Review-PR.ps1 -PRNumber ${{ parameters.PRNumber }} -Platform ${{ parameters.Platform }} -LogFile "$(Build.ArtifactStagingDirectory)/copilot-logs/copilot_review_output.md" + COPILOT_EXIT_CODE=$? + set -e + + echo "Review-PR.ps1 exit code: $COPILOT_EXIT_CODE" + + # Terminate any orphaned copilot CLI processes that could hold this step's + # stdout fd open and prevent the bash step from exiting. + # Only target processes whose command line includes the copilot CLI path. + echo "Cleaning up orphaned copilot processes..." + SELF_PID=$$ + for proc in $(pgrep -f "[c]opilot" 2>/dev/null || true); do + if [ -n "$proc" ] && [ "$proc" != "$SELF_PID" ]; then + PROC_CMD=$(ps -p "$proc" -o args= 2>/dev/null || true) + if echo "$PROC_CMD" | grep -q "copilot"; then + echo " Stopping copilot process $proc: $PROC_CMD" + kill "$proc" 2>/dev/null || true + fi + fi + done + + # Copy any Copilot session files + if [ -d "$HOME/.copilot" ]; then + echo "Copying Copilot session state..." + cp -r "$HOME/.copilot" $(Build.ArtifactStagingDirectory)/copilot-logs/copilot-session-state || true + fi + + # Copy CustomAgentLogsTmp if it exists + if [ -d "CustomAgentLogsTmp" ]; then + echo "Copying CustomAgentLogsTmp..." + cp -r CustomAgentLogsTmp $(Build.ArtifactStagingDirectory)/copilot-logs/ || true + fi + + # Copy any Review_Feedback files + find . -name "Review_Feedback_*.md" -type f -exec cp {} $(Build.ArtifactStagingDirectory)/copilot-logs/ \; 2>/dev/null || true + + # Copy any .github/agent-pr-session files + if [ -d ".github/agent-pr-session" ]; then + echo "Copying agent-pr-session..." + cp -r .github/agent-pr-session $(Build.ArtifactStagingDirectory)/copilot-logs/ || true + fi + + # Check for failure indicators in output + if [ $COPILOT_EXIT_CODE -ne 0 ]; then + echo "##vso[task.logissue type=error]Review-PR.ps1 exited with code $COPILOT_EXIT_CODE" + # Don't exit yet - let artifacts be published first + echo "##vso[task.setvariable variable=CopilotFailed]true" + fi + + # Check output for common failure patterns + if grep -qi "error\|failed\|exception" $(Build.ArtifactStagingDirectory)/copilot-logs/copilot_review_output.md 2>/dev/null; then + if grep -qi "simulator.*not\|emulator.*not\|workload.*not\|sdk.*not found" $(Build.ArtifactStagingDirectory)/copilot-logs/copilot_review_output.md 2>/dev/null; then + echo "##vso[task.logissue type=warning]Copilot encountered environment issues. Check artifacts for details." + fi + fi + + echo "Review output saved to $(Build.ArtifactStagingDirectory)/copilot-logs/" + displayName: 'Run PR Reviewer Agent' + env: + COPILOT_GITHUB_TOKEN: $(COPILOT_TOKEN) + GH_TOKEN: $(GH_COMMENT_TOKEN) + DEVICE_UDID: $(DEVICE_UDID) + + # Publish Copilot logs and session artifacts + - task: PublishPipelineArtifact@1 + displayName: 'Publish Copilot Logs' + inputs: + targetPath: '$(Build.ArtifactStagingDirectory)/copilot-logs' + artifact: 'CopilotLogs' + publishLocation: 'pipeline' + condition: succeededOrFailed() + + # Publish build logs if they exist + - task: PublishPipelineArtifact@1 + displayName: 'Publish Build Logs' + inputs: + targetPath: '$(LogDirectory)' + artifact: 'BuildLogs' + publishLocation: 'pipeline' + condition: and(succeededOrFailed(), ne(variables['LogDirectory'], '')) + + # Fail the pipeline if Copilot failed + - script: | + if [ "$(CopilotFailed)" = "true" ]; then + echo "##vso[task.logissue type=error]Copilot PR review failed. Check CopilotLogs artifact for details." + exit 1 + fi + displayName: 'Check Copilot Result' + condition: succeededOrFailed() diff --git a/eng/pipelines/ci-uitests.yml b/eng/pipelines/ci-uitests.yml index 3588b03db746..7e16e1733d75 100644 --- a/eng/pipelines/ci-uitests.yml +++ b/eng/pipelines/ci-uitests.yml @@ -167,12 +167,12 @@ stages: # BuildNativeAOT is false by default, but true in devdiv environment BuildNativeAOT: ${{ or(parameters.BuildNativeAOT, and(ne(variables['Build.Reason'], 'PullRequest'), eq(variables['System.TeamProject'], 'devdiv'))) }} RunNativeAOT: ${{ parameters.RunNativeAOT }} - ${{ if or(parameters.BuildEverything, and(ne(variables['Build.Reason'], 'PullRequest'), eq(variables['System.TeamProject'], 'devdiv'))) }}: + ${{ if or(parameters.BuildEverything, ne(variables['Build.Reason'], 'PullRequest')) }}: androidApiLevels: [ 30 ] - iosVersions: [ '18.4' ] + iosVersions: [ '18.5', 'latest' ] ${{ else }}: androidApiLevels: [ 30 ] - iosVersions: [ '18.4' ] + iosVersions: [ 'latest' ] projects: - name: controls desc: Controls diff --git a/eng/pipelines/ci.yml b/eng/pipelines/ci.yml index 7787a888117b..218419358df5 100644 --- a/eng/pipelines/ci.yml +++ b/eng/pipelines/ci.yml @@ -290,7 +290,25 @@ stages: timeout: 120 testCategory: MultiProject - # TODO: macOSTemplates and AOT template categories + # TODO: macOSTemplates category + + - name: win_aot_tests + ${{ if eq(variables['Build.DefinitionName'], 'maui-pr') }}: + pool: ${{ parameters.WindowsPool.public }} + runAsPublic: true + ${{ else }}: + pool: ${{ parameters.WindowsPool.internal }} + runAsPublic: false + timeout: 120 + testCategory: AOT + - name: mac_aot_tests + ${{ if eq(variables['Build.DefinitionName'], 'maui-pr') }}: + pool: ${{ parameters.MacOSPool.public }} + ${{ else }}: + pool: ${{ parameters.MacOSPool.internal }} + timeout: 240 + testCategory: AOT + - name: mac_runandroid_tests ${{ if eq(variables['Build.DefinitionName'], 'maui-pr') }}: pool: ${{ parameters.AndroidPoolLinux }} diff --git a/eng/pipelines/common/provision.yml b/eng/pipelines/common/provision.yml index 9dbe7125e1e7..c829f9638e7a 100644 --- a/eng/pipelines/common/provision.yml +++ b/eng/pipelines/common/provision.yml @@ -32,6 +32,7 @@ parameters: expiryInHours: 1 base64Encode: false skipInternalFeeds: true + skipCertificates: false steps: @@ -168,9 +169,19 @@ steps: done if [[ -z "$XCODE_PATH" ]]; then - echo "ERROR: No suitable Xcode version found for requested version ${ORIGINAL_VERSION}" + echo "WARNING: No exact match for requested Xcode version ${ORIGINAL_VERSION}" echo "Tried: ${VERSIONS_TO_TRY[*]}" - exit 1 + echo "Falling back to latest available Xcode on this agent..." + # Find the latest Xcode by sorting version numbers + LATEST_XCODE=$(ls -1d /Applications/Xcode_*.app 2>/dev/null | sed 's|/Applications/Xcode_||;s|\.app||' | sort -t. -k1,1n -k2,2n -k3,3n | tail -1) + if [[ -n "$LATEST_XCODE" ]]; then + XCODE_VERSION="$LATEST_XCODE" + XCODE_PATH="/Applications/Xcode_${LATEST_XCODE}.app" + echo "Using latest available Xcode: ${XCODE_VERSION} at ${XCODE_PATH}" + else + echo "ERROR: No Xcode installations found in /Applications" + exit 1 + fi fi sudo xcode-select -s "$XCODE_PATH" diff --git a/eng/pipelines/common/ui-tests.yml b/eng/pipelines/common/ui-tests.yml index 5b0798ac2974..cd06dcfbcc75 100644 --- a/eng/pipelines/common/ui-tests.yml +++ b/eng/pipelines/common/ui-tests.yml @@ -9,6 +9,7 @@ parameters: androidApiLevelsExtended: [ 36 ] # API 36 for Material3 tests with Pixel 3 XL iosVersions: [ 'latest' ] provisionatorChannel: 'latest' + defaultiOSVersion: '26.0' timeoutInMinutes: 180 skipProvisioning: true BuildNativeAOT: false # Parameter to control whether NativeAOT artifacts should be built @@ -335,7 +336,7 @@ stages: parameters: platform: ios ${{ if eq(version, 'latest') }}: - version: 18.5 + version: ${{ parameters.defaultiOSVersion }} ${{ if ne(version, 'latest') }}: version: ${{ version }} path: ${{ project.ios }} @@ -377,7 +378,7 @@ stages: parameters: platform: ios ${{ if eq(version, 'latest') }}: - version: 18.5 + version: ${{ parameters.defaultiOSVersion }} ${{ if ne(version, 'latest') }}: version: ${{ version }} path: ${{ project.ios }} @@ -420,7 +421,7 @@ stages: parameters: platform: ios ${{ if eq(version, 'latest') }}: - version: 18.5 + version: ${{ parameters.defaultiOSVersion }} ${{ if ne(version, 'latest') }}: version: ${{ version }} path: ${{ project.ios }} @@ -469,7 +470,7 @@ stages: parameters: platform: ios ${{ if eq(version, 'latest') }}: - version: 18.5 + version: ${{ parameters.defaultiOSVersion }} ${{ if ne(version, 'latest') }}: version: ${{ version }} path: ${{ project.ios }} diff --git a/eng/pipelines/common/variables.yml b/eng/pipelines/common/variables.yml index 62f6b0af0fcd..012613e07189 100644 --- a/eng/pipelines/common/variables.yml +++ b/eng/pipelines/common/variables.yml @@ -54,14 +54,7 @@ variables: - group: MAUI # This is the main MAUI variable group that contains secrets for the apple certificate -# Variable groups required for all builds -- ${{ if and(ne(variables['Build.DefinitionName'], 'maui-pr'), ne(variables['Build.DefinitionName'], 'dotnet-maui'), ne(variables['Build.DefinitionName'], 'maui-pr-devicetests'), ne(variables['Build.DefinitionName'], 'maui-pr-uitests')) }}: - - group: maui-provisionator # This is just needed for the provisionator - - -- ${{ if or(eq(variables['System.TeamProject'], 'DevDiv'), eq(variables['Build.DefinitionName'], 'dotnet-maui'), eq(variables['Build.DefinitionName'], 'dotnet-maui-build')) }}: - - name: internalProvisioning - value: true +- ${{ if or(eq(variables['Build.DefinitionName'], 'dotnet-maui'), eq(variables['Build.DefinitionName'], 'dotnet-maui-build')) }}: - ${{ if notin(variables['Build.Reason'], 'PullRequest') }}: - name: PrivateBuild value: false @@ -71,13 +64,11 @@ variables: value: true - name: _SignType value: real - - - group: AzureDevOps-Artifact-Feeds-Pats - -- ${{ if eq(variables['Build.DefinitionName'], 'dotnet-maui') }}: - - ${{ if notin(variables['Build.Reason'], 'PullRequest') }}: - # Publish-Build-Assets provides: MaestroAccessToken, BotAccount-dotnet-maestro-bot-PAT - # DotNet-HelixApi-Access provides: HelixApiAccessToken - - group: Publish-Build-Assets - group: DotNet-HelixApi-Access - group: SDL_Settings + - group: AzureDevOps-Artifact-Feeds-Pats + - ${{ if eq(variables['Build.DefinitionName'], 'dotnet-maui') }}: + - group: Publish-Build-Assets # This variable group contains secrets to publis to BAR + + + diff --git a/eng/pipelines/device-tests.yml b/eng/pipelines/device-tests.yml index c290dbee24e4..340c09e61451 100644 --- a/eng/pipelines/device-tests.yml +++ b/eng/pipelines/device-tests.yml @@ -219,3 +219,15 @@ stages: ios: $(System.DefaultWorkingDirectory)/src/BlazorWebView/tests/DeviceTests/MauiBlazorWebView.DeviceTests.csproj catalyst: $(System.DefaultWorkingDirectory)/src/BlazorWebView/tests/DeviceTests/MauiBlazorWebView.DeviceTests.csproj windows: $(System.DefaultWorkingDirectory)/src/BlazorWebView/tests/DeviceTests/MauiBlazorWebView.DeviceTests.csproj + - name: essentialsai + desc: Essentials.AI + androidApiLevelsExclude: [ 25, 27 ] + androidApiLevelsCoreClrExclude: [ 27, 25, 23] + androidConfiguration: 'Release' + iOSConfiguration: 'Debug' + windowsConfiguration: 'Debug' + windowsPackageId: 'com.microsoft.maui.ai.devicetests' + android: $(System.DefaultWorkingDirectory)/src/AI/tests/Essentials.AI.DeviceTests/Essentials.AI.DeviceTests.csproj + ios: $(System.DefaultWorkingDirectory)/src/AI/tests/Essentials.AI.DeviceTests/Essentials.AI.DeviceTests.csproj + catalyst: $(System.DefaultWorkingDirectory)/src/AI/tests/Essentials.AI.DeviceTests/Essentials.AI.DeviceTests.csproj + windows: $(System.DefaultWorkingDirectory)/src/AI/tests/Essentials.AI.DeviceTests/Essentials.AI.DeviceTests.csproj diff --git a/eng/scripts/get-maui-pr.ps1 b/eng/scripts/get-maui-pr.ps1 index e20f81a86828..eeb19414d1aa 100644 --- a/eng/scripts/get-maui-pr.ps1 +++ b/eng/scripts/get-maui-pr.ps1 @@ -146,12 +146,33 @@ function Get-PullRequestInfo { } } -# Get build information from GitHub Checks API +# Check if a build is currently in progress for this PR via Azure DevOps API +function Test-BuildInProgress { + param([int]$PrNumber) + + try { + $buildsUrl = "https://dev.azure.com/$AzureDevOpsOrg/$AzureDevOpsProject/_apis/build/builds?api-version=7.1&branchName=refs/pull/$PrNumber/merge&`$top=10" + $response = Invoke-RestMethod -Uri $buildsUrl -Headers @{ "User-Agent" = "MAUI-PR-Script" } -TimeoutSec 30 + + foreach ($build in $response.value) { + if ($build.definition.name -eq "maui-pr" -and $build.status -in @("inProgress", "notStarted", "postponed")) { + return $true + } + } + return $false + } + catch { + return $false + } +} + +# Get build information from GitHub Checks API, with AzDO fallback function Get-BuildInfo { - param([string]$SHA) + param([string]$SHA, [int]$PrNumber) Write-Info "Looking for build artifacts for commit $($SHA.Substring(0, 7))..." + # Strategy 1: Try GitHub Checks API try { $checksUrl = "https://api.github.com/repos/$GitHubRepo/commits/$SHA/check-runs" $response = Invoke-RestMethod -Uri $checksUrl -Headers ($GitHubHeaders + @{ @@ -163,34 +184,87 @@ function Get-BuildInfo { $_.name -like "maui-pr*" -and $_.name -notlike "*uitests*" -and $_.status -eq "completed" -and $_.details_url -match 'buildId=' } | Select-Object -First 1 - if (-not $buildCheck) { - throw "No completed build found for this PR. The build may still be in progress or may have failed." - } - - if ($buildCheck.conclusion -ne "success") { - Write-Warn "Build completed with status: $($buildCheck.conclusion)" - if (-not $Yes) { - $continue = Read-Host "Do you want to continue anyway? (y/N)" - if ($continue -ne "y" -and $continue -ne "Y") { - throw "Build was not successful. Aborting." + if ($buildCheck) { + if ($buildCheck.conclusion -ne "success") { + Write-Warn "Build completed with status: $($buildCheck.conclusion)" + if (-not $Yes) { + $continue = Read-Host "Do you want to continue anyway? (y/N)" + if ($continue -ne "y" -and $continue -ne "Y") { + throw "Build was not successful. Aborting." + } + } + } + + # Extract build ID from details URL + if ($buildCheck.details_url -match 'buildId=(\d+)') { + Write-Success "Found build ID: $($Matches[1]) (via GitHub Checks)" + return @{ + BuildId = $Matches[1] + Status = $buildCheck.conclusion + Url = $buildCheck.details_url } } } + } + catch { + Write-Info "GitHub Checks API lookup failed, trying Azure DevOps directly..." + } + + # Strategy 2: Query Azure DevOps directly (handles merge commits not reported to GitHub) + Write-Info "Searching Azure DevOps directly for PR #$PrNumber builds..." + try { + $buildsUrl = "https://dev.azure.com/$AzureDevOpsOrg/$AzureDevOpsProject/_apis/build/builds?api-version=7.1&branchName=refs/pull/$PrNumber/merge&`$top=10" + $response = Invoke-RestMethod -Uri $buildsUrl -Headers @{ "User-Agent" = "MAUI-PR-Script" } -TimeoutSec 30 + + $completedBuild = $response.value | Where-Object { + $_.definition.name -eq "maui-pr" -and $_.status -eq "completed" + } | Select-Object -First 1 - # Extract build ID from details URL - if ($buildCheck.details_url -match 'buildId=(\d+)') { + if ($completedBuild) { + # Validate build ID is numeric + if ("$($completedBuild.id)" -notmatch '^\d+$') { + throw "Invalid build ID received from Azure DevOps API" + } + + # Check if a newer build is in progress (user may have pushed a new commit) + $inProgressBuild = $response.value | Where-Object { + $_.definition.name -eq "maui-pr" -and $_.status -in @("inProgress", "notStarted", "postponed") + } | Select-Object -First 1 + if ($inProgressBuild) { + Write-Warn "A newer build is currently in progress. The available artifacts may be from a previous commit." + Write-Warn "If you just pushed changes, wait for the new build to complete." + } + + if ($completedBuild.result -ne "succeeded") { + Write-Warn "Build completed with result: $($completedBuild.result)" + if (-not $Yes) { + $continue = Read-Host "Do you want to continue anyway? (y/N)" + if ($continue -ne "y" -and $continue -ne "Y") { + throw "Build was not successful. Aborting." + } + } + } + + $buildUrl = "https://dev.azure.com/$AzureDevOpsOrg/$AzureDevOpsProject/_build/results?buildId=$($completedBuild.id)" + Write-Success "Found build ID: $($completedBuild.id) (via Azure DevOps)" return @{ - BuildId = $Matches[1] - Status = $buildCheck.conclusion - Url = $buildCheck.details_url + BuildId = "$($completedBuild.id)" + Status = $completedBuild.result + Url = $buildUrl } } - - throw "Could not extract build ID from check run details." } catch { - throw "Failed to get build information: $_" + Write-Info "Azure DevOps direct lookup also failed." } + + # No build found - check if one is in progress + $buildInProgress = Test-BuildInProgress -PrNumber $PrNumber + if ($buildInProgress) { + throw "No completed build found, but a build is currently in progress for PR #$PrNumber. Please wait for it to complete and try again. Check status: https://github.com/dotnet/maui/pull/$PrNumber" + } + + throw "No completed build found for PR #$PrNumber. The PR may not have triggered CI builds yet (draft PRs don't auto-trigger builds), or the build may have failed. Check: https://github.com/dotnet/maui/pull/$PrNumber" } # Get artifacts from Azure DevOps @@ -468,7 +542,7 @@ try { Write-Info "Current target framework: .NET $targetNetVersion.0" Write-Step "Finding build artifacts" - $buildInfo = Get-BuildInfo -SHA $prInfo.SHA + $buildInfo = Get-BuildInfo -SHA $prInfo.SHA -PrNumber $PrNumber Write-Step "Downloading artifacts" $downloadUrl = Get-BuildArtifacts -BuildId $buildInfo.BuildId diff --git a/eng/scripts/get-maui-pr.sh b/eng/scripts/get-maui-pr.sh index 9541a0556644..8a37b98c6006 100644 --- a/eng/scripts/get-maui-pr.sh +++ b/eng/scripts/get-maui-pr.sh @@ -178,12 +178,32 @@ get_pr_info() { echo "$pr_json" } -# Get build information from GitHub Checks API +# Check if a build is currently in progress for this PR via Azure DevOps API +check_build_in_progress() { + local pr_num="$1" + + local builds_url="https://dev.azure.com/$AZURE_DEVOPS_ORG/$AZURE_DEVOPS_PROJECT/_apis/build/builds?api-version=7.1&branchName=refs/pull/$pr_num/merge&\$top=10" + local builds_json + builds_json=$(curl -s -H "User-Agent: MAUI-PR-Script" "$builds_url" 2>/dev/null) || return 1 + + # Check if any maui-pr build is in progress + local in_progress + in_progress=$(echo "$builds_json" | jq -r '[.value[] | select(.definition.name == "maui-pr" and (.status == "inProgress" or .status == "notStarted" or .status == "postponed"))] | length' 2>/dev/null || echo "0") + + if [ "$in_progress" != "0" ] && [ -n "$in_progress" ]; then + return 0 # true - build is in progress + fi + return 1 # false - no build in progress +} + +# Get build information from GitHub Checks API, with AzDO fallback get_build_info() { local sha="$1" + local pr_num="$2" info "Looking for build artifacts for commit ${sha:0:7}..." + # Strategy 1: Try GitHub Checks API local checks_url="https://api.github.com/repos/$GITHUB_REPO/commits/$sha/check-runs" local checks_json checks_json=$(curl -s -H "User-Agent: MAUI-PR-Script" -H "Accept: application/vnd.github.v3+json" ${GITHUB_AUTH_HEADER:+-H "$GITHUB_AUTH_HEADER"} "$checks_url") @@ -191,37 +211,91 @@ get_build_info() { # Find the main MAUI build check (not uitests) local build_check=$(echo "$checks_json" | jq -r '.check_runs[] | select((.name | startswith("maui-pr")) and (.name | contains("uitests") | not) and .status == "completed" and (.details_url | contains("buildId="))) | @json' | head -n 1) - if [ -z "$build_check" ] || [ "$build_check" == "null" ]; then - error "No completed build found for this PR" - info "The build may still be in progress or may have failed." - exit 1 + if [ -n "$build_check" ] && [ "$build_check" != "null" ]; then + local conclusion=$(echo "$build_check" | jq -r '.conclusion') + if [ "$conclusion" != "success" ]; then + warning "Build completed with status: $conclusion" + if [ "$YES_FLAG" = true ]; then + info "Auto-accepting non-successful build (-y flag)" + else + read -p "Do you want to continue anyway? (y/N) " -n 1 -r + echo >&2 + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + error "Build was not successful. Aborting." + exit 1 + fi + fi + fi + + # Extract build ID from details URL + local details_url=$(echo "$build_check" | jq -r '.details_url') + if [[ "$details_url" =~ buildId=([0-9]+) ]]; then + local build_id="${BASH_REMATCH[1]}" + success "Found build ID: $build_id (via GitHub Checks)" + echo "$build_id" + return 0 + fi fi - local conclusion=$(echo "$build_check" | jq -r '.conclusion') - if [ "$conclusion" != "success" ]; then - warning "Build completed with status: $conclusion" - if [ "$YES_FLAG" = true ]; then - info "Auto-accepting non-successful build (-y flag)" - else - read -p "Do you want to continue anyway? (y/N) " -n 1 -r - echo >&2 - if [[ ! $REPLY =~ ^[Yy]$ ]]; then - error "Build was not successful. Aborting." + # Strategy 2: Query Azure DevOps directly (handles merge commits not reported to GitHub) + info "Searching Azure DevOps directly for PR #$pr_num builds..." + local builds_url="https://dev.azure.com/$AZURE_DEVOPS_ORG/$AZURE_DEVOPS_PROJECT/_apis/build/builds?api-version=7.1&branchName=refs/pull/$pr_num/merge&\$top=10" + local builds_json + builds_json=$(curl -s -H "User-Agent: MAUI-PR-Script" "$builds_url" 2>/dev/null) + + if [ -n "$builds_json" ]; then + local completed_build + completed_build=$(echo "$builds_json" | jq -r '[.value[] | select(.definition.name == "maui-pr" and .status == "completed")] | first | @json' 2>/dev/null || echo "") + + if [ -n "$completed_build" ] && [ "$completed_build" != "null" ]; then + local azdo_build_id=$(echo "$completed_build" | jq -r '.id') + local azdo_result=$(echo "$completed_build" | jq -r '.result') + + # Validate build ID is numeric + if ! [[ "$azdo_build_id" =~ ^[0-9]+$ ]]; then + error "Invalid build ID received from Azure DevOps API" exit 1 fi + + # Check if a newer build is in progress (user may have pushed a new commit) + local in_progress_count + in_progress_count=$(echo "$builds_json" | jq -r '[.value[] | select(.definition.name == "maui-pr" and (.status == "inProgress" or .status == "notStarted" or .status == "postponed"))] | length' 2>/dev/null || echo "0") + if [ "$in_progress_count" != "0" ] && [ -n "$in_progress_count" ]; then + warning "A newer build is currently in progress. The available artifacts may be from a previous commit." + warning "If you just pushed changes, wait for the new build to complete." + fi + + if [ "$azdo_result" != "succeeded" ]; then + warning "Build completed with result: $azdo_result" + if [ "$YES_FLAG" = true ]; then + info "Auto-accepting non-successful build (-y flag)" + else + read -p "Do you want to continue anyway? (y/N) " -n 1 -r + echo >&2 + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + error "Build was not successful. Aborting." + exit 1 + fi + fi + fi + + success "Found build ID: $azdo_build_id (via Azure DevOps)" + echo "$azdo_build_id" + return 0 fi fi - # Extract build ID from details URL - local details_url=$(echo "$build_check" | jq -r '.details_url') - if [[ "$details_url" =~ buildId=([0-9]+) ]]; then - local build_id="${BASH_REMATCH[1]}" - success "Found build ID: $build_id" - echo "$build_id" - return 0 + # No build found - check if one is in progress + if check_build_in_progress "$pr_num"; then + error "No completed build found, but a build is currently in progress for PR #$pr_num" + info "Please wait for it to complete and try again." + info "Check status: https://github.com/dotnet/maui/pull/$pr_num" + exit 1 fi - error "Could not extract build ID from check run details" + error "No completed build found for PR #$pr_num" + info "The PR may not have triggered CI builds yet (draft PRs don't auto-trigger builds), or the build may have failed." + info "Check: https://github.com/dotnet/maui/pull/$pr_num" exit 1 } @@ -450,6 +524,13 @@ main() { fi pr_number="${positional_args[0]}" # Global for error handler + + # Validate PR number is numeric + if ! [[ "$pr_number" =~ ^[0-9]+$ ]]; then + error "PR number must be a valid number, got: $pr_number" + exit 1 + fi + local project_path_arg="${positional_args[1]:-}" # Check dependencies @@ -497,7 +578,7 @@ EOF step "Finding build artifacts" local build_id - build_id=$(get_build_info "$pr_sha") + build_id=$(get_build_info "$pr_sha" "$pr_number") step "Downloading artifacts" local download_url diff --git a/src/AI/samples/Essentials.AI.Sample/AI/1_TravelPlannerExecutor.cs b/src/AI/samples/Essentials.AI.Sample/AI/1_TravelPlannerExecutor.cs index b8522927d006..8f20bca343c0 100644 --- a/src/AI/samples/Essentials.AI.Sample/AI/1_TravelPlannerExecutor.cs +++ b/src/AI/samples/Essentials.AI.Sample/AI/1_TravelPlannerExecutor.cs @@ -1,4 +1,3 @@ -using System.Text.Json; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Workflows; using Microsoft.Extensions.AI; @@ -9,30 +8,17 @@ namespace Maui.Controls.Sample.AI; ///

/// Agent 1: Travel Planner - Parses natural language to extract intent. /// No tools - just NLP to extract destinationName, dayCount, language. -/// Extends ChatProtocolExecutor to support the chat protocol for workflow-as-agent. /// -internal sealed class TravelPlannerExecutor(AIAgent agent, JsonSerializerOptions jsonOptions, ILogger logger) - : ChatProtocolExecutor("TravelPlannerExecutor") +internal sealed class TravelPlannerExecutor(AIAgent agent, ILogger logger) + : ChatProtocolExecutor("TravelPlannerExecutor", new ChatProtocolExecutorOptions { AutoSendTurnToken = false }) { - public const string Instructions = """ - You are a simple text parser. - - Extract ONLY these 3 values from the user's request: - 1. destinationName: The place/location name mentioned (extract it exactly as written) - 2. dayCount: The number of days mentioned (default: 3 if not specified) - 3. language: The language mentioned for the output (default: English if not specified) - - Rules: - 1. ALWAYS extract the raw values. - 2. NEVER make up values or interpret the user's intent. - - Examples: - - "5-day trip to Maui in French" → destinationName: "Maui", dayCount: 5, language: "French" - - "Visit the Great Wall" → destinationName: "Great Wall", dayCount: 3, language: "English" - - "Itinerary for Tokyo" → destinationName: "Tokyo", dayCount: 3, language: "English" - - "Give me a Maui itinerary" → destinationName: "Maui", dayCount: 3, language: "English" - - "Plan a 7 day Japan trip in Spanish" → destinationName: "Japan", dayCount: 7, language: "Spanish" - """; + /// + /// Declares TravelPlanResult as a sent message type so the edge router can map it to downstream executors. + /// Without this, ChatProtocolExecutor only declares List<ChatMessage> and TurnToken, causing + /// TravelPlanResult to be silently dropped with DroppedTypeMismatch. + /// + protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) + => base.ConfigureProtocol(protocolBuilder).SendsMessage(); protected override async ValueTask TakeTurnAsync( List messages, @@ -42,18 +28,13 @@ protected override async ValueTask TakeTurnAsync( { logger.LogDebug("[TravelPlannerExecutor] Starting - parsing user intent"); - await context.AddEventAsync(new ExecutorStatusEvent("Analyzing your request...")); + await context.AddEventAsync(new ExecutorStatusEvent("Analyzing your request..."), cancellationToken); - var runOptions = new ChatClientAgentRunOptions(new ChatOptions - { - ResponseFormat = ChatResponseFormat.ForJsonSchema(jsonOptions) - }); - - var response = await agent.RunAsync(messages, options: runOptions, cancellationToken: cancellationToken); + var response = await agent.RunAsync(messages, cancellationToken: cancellationToken); logger.LogTrace("[TravelPlannerExecutor] Raw response: {Response}", response.Text); - var result = JsonSerializer.Deserialize(response.Text, jsonOptions)!; + var result = response.Result; logger.LogDebug("[TravelPlannerExecutor] Completed - extracted: destination={Destination}, days={Days}, language={Language}", result.DestinationName, result.DayCount, result.Language); @@ -61,7 +42,7 @@ protected override async ValueTask TakeTurnAsync( var summary = result.Language != "English" ? $"Planning {result.DayCount}-day trip to {result.DestinationName} in {result.Language}" : $"Planning {result.DayCount}-day trip to {result.DestinationName}"; - await context.AddEventAsync(new ExecutorStatusEvent(summary)); + await context.AddEventAsync(new ExecutorStatusEvent(summary), cancellationToken); await context.SendMessageAsync(result, cancellationToken); } diff --git a/src/AI/samples/Essentials.AI.Sample/AI/2_ResearcherExecutor.cs b/src/AI/samples/Essentials.AI.Sample/AI/2_ResearcherExecutor.cs index 409968a245d7..ddc9cad04547 100644 --- a/src/AI/samples/Essentials.AI.Sample/AI/2_ResearcherExecutor.cs +++ b/src/AI/samples/Essentials.AI.Sample/AI/2_ResearcherExecutor.cs @@ -1,40 +1,20 @@ -using System.ComponentModel; -using System.Text.Json; -using Maui.Controls.Sample.Models; -using Maui.Controls.Sample.Services; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Workflows; -using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; namespace Maui.Controls.Sample.AI; /// -/// Agent 2: Researcher - Uses RAG to find candidate destinations, then AI selects the best match. -/// Uses semantic search (embeddings) to pre-filter destinations, then LLM picks the best one. +/// Agent 2: Researcher - Uses TextSearchProvider (RAG) to automatically inject matching destinations +/// into the AI context before each invocation, then the AI selects the best match. +/// The TextSearchProvider is configured in with BeforeAIInvoke mode, so candidate destinations are +/// automatically searched and injected. /// -internal sealed class ResearcherExecutor(AIAgent agent, DataService dataService, JsonSerializerOptions jsonOptions, ILogger logger) - : Executor("ResearcherExecutor") +internal sealed partial class ResearcherExecutor(AIAgent agent, ILogger logger) + : Executor("ResearcherExecutor") { - /// - /// Maximum number of RAG candidates to return from semantic search. - /// - private const int MaxRagCandidates = 5; - - public const string Instructions = """ - You are a travel researcher. - Your job is to select the best matching destination from a list of candidates. - - Rules: - 1. You will be given a list of candidate destinations that semantically match the user's request. - 2. Select the ONE destination that best matches what the user asked for. - 3. NEVER make up destinations - only choose from the provided candidates. - 4. If none of the candidates match well, pick the closest one. - - Return the exact name of the best matching destination from the candidates. - """; - - public override async ValueTask HandleAsync( + [MessageHandler] + private async ValueTask HandleAsync( TravelPlanResult input, IWorkflowContext context, CancellationToken cancellationToken = default) @@ -42,72 +22,34 @@ public override async ValueTask HandleAsync( logger.LogDebug("[ResearcherExecutor] Starting - finding best matching destination for '{DestinationName}'", input.DestinationName); logger.LogTrace("[ResearcherExecutor] Input: {@Input}", input); - await context.AddEventAsync(new ExecutorStatusEvent("Searching destinations...")); - - // Step 1: Use RAG to find semantically similar destinations - var candidates = await dataService.SearchLandmarksAsync(input.DestinationName, MaxRagCandidates); - - logger.LogDebug("[ResearcherExecutor] RAG returned {Count} candidates: {Names}", - candidates.Count, string.Join(", ", candidates.Select(c => c.Name))); - - if (candidates.Count == 0) - { - logger.LogDebug("[ResearcherExecutor] No candidates found"); - await context.AddEventAsync(new ExecutorStatusEvent("No matching destinations found")); - return new ResearchResult(null, input.DayCount, input.Language); - } + await context.AddEventAsync(new ExecutorStatusEvent("Searching destinations..."), cancellationToken); - // If only one candidate, use it directly without LLM call - if (candidates.Count == 1) - { - var singleMatch = candidates[0]; - logger.LogDebug("[ResearcherExecutor] Single candidate found: {Name}", singleMatch.Name); - await context.AddEventAsync(new ExecutorStatusEvent($"Found destination: {singleMatch.Name}")); - return new ResearchResult(singleMatch, input.DayCount, input.Language); - } - - await context.AddEventAsync(new ExecutorStatusEvent($"Evaluating {candidates.Count} candidates...")); - - // Step 2: Ask LLM to pick the best match from RAG candidates - var candidateDescriptions = string.Join("\n", candidates.Select(c => - $"- {c.Name}: {c.ShortDescription}")); - - var prompt = $""" - The user wants to visit: "{input.DestinationName}" - - Here are the available destinations that might match: - {candidateDescriptions} - - Which destination best matches what the user is looking for? - """; + // TextSearchProvider (configured via CreateAgent) automatically searches + // DataService.SearchLandmarksAsync and injects results as context before + // the AI call. We just need to ask the AI to pick the best match. + var prompt = input.DestinationName; logger.LogTrace("[ResearcherExecutor] Prompt: {Prompt}", prompt); - var runOptions = new ChatClientAgentRunOptions(new ChatOptions - { - ResponseFormat = ChatResponseFormat.ForJsonSchema(jsonOptions) - }); - - var response = await agent.RunAsync(prompt, options: runOptions, cancellationToken: cancellationToken); + var response = await agent.RunAsync(prompt, cancellationToken: cancellationToken); logger.LogTrace("[ResearcherExecutor] Raw response: {Response}", response.Text); - // Parse the AI's response to get the matched destination name - var matchResult = JsonSerializer.Deserialize(response.Text, jsonOptions); - var matchedName = matchResult?.MatchedDestinationName ?? input.DestinationName; - - logger.LogDebug("[ResearcherExecutor] AI selected '{MatchedName}' from candidates", matchedName); + // Parse the AI's response — both name and description come from RAG context + var matchResult = response.Result; - // Find the landmark from candidates (prefer exact match from candidates) - var landmark = candidates.FirstOrDefault(l => l.Name.Equals(matchedName, StringComparison.OrdinalIgnoreCase)) - ?? candidates[0]; // Fallback to top RAG result if LLM returned unexpected name + logger.LogDebug("[ResearcherExecutor] AI selected '{MatchedName}'", matchResult.MatchedDestinationName); - var result = new ResearchResult(landmark, input.DayCount, input.Language); + var result = new ResearchResult( + matchResult.MatchedDestinationName, + matchResult.MatchedDestinationDescription, + input.DayCount, + input.Language); - logger.LogDebug("[ResearcherExecutor] Completed - selected destination: {Name}", landmark.Name); + logger.LogDebug("[ResearcherExecutor] Completed - selected destination: {Name}", matchResult.MatchedDestinationName); logger.LogTrace("[ResearcherExecutor] Output: {@Result}", result); - await context.AddEventAsync(new ExecutorStatusEvent($"Found destination: {landmark.Name}")); + await context.AddEventAsync(new ExecutorStatusEvent($"Found destination: {matchResult.MatchedDestinationName}"), cancellationToken); return result; } diff --git a/src/AI/samples/Essentials.AI.Sample/AI/3_ItineraryPlannerExecutor.cs b/src/AI/samples/Essentials.AI.Sample/AI/3_ItineraryPlannerExecutor.cs index 52c60c46c301..c70a0e32c0b5 100644 --- a/src/AI/samples/Essentials.AI.Sample/AI/3_ItineraryPlannerExecutor.cs +++ b/src/AI/samples/Essentials.AI.Sample/AI/3_ItineraryPlannerExecutor.cs @@ -1,7 +1,4 @@ -using System.ComponentModel; using System.Text; -using System.Text.Json; -using Maui.Controls.Sample.Models; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Workflows; using Microsoft.Extensions.AI; @@ -11,71 +8,42 @@ namespace Maui.Controls.Sample.AI; /// /// Agent 3: Itinerary Planner - Builds the travel itinerary with streaming output. -/// Tools: findPointsOfInterest(destinationName, category, query) +/// Tools are used to assist in generating the itinerary. /// Uses RunStreamingAsync to emit partial JSON as it's generated. /// -internal sealed class ItineraryPlannerExecutor(AIAgent agent, JsonSerializerOptions jsonOptions, ILogger logger) - : Executor("ItineraryPlannerExecutor") +internal sealed partial class ItineraryPlannerExecutor(AIAgent agent, ILogger logger) + : Executor("ItineraryPlannerExecutor") { - private IWorkflowContext? _context; - - public const string Instructions = $""" - You create detailed travel itineraries. - - For each day include these places: - 1. An activity or attraction - 2. A hotel recommendation - 3. A restaurant recommendation - - Rules: - 1. ALWAYS use the `{FindPointsOfInterestToolName}` tool to discover real places near the destination. - 2. NEVER make up places or use your own knowledge. - 3. ONLY use places returned by the `{FindPointsOfInterestToolName}` tool. - 4. PREFER the places returned by the `{FindPointsOfInterestToolName}` tool instead of the destination description. - - Give the itinerary a fun, creative title and engaging description. - - Include a rationale explaining why you chose these activities for the traveler. - """; - - public const string FindPointsOfInterestToolName = "findPointsOfInterest"; - - public override async ValueTask HandleAsync( + [MessageHandler] + private async ValueTask HandleAsync( ResearchResult input, IWorkflowContext context, CancellationToken cancellationToken = default) { - _context = context; - - logger.LogDebug("[ItineraryPlannerExecutor] Starting - building {Days}-day itinerary for '{Landmark}'", - input.DayCount, input.Landmark?.Name ?? "unknown"); + logger.LogDebug("[ItineraryPlannerExecutor] Starting - building {Days}-day itinerary for '{Destination}'", + input.DayCount, input.DestinationName ?? "unknown"); logger.LogTrace("[ItineraryPlannerExecutor] Input: {@Input}", input); - await context.AddEventAsync(new ExecutorStatusEvent("Building your itinerary...")); + await context.AddEventAsync(new ExecutorStatusEvent("Building your itinerary..."), cancellationToken); - if (input.Landmark is null) + if (input.DestinationName is null) { - logger.LogDebug("[ItineraryPlannerExecutor] No landmark found - returning error"); - await context.AddEventAsync(new ExecutorStatusEvent("Error: No destination found")); - return new ItineraryResult(JsonSerializer.Serialize(new { error = "Landmark not found" }), input.Language); + logger.LogDebug("[ItineraryPlannerExecutor] No destination found - returning error"); + await context.AddEventAsync(new ExecutorStatusEvent("Error: No destination found"), cancellationToken); + return new ItineraryResult(System.Text.Json.JsonSerializer.Serialize(new { error = "Destination not found" }), input.Language); } var prompt = $""" - Generate a {input.DayCount}-day itinerary to {input.Landmark.Name}. - Destination description: {input.Landmark.Description} + Generate a {input.DayCount}-day itinerary to {input.DestinationName}. + Destination description: {input.DestinationDescription} """; logger.LogTrace("[ItineraryPlannerExecutor] Prompt: {Prompt}", prompt); - var runOptions = new ChatClientAgentRunOptions(new ChatOptions - { - Tools = [AIFunctionFactory.Create(FindPointsOfInterestAsync, name: FindPointsOfInterestToolName)], - ResponseFormat = ChatResponseFormat.ForJsonSchema(jsonOptions) - }); - // Use streaming to emit partial JSON as it's generated + // Tools and ResponseFormat are configured at agent level in ItineraryWorkflowExtensions var fullResponse = new StringBuilder(); - await foreach (var update in agent.RunStreamingAsync(prompt, options: runOptions, cancellationToken: cancellationToken)) + await foreach (var update in agent.RunStreamingAsync(prompt, cancellationToken: cancellationToken)) { foreach (var content in update.Contents) { @@ -92,53 +60,8 @@ Generate a {input.DayCount}-day itinerary to {input.Landmark.Name}. logger.LogTrace("[ItineraryPlannerExecutor] Raw response: {Response}", responseText); logger.LogDebug("[ItineraryPlannerExecutor] Completed - itinerary generated, language: {Language}", input.Language); - await context.AddEventAsync(new ExecutorStatusEvent($"Created {input.DayCount}-day itinerary for {input.Landmark.Name}")); + await context.AddEventAsync(new ExecutorStatusEvent($"Created {input.DayCount}-day itinerary for {input.DestinationName}"), cancellationToken); return new ItineraryResult(responseText, input.Language); } - - [Description("Finds points of interest (hotels, restaurants, activities) near a destination.")] - private async Task FindPointsOfInterestAsync( - [Description("The name of the destination to search near.")] - string destinationName, - [Description("The category of place to find (Hotel, Restaurant, Cafe, Museum, etc.).")] - PointOfInterestCategory category, - [Description("A natural language query to refine the search.")] - string additionalSearchQuery) - { - if (_context is not null) - { - await _context.AddEventAsync(new ExecutorStatusEvent($"Finding {category}s near {destinationName}...")); - } - - var suggestions = GetSuggestions(category); - var result = $""" - These {category} options are available near {destinationName}: - - - {string.Join(Environment.NewLine + "- ", suggestions)} - """; - - logger.LogTrace("[ItineraryPlannerExecutor] findPointsOfInterest tool called - destination={Destination}, category={Category}, query={Query}, result={Result}", - destinationName, category, additionalSearchQuery ?? "(none)", result); - - if (_context is not null) - { - await _context.AddEventAsync(new ExecutorStatusEvent($"Found {suggestions.Length} {category} options")); - } - - return result; - } - - private static string[] GetSuggestions(PointOfInterestCategory category) => - category switch - { - PointOfInterestCategory.Cafe => ["Cafe 1", "Cafe 2", "Cafe 3"], - PointOfInterestCategory.Campground => ["Campground 1", "Campground 2", "Campground 3"], - PointOfInterestCategory.Hotel => ["Hotel 1", "Hotel 2", "Hotel 3"], - PointOfInterestCategory.Marina => ["Marina 1", "Marina 2", "Marina 3"], - PointOfInterestCategory.Museum => ["Museum 1", "Museum 2", "Museum 3"], - PointOfInterestCategory.NationalMonument => ["The National Rock 1", "The National Rock 2", "The National Rock 3"], - PointOfInterestCategory.Restaurant => ["Restaurant 1", "Restaurant 2", "Restaurant 3"], - _ => [] - }; } diff --git a/src/AI/samples/Essentials.AI.Sample/AI/4_TranslatorExecutor.cs b/src/AI/samples/Essentials.AI.Sample/AI/4_TranslatorExecutor.cs index 4e582c7b8a28..122494ece73e 100644 --- a/src/AI/samples/Essentials.AI.Sample/AI/4_TranslatorExecutor.cs +++ b/src/AI/samples/Essentials.AI.Sample/AI/4_TranslatorExecutor.cs @@ -1,6 +1,4 @@ using System.Text; -using System.Text.Json; -using Maui.Controls.Sample.Models; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Workflows; using Microsoft.Extensions.AI; @@ -10,22 +8,14 @@ namespace Maui.Controls.Sample.AI; /// /// Agent 4: Translator - Translates the itinerary to target language (conditional) with streaming. -/// No tools - just translation. Uses RunStreamingAsync to emit partial translated JSON. +/// No tools - just translation. +/// Uses RunStreamingAsync to emit partial translated JSON. /// -internal sealed class TranslatorExecutor(AIAgent agent, JsonSerializerOptions jsonOptions, ILogger logger) - : Executor("TranslatorExecutor") +internal sealed partial class TranslatorExecutor(AIAgent agent, ILogger logger) + : Executor("TranslatorExecutor") { - public const string Instructions = """ - You are a professional translator. - Translate the provided JSON content to the target language. - - Rules: - 1. ALWAYS preserve the JSON format exactly. - 2. ONLY translate the text values within the JSON. - 3. NEVER add explanations or commentary. - """; - - public override async ValueTask HandleAsync( + [MessageHandler] + private async ValueTask HandleAsync( ItineraryResult input, IWorkflowContext context, CancellationToken cancellationToken = default) @@ -33,12 +23,7 @@ public override async ValueTask HandleAsync( logger.LogDebug("[TranslatorExecutor] Starting - translating to '{Language}'", input.TargetLanguage); logger.LogTrace("[TranslatorExecutor] Input JSON: {Json}", input.ItineraryJson); - await context.AddEventAsync(new ExecutorStatusEvent($"Translating to {input.TargetLanguage}...")); - - var runOptions = new ChatClientAgentRunOptions(new ChatOptions - { - ResponseFormat = ChatResponseFormat.ForJsonSchema(jsonOptions) - }); + await context.AddEventAsync(new ExecutorStatusEvent($"Translating to {input.TargetLanguage}..."), cancellationToken); var prompt = $""" Translate to {input.TargetLanguage}: @@ -49,8 +34,9 @@ public override async ValueTask HandleAsync( logger.LogTrace("[TranslatorExecutor] Prompt: {Prompt}", prompt); // Use streaming to emit partial JSON as it's generated + // ResponseFormat is set at agent creation time in ItineraryWorkflowExtensions var fullResponse = new StringBuilder(); - await foreach (var update in agent.RunStreamingAsync(prompt, options: runOptions, cancellationToken: cancellationToken)) + await foreach (var update in agent.RunStreamingAsync(prompt, cancellationToken: cancellationToken)) { foreach (var content in update.Contents) { @@ -67,7 +53,7 @@ public override async ValueTask HandleAsync( logger.LogTrace("[TranslatorExecutor] Raw response: {Response}", responseText); logger.LogDebug("[TranslatorExecutor] Completed - translation to '{Language}' finished", input.TargetLanguage); - await context.AddEventAsync(new ExecutorStatusEvent($"Translated to {input.TargetLanguage}")); + await context.AddEventAsync(new ExecutorStatusEvent($"Translated to {input.TargetLanguage}"), cancellationToken); return new ItineraryResult(responseText, input.TargetLanguage); } diff --git a/src/AI/samples/Essentials.AI.Sample/AI/5_OutputExecutor.cs b/src/AI/samples/Essentials.AI.Sample/AI/5_OutputExecutor.cs index 8b046e0f2a56..85610945ad9e 100644 --- a/src/AI/samples/Essentials.AI.Sample/AI/5_OutputExecutor.cs +++ b/src/AI/samples/Essentials.AI.Sample/AI/5_OutputExecutor.cs @@ -7,10 +7,11 @@ namespace Maui.Controls.Sample.AI; /// Final executor that marks the workflow as complete. /// The itinerary JSON has already been streamed by ItineraryPlannerExecutor or TranslatorExecutor. /// -internal sealed class OutputExecutor(ILogger logger) - : Executor("OutputExecutor") +internal sealed partial class OutputExecutor(ILogger logger) + : Executor("OutputExecutor") { - public override async ValueTask HandleAsync( + [MessageHandler] + private async ValueTask HandleAsync( ItineraryResult input, IWorkflowContext context, CancellationToken cancellationToken = default) @@ -19,7 +20,7 @@ public override async ValueTask HandleAsync( logger.LogTrace("[OutputExecutor] Final JSON: {Json}", input.ItineraryJson); // Don't re-emit the JSON - it was already streamed by ItineraryPlannerExecutor or TranslatorExecutor - await context.AddEventAsync(new ExecutorStatusEvent("Your itinerary is ready!")); + await context.AddEventAsync(new ExecutorStatusEvent("Your itinerary is ready!"), cancellationToken); logger.LogDebug("[OutputExecutor] Completed - workflow finished"); } diff --git a/src/AI/samples/Essentials.AI.Sample/AI/ItineraryWorkflowExtensions.cs b/src/AI/samples/Essentials.AI.Sample/AI/ItineraryWorkflowExtensions.cs index a69c36f711b2..0efbe8a04ec2 100644 --- a/src/AI/samples/Essentials.AI.Sample/AI/ItineraryWorkflowExtensions.cs +++ b/src/AI/samples/Essentials.AI.Sample/AI/ItineraryWorkflowExtensions.cs @@ -1,9 +1,11 @@ using System.Text.Json; using System.Text.Json.Serialization; +using Maui.Controls.Sample.Models; using Maui.Controls.Sample.Services; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Hosting; using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -11,6 +13,8 @@ namespace Maui.Controls.Sample.AI; /// /// Extension methods to register the 4-agent itinerary workflow. +/// All agent configuration (instructions, tools, response formats, content providers) is +/// defined here. Executors contain only execution logic (streaming, status events, prompt assembly). /// public static class ItineraryWorkflowExtensions { @@ -30,29 +34,131 @@ public static class ItineraryWorkflowExtensions /// public static IHostApplicationBuilder AddItineraryWorkflow(this IHostApplicationBuilder builder) { + // Tool: findPointsOfInterest - used by Agent 3 + var findPoiTool = AIFunctionFactory.Create( + ItineraryWorkflowTools.FindPointsOfInterestAsync, + name: ItineraryWorkflowTools.FindPointsOfInterestToolName); + // Agent 1: Travel Planner - parses natural language, extracts intent builder.AddAIAgent( name: "travel-planner-agent", - instructions: TravelPlannerExecutor.Instructions, + instructions: """ + You are a simple text parser. + + Extract ONLY these 3 values from the user's request: + 1. destinationName: The place/location name mentioned (extract it exactly as written) + 2. dayCount: The number of days mentioned (default: 3 if not specified) + 3. language: The language mentioned for the output (default: English if not specified) + + Rules: + 1. ALWAYS extract the raw values. + 2. NEVER make up values or interpret the user's intent. + + Examples: + - "5-day trip to Maui in French" → destinationName: "Maui", dayCount: 5, language: "French" + - "Visit the Great Wall" → destinationName: "Great Wall", dayCount: 3, language: "English" + - "Itinerary for Tokyo" → destinationName: "Tokyo", dayCount: 3, language: "English" + - "Give me a Maui itinerary" → destinationName: "Maui", dayCount: 3, language: "English" + - "Plan a 7 day Japan trip in Spanish" → destinationName: "Japan", dayCount: 7, language: "Spanish" + """, chatClientServiceKey: "local-model"); - // Agent 2: Researcher - finds best matching destination - builder.AddAIAgent( - name: "researcher-agent", - instructions: ResearcherExecutor.Instructions, - chatClientServiceKey: "local-model"); + // Agent 2: Researcher - finds best matching destination using RAG via TextSearchProvider + builder.AddAIAgent("researcher-agent", (sp, name) => + { + var chatClient = sp.GetRequiredKeyedService("local-model"); + var dataService = sp.GetRequiredService(); + var loggerFactory = sp.GetRequiredService(); - // Agent 3: Itinerary Planner - builds detailed itineraries - builder.AddAIAgent( - name: "itinerary-planner-agent", - instructions: ItineraryPlannerExecutor.Instructions, - chatClientServiceKey: "local-model"); + var searchProvider = ItineraryWorkflowTools.CreateLandmarkSearchProvider(dataService, loggerFactory); - // Agent 4: Translator - translates content - builder.AddAIAgent( - name: "translator-agent", - instructions: TranslatorExecutor.Instructions, - chatClientServiceKey: "cloud-model"); + return chatClient.AsAIAgent( + new ChatClientAgentOptions + { + Name = name, + ChatOptions = new ChatOptions + { + Instructions = """ + You are a travel researcher. + Your job is to select the best matching destination from the additional context provided. + + Rules: + 1. You will be given additional context containing candidate destinations that match the user's request. + 2. Select the ONE destination that best matches what the user asked for. + 3. NEVER make up destinations - only choose from the provided candidates. + 4. If none of the candidates match well, pick the closest one. + 5. Include the destination's description from the context in your response. + + Return the exact name of the best matching destination from the candidates. + """ + }, + AIContextProviders = [searchProvider], + }, + loggerFactory); + }); + + // Agent 3: Itinerary Planner - builds detailed itineraries with tool calling + builder.AddAIAgent("itinerary-planner-agent", (sp, name) => + { + var chatClient = sp.GetRequiredKeyedService("local-model"); + var loggerFactory = sp.GetRequiredService(); + return chatClient.AsAIAgent( + new ChatClientAgentOptions + { + Name = name, + ChatOptions = new ChatOptions + { + Instructions = $""" + You create detailed travel itineraries. + + For each day include these places: + 1. An activity or attraction + 2. A hotel recommendation + 3. A restaurant recommendation + + Rules: + 1. ALWAYS use the `{ItineraryWorkflowTools.FindPointsOfInterestToolName}` tool to discover real places near the destination. + 2. NEVER make up places or use your own knowledge. + 3. ONLY use places returned by the `{ItineraryWorkflowTools.FindPointsOfInterestToolName}` tool. + 4. PREFER the places returned by the `{ItineraryWorkflowTools.FindPointsOfInterestToolName}` tool instead of the destination description. + + Give the itinerary a fun, creative title and engaging description. + + Include a rationale explaining why you chose these activities for the traveler. + """, + ResponseFormat = ChatResponseFormat.ForJsonSchema(JsonOptions), + Tools = [findPoiTool], + }, + }, + loggerFactory, + services: sp); + }); + + // Agent 4: Translator - translates content with streaming + builder.AddAIAgent("translator-agent", (sp, name) => + { + var chatClient = sp.GetRequiredKeyedService("cloud-model"); + var loggerFactory = sp.GetRequiredService(); + return chatClient.AsAIAgent( + new ChatClientAgentOptions + { + Name = name, + ChatOptions = new ChatOptions + { + Instructions = """ + You are a professional translator. + Translate the provided JSON content to the target language. + + Rules: + 1. ALWAYS preserve the JSON format exactly. + 2. ONLY translate the text values within the JSON. + 3. NEVER add explanations or commentary. + """, + ResponseFormat = ChatResponseFormat.ForJsonSchema(JsonOptions), + }, + }, + loggerFactory); + }); // Register the workflow var workflow = builder.AddWorkflow("itinerary-workflow", (sp, key) => @@ -61,14 +167,13 @@ public static IHostApplicationBuilder AddItineraryWorkflow(this IHostApplication var researcherAgent = sp.GetRequiredKeyedService("researcher-agent"); var itineraryPlannerAgent = sp.GetRequiredKeyedService("itinerary-planner-agent"); var translatorAgent = sp.GetRequiredKeyedService("translator-agent"); - var landmarkService = sp.GetRequiredService(); var logger = sp.GetRequiredService().CreateLogger("ItineraryWorkflow"); - // Create executors for each agent with logging - var travelPlannerExecutor = new TravelPlannerExecutor(travelPlannerAgent, JsonOptions, logger); - var researcherExecutor = new ResearcherExecutor(researcherAgent, landmarkService, JsonOptions, logger); - var itineraryPlannerExecutor = new ItineraryPlannerExecutor(itineraryPlannerAgent, JsonOptions, logger); - var translatorExecutor = new TranslatorExecutor(translatorAgent, JsonOptions, logger); + // Create executors — thin wrappers with just execution logic + var travelPlannerExecutor = new TravelPlannerExecutor(travelPlannerAgent, logger); + var researcherExecutor = new ResearcherExecutor(researcherAgent, logger); + var itineraryPlannerExecutor = new ItineraryPlannerExecutor(itineraryPlannerAgent, logger); + var translatorExecutor = new TranslatorExecutor(translatorAgent, logger); var outputExecutor = new OutputExecutor(logger); // Build the 4-agent workflow with conditional translation: @@ -77,10 +182,9 @@ public static IHostApplicationBuilder AddItineraryWorkflow(this IHostApplication .WithName(key) .AddEdge(travelPlannerExecutor, researcherExecutor) .AddEdge(researcherExecutor, itineraryPlannerExecutor) - // English path: skip translation - .AddEdge(itineraryPlannerExecutor, outputExecutor, condition: IsEnglish) - // Non-English path: translate first - .AddEdge(itineraryPlannerExecutor, translatorExecutor, condition: NeedsTranslation) + .AddSwitch(itineraryPlannerExecutor, switch_ => switch_ + .AddCase(r => r is not null && !string.Equals(r.TargetLanguage, "English", StringComparison.OrdinalIgnoreCase), translatorExecutor) + .WithDefault(outputExecutor)) .AddEdge(translatorExecutor, outputExecutor) .WithOutputFrom(outputExecutor) .Build(); @@ -93,10 +197,4 @@ public static IHostApplicationBuilder AddItineraryWorkflow(this IHostApplication return builder; } - - private static bool IsEnglish(ItineraryResult? result) => - result is not null && string.Equals(result.TargetLanguage, "English", StringComparison.OrdinalIgnoreCase); - - private static bool NeedsTranslation(ItineraryResult? result) => - result is not null && !string.Equals(result.TargetLanguage, "English", StringComparison.OrdinalIgnoreCase); } diff --git a/src/AI/samples/Essentials.AI.Sample/AI/ItineraryWorkflowTools.cs b/src/AI/samples/Essentials.AI.Sample/AI/ItineraryWorkflowTools.cs new file mode 100644 index 000000000000..c84c6b3e85e9 --- /dev/null +++ b/src/AI/samples/Essentials.AI.Sample/AI/ItineraryWorkflowTools.cs @@ -0,0 +1,84 @@ +using System.ComponentModel; +using Maui.Controls.Sample.Models; +using Maui.Controls.Sample.Services; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +namespace Maui.Controls.Sample.AI; + +/// +/// Static tool functions and content providers used by the itinerary workflow. +/// Defined here so they can be registered centrally in . +/// +internal static class ItineraryWorkflowTools +{ + public const string FindPointsOfInterestToolName = "findPointsOfInterest"; + + /// + /// Creates a that performs RAG via . + /// The provider runs in BeforeAIInvoke mode, automatically searching for matching landmarks + /// and injecting them as context before each AI call. + /// + public static TextSearchProvider CreateLandmarkSearchProvider(DataService dataService, ILoggerFactory loggerFactory) + { + var ragLogger = loggerFactory.CreateLogger(); + + return new TextSearchProvider( + async (query, ct) => + { + ragLogger.LogDebug("[RAG] Searching landmarks for query: '{Query}'", query); + var results = await dataService.SearchLandmarksAsync(query, maxResults: 5); + ragLogger.LogDebug("[RAG] Found {Count} landmarks: {Names}", + results.Count, string.Join(", ", results.Select(r => r.Name))); + return results.Select(r => new TextSearchProvider.TextSearchResult + { + Text = $"{r.Name}: {r.ShortDescription}", + SourceName = r.Name, + }); + }, + new TextSearchProviderOptions + { + SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke, + }, + loggerFactory); + } + + [Description("Finds points of interest (hotels, restaurants, activities) near a destination.")] + public static Task FindPointsOfInterestAsync( + [Description("The name of the destination to search near.")] + string destinationName, + [Description("The category of place to find (Hotel, Restaurant, Cafe, Museum, etc.).")] + PointOfInterestCategory category, + [Description("A natural language query to refine the search.")] + string additionalSearchQuery, + IServiceProvider services) + { + var logger = services.GetService()?.CreateLogger("ItineraryWorkflowTools"); + + var suggestions = GetSuggestions(category); + var result = $""" + These {category} options are available near {destinationName}: + + - {string.Join(Environment.NewLine + "- ", suggestions)} + """; + + logger?.LogTrace("[ItineraryWorkflowTools] findPointsOfInterest - destination={Destination}, category={Category}, query={Query}, results={Count}", + destinationName, category, additionalSearchQuery ?? "(none)", suggestions.Length); + + return Task.FromResult(result); + } + + private static string[] GetSuggestions(PointOfInterestCategory category) => + category switch + { + PointOfInterestCategory.Cafe => ["Cafe 1", "Cafe 2", "Cafe 3"], + PointOfInterestCategory.Campground => ["Campground 1", "Campground 2", "Campground 3"], + PointOfInterestCategory.Hotel => ["Hotel 1", "Hotel 2", "Hotel 3"], + PointOfInterestCategory.Marina => ["Marina 1", "Marina 2", "Marina 3"], + PointOfInterestCategory.Museum => ["Museum 1", "Museum 2", "Museum 3"], + PointOfInterestCategory.NationalMonument => ["The National Rock 1", "The National Rock 2", "The National Rock 3"], + PointOfInterestCategory.Restaurant => ["Restaurant 1", "Restaurant 2", "Restaurant 3"], + _ => [] + }; +} diff --git a/src/AI/samples/Essentials.AI.Sample/AI/NonFunctionInvokingChatClient.cs b/src/AI/samples/Essentials.AI.Sample/AI/NonFunctionInvokingChatClient.cs deleted file mode 100644 index d2cd6b29d93e..000000000000 --- a/src/AI/samples/Essentials.AI.Sample/AI/NonFunctionInvokingChatClient.cs +++ /dev/null @@ -1,230 +0,0 @@ -using System.Runtime.CompilerServices; -using System.Text.Json; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Maui.Controls.Sample.AI; - -/// -/// A chat client wrapper that prevents Agent Framework from adding its own function invocation layer. -/// -/// -/// -/// Some chat clients handle tool invocation internally - when tools are registered, the underlying -/// service invokes them automatically and returns the results. However, Agent Framework's -/// ChatClientAgent also tries to invoke tools when it sees -/// in the response, causing double invocation. -/// -/// -/// This wrapper solves the problem by: -/// -/// The inner handler converts and -/// to internal marker types that doesn't recognize -/// We wrap the handler with a real , satisfying -/// Agent Framework's GetService<FunctionInvokingChatClient>() check so it won't create another -/// The outer layer unwraps the marker types back to the original content types for the caller -/// -/// -/// -/// When the employed enables , the contents of -/// function calls and results are logged. These may contain sensitive application data. -/// is disabled by default and should never be enabled in a production environment. -/// -/// -/// Use this wrapper for any that handles its own tool invocation, such as -/// on-device models (Apple Intelligence, etc.) or remote services that invoke tools server-side. -/// -/// -public sealed partial class NonFunctionInvokingChatClient : DelegatingChatClient -{ - private readonly ILogger _logger; - - /// - /// Initializes a new instance of the class. - /// - /// The to wrap. - /// Optional logger factory for logging function invocations. - /// Optional service provider for dependency resolution. - public NonFunctionInvokingChatClient( - IChatClient innerClient, - ILoggerFactory? loggerFactory = null, - IServiceProvider? serviceProvider = null) - : base(CreateInnerClient(innerClient, loggerFactory, serviceProvider)) - { - _logger = (ILogger?)loggerFactory?.CreateLogger() ?? NullLogger.Instance; - } - - private static FunctionInvokingChatClient CreateInnerClient( - IChatClient innerClient, - ILoggerFactory? loggerFactory, - IServiceProvider? serviceProvider) - { - ArgumentNullException.ThrowIfNull(innerClient); - var handler = new ToolCallPassThroughHandler(innerClient); - return new FunctionInvokingChatClient(handler, loggerFactory, serviceProvider); - } - - /// - public override async Task GetResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - CancellationToken cancellationToken = default) - { - var response = await base.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false); - foreach (var message in response.Messages) - { - message.Contents.Unwrap(this); - } - return response; - } - - /// - public override async IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - await foreach (var update in base.GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false)) - { - update.Contents.Unwrap(this); - yield return update; - } - } - - internal void LogFunctionInvoking(string functionName, string callId, IDictionary? arguments) - { - if (_logger.IsEnabled(LogLevel.Trace) && arguments is not null) - { - var argsJson = JsonSerializer.Serialize(arguments, AIJsonUtilities.DefaultOptions); - LogToolInvokedSensitive(functionName, callId, argsJson); - } - else if (_logger.IsEnabled(LogLevel.Debug)) - { - LogToolInvoked(functionName, callId); - } - } - - internal void LogFunctionInvocationCompleted(string callId, object? result) - { - if (_logger.IsEnabled(LogLevel.Trace) && result is not null) - { - var resultJson = result is string s ? s : JsonSerializer.Serialize(result, AIJsonUtilities.DefaultOptions); - LogToolInvocationCompletedSensitive(callId, resultJson); - } - else if (_logger.IsEnabled(LogLevel.Debug)) - { - LogToolInvocationCompleted(callId); - } - } - - [LoggerMessage(LogLevel.Debug, "Received tool call: {ToolName} (ID: {ToolCallId})")] - private partial void LogToolInvoked(string toolName, string toolCallId); - - [LoggerMessage(LogLevel.Trace, "Received tool call: {ToolName} (ID: {ToolCallId}) with arguments: {Arguments}")] - private partial void LogToolInvokedSensitive(string toolName, string toolCallId, string arguments); - - [LoggerMessage(LogLevel.Debug, "Received tool result for call ID: {ToolCallId}")] - private partial void LogToolInvocationCompleted(string toolCallId); - - [LoggerMessage(LogLevel.Trace, "Received tool result for call ID: {ToolCallId}: {Result}")] - private partial void LogToolInvocationCompletedSensitive(string toolCallId, string result); - - /// - /// Handler that wraps the inner client and converts tool call/result content to server-handled types. - /// - private sealed class ToolCallPassThroughHandler(IChatClient innerClient) : DelegatingChatClient(innerClient) - { - public override async Task GetResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - CancellationToken cancellationToken = default) - { - var response = await base.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false); - foreach (var message in response.Messages) - { - message.Contents.Wrap(); - } - return response; - } - - public override async IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - await foreach (var update in base.GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false)) - { - update.Contents.Wrap(); - yield return update; - } - } - } -} - -file static class Extensions -{ - /// - /// Wraps any or in the contents list. - /// - /// The list of contents to wrap. - public static void Wrap(this IList contents) - { - for (var i = 0; i < contents.Count; i++) - { - if (contents[i] is FunctionCallContent fcc) - { - // The inner client already handled this tool call - wrap it so FICC ignores it - contents[i] = new ServerFunctionCallContent(fcc); - } - else if (contents[i] is FunctionResultContent frc) - { - // The inner client already produced this result - wrap it so FICC ignores it - contents[i] = new ServerFunctionResultContent(frc); - } - } - } - - /// - /// Unwraps any or in the contents list - /// and logs the function invocations. - /// - /// The list of contents to unwrap. - /// The client to use for logging. - public static void Unwrap(this IList contents, NonFunctionInvokingChatClient client) - { - for (var i = 0; i < contents.Count; i++) - { - if (contents[i] is ServerFunctionCallContent serverFcc) - { - var fcc = serverFcc.FunctionCallContent; - client.LogFunctionInvoking(fcc.Name, fcc.CallId, fcc.Arguments); - contents[i] = fcc; - } - else if (contents[i] is ServerFunctionResultContent serverFrc) - { - var frc = serverFrc.FunctionResultContent; - client.LogFunctionInvocationCompleted(frc.CallId, frc.Result); - contents[i] = frc; - } - } - } - - /// - /// Marker type for function calls that were already handled by the inner client. - /// only looks for , - /// so this type passes through without triggering function invocation. - /// - private sealed class ServerFunctionCallContent(FunctionCallContent functionCallContent) : AIContent - { - public FunctionCallContent FunctionCallContent { get; } = functionCallContent; - } - - /// - /// Marker type for function results that were already produced by the inner client. - /// - private sealed class ServerFunctionResultContent(FunctionResultContent functionResultContent) : AIContent - { - public FunctionResultContent FunctionResultContent { get; } = functionResultContent; - } -} diff --git a/src/AI/samples/Essentials.AI.Sample/AI/WorkflowModels.cs b/src/AI/samples/Essentials.AI.Sample/AI/WorkflowModels.cs index 7f9d982ff403..dea298dd0ee6 100644 --- a/src/AI/samples/Essentials.AI.Sample/AI/WorkflowModels.cs +++ b/src/AI/samples/Essentials.AI.Sample/AI/WorkflowModels.cs @@ -1,5 +1,4 @@ using System.ComponentModel; -using Maui.Controls.Sample.Models; namespace Maui.Controls.Sample.AI; @@ -19,18 +18,22 @@ public record TravelPlanResult( string Language); /// -/// Result from the Researcher Agent - the best matching destination name (for JSON schema). +/// Result from the Researcher Agent - the best matching destination (for JSON schema). /// internal record DestinationMatchResult( [property: DisplayName("matchedDestinationName")] [property: Description("The exact name of the best matching destination from the available list.")] - string MatchedDestinationName); + string MatchedDestinationName, + [property: DisplayName("matchedDestinationDescription")] + [property: Description("A brief description of the matched destination, based on the information provided in the additional context.")] + string MatchedDestinationDescription); /// -/// Result from the Researcher Agent - includes full landmark details. +/// Result from the Researcher Agent - includes destination name and description from RAG context. /// public record ResearchResult( - Landmark? Landmark, + string? DestinationName, + string? DestinationDescription, int DayCount, string Language); diff --git a/src/AI/samples/Essentials.AI.Sample/Essentials.AI.Sample.csproj b/src/AI/samples/Essentials.AI.Sample/Essentials.AI.Sample.csproj index 44e76794dac9..dd675e590bbf 100644 --- a/src/AI/samples/Essentials.AI.Sample/Essentials.AI.Sample.csproj +++ b/src/AI/samples/Essentials.AI.Sample/Essentials.AI.Sample.csproj @@ -32,14 +32,16 @@ - - + + + + diff --git a/src/AI/samples/Essentials.AI.Sample/MauiProgram.cs b/src/AI/samples/Essentials.AI.Sample/MauiProgram.cs index 2346fb53c0ab..2d95fd1146f5 100644 --- a/src/AI/samples/Essentials.AI.Sample/MauiProgram.cs +++ b/src/AI/samples/Essentials.AI.Sample/MauiProgram.cs @@ -52,6 +52,7 @@ public static MauiApp CreateMauiApp() // Register ViewModels builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddSingleton(); // Register Services builder.Services.AddSingleton(); @@ -59,6 +60,7 @@ public static MauiApp CreateMauiApp() builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddHttpClient(); + builder.Services.AddSingleton(); // Configure Logging builder.Services.AddLogging(); @@ -110,9 +112,6 @@ private static MauiAppBuilder AddAppleIntelligenceServices(this MauiAppBuilder b return appleClient .AsBuilder() .UseLogging(loggerFactory) - // This prevents double tool invocation when using Microsoft Agent Framework - // TODO: workaround for https://github.com/dotnet/extensions/issues/7204 - .Use(cc => new NonFunctionInvokingChatClient(cc, loggerFactory, sp)) .Build(); }); @@ -183,7 +182,6 @@ private static MauiAppBuilder AddOpenAIServices(this MauiAppBuilder builder) }); // Add chat client for local model with function calling - // TODO: Replace with actual local model client when available builder.Services.AddKeyedSingleton("local-model", (provider, _) => { var lf = provider.GetRequiredService(); diff --git a/src/AI/samples/Essentials.AI.Sample/Models/Landmark.cs b/src/AI/samples/Essentials.AI.Sample/Models/Landmark.cs index 94b5457614c1..1edf16df6fa6 100644 --- a/src/AI/samples/Essentials.AI.Sample/Models/Landmark.cs +++ b/src/AI/samples/Essentials.AI.Sample/Models/Landmark.cs @@ -33,8 +33,9 @@ public record Landmark public Location Location => new(Latitude, Longitude); /// - /// Embedding vector generated from Name and ShortDescription for RAG search. + /// Embedding vectors generated from the name, short description, and individual + /// sentences of the full description for multi-granularity semantic search. /// [JsonIgnore] - public Embedding? Embedding { get; set; } + public IReadOnlyList>? Embeddings { get; set; } } diff --git a/src/AI/samples/Essentials.AI.Sample/Models/PointOfInterest.cs b/src/AI/samples/Essentials.AI.Sample/Models/PointOfInterest.cs index 1f17752b7425..7d95e45d47d9 100644 --- a/src/AI/samples/Essentials.AI.Sample/Models/PointOfInterest.cs +++ b/src/AI/samples/Essentials.AI.Sample/Models/PointOfInterest.cs @@ -11,8 +11,11 @@ public class PointOfInterest public string Description { get; set; } = string.Empty; + /// + /// Embedding vectors generated from the name and description for semantic search. + /// [JsonIgnore] - public Embedding? Embedding { get; set; } + public IReadOnlyList>? Embeddings { get; set; } } public enum PointOfInterestCategory diff --git a/src/AI/samples/Essentials.AI.Sample/Pages/LandmarksPage.xaml b/src/AI/samples/Essentials.AI.Sample/Pages/LandmarksPage.xaml index 8dabc74f7b16..d8e36611f2b9 100644 --- a/src/AI/samples/Essentials.AI.Sample/Pages/LandmarksPage.xaml +++ b/src/AI/samples/Essentials.AI.Sample/Pages/LandmarksPage.xaml @@ -69,6 +69,53 @@ FontSize="24" Background="{AppThemeBinding Light=#80FFFFFF, Dark=#80000000}" TextColor="{AppThemeBinding Light={StaticResource Gray900}, Dark=White}" /> + + +