diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index c4350d266ccd..43a1e9aa8fbf 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -200,6 +200,16 @@ The repository includes specialized custom agents for specific tasks. These agen - **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 uitest-coding-agent) +### Reusable Skills + +Skills are modular capabilities that can be invoked directly or used by agents. Located in `.github/skills/`: + +1. **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", "triage Android issues" + - **Scripts**: `init-triage-session.ps1`, `query-issues.ps1`, `record-triage.ps1` + - **Used by**: Any agent or direct invocation + ### Using Custom Agents **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. diff --git a/.github/skills/issue-triage/SKILL.md b/.github/skills/issue-triage/SKILL.md new file mode 100644 index 000000000000..780084b1b543 --- /dev/null +++ b/.github/skills/issue-triage/SKILL.md @@ -0,0 +1,162 @@ +--- +name: issue-triage +description: Queries and triages open GitHub issues that need attention. Helps identify issues needing milestones, labels, or investigation. +metadata: + author: dotnet-maui + version: "2.1" +compatibility: Requires GitHub CLI (gh) authenticated with access to dotnet/maui repository. +--- + +# Issue Triage Skill + +This skill helps triage open GitHub issues in the dotnet/maui repository by: +1. Initializing a session with current milestones and labels +2. Loading a batch of issues into memory +3. Presenting issues ONE AT A TIME for triage decisions +4. Suggesting milestones based on issue characteristics +5. Tracking progress through a triage session + +## When to Use + +- "Find issues to triage" +- "Let's triage issues" +- "Grab me 10 issues to triage" +- "Triage Android issues" + +## Triage Workflow + +### Step 1: Initialize Session + +Start by initializing a session to load current milestones and labels: + +```bash +pwsh .github/skills/issue-triage/scripts/init-triage-session.ps1 +``` + +### Step 2: Load Issues Into Memory + +Load a batch of issues (e.g., 20) but DO NOT display them all. Store them for one-at-a-time presentation: + +```bash +pwsh .github/skills/issue-triage/scripts/query-issues.ps1 -Limit 100 -OutputFormat triage +``` + +### Step 3: Present ONE Issue at a Time + +**IMPORTANT**: When user asks to triage, present only ONE issue at a time in this format: + +```markdown +## Issue #XXXXX + +**[Title]** + +🔗 [URL] + +| Field | Value | +|-------|-------| +| **Author** | username (Syncfusion if applicable) | +| **Platform** | platform | +| **Area** | area | +| **Labels** | labels | +| **Linked PR** | PR info with milestone if available | +| **Regression** | Yes/No | +| **Comments** | count | + +**Comment Summary** (if any): +- [Author] Comment preview... + +**My Suggestion**: `Milestone` - Reason + +--- + +What would you like to do with this issue? +``` + +### Step 4: Wait for User Decision + +Wait for user to say: +- A milestone name (e.g., "Backlog", "current SR", "Servicing") +- "yes" to accept suggestion +- "skip" or "next" to move on without changes +- Specific instructions (e.g., "next SR and add regression label") + +### Step 5: Apply Changes and Move to Next + +After applying changes, automatically present the NEXT issue. + +## Script Parameters + +### query-issues.ps1 + +| Parameter | Values | Default | Description | +|-----------|--------|---------|-------------| +| `-Platform` | android, ios, windows, maccatalyst, all | all | Filter by platform | +| `-Area` | Any area label (e.g., collectionview, shell) | "" | Filter by area | +| `-Limit` | 1-1000 | 50 | Maximum issues to fetch | +| `-Skip` | 0+ | 0 | Skip first N issues (for pagination) | +| `-OutputFormat` | table, json, markdown, triage | table | Output format | +| `-RequireAreaLabel` | switch | false | Only return issues with area-* labels | +| `-SkipDetails` | switch | false | Skip fetching PRs/comments (faster) | + +## Milestone Suggestion Logic + +The script dynamically queries current milestones from dotnet/maui and suggests them based on issue characteristics: + +| Condition | Suggested Milestone | Reason | +|-----------|---------------------|--------| +| Linked PR has milestone | PR's milestone | "PR already has milestone" | +| Has `i/regression` label | Current SR milestone (soonest due) | "Regression - current SR milestone" | +| Has open linked PR | Servicing milestone (or next SR) | "Has open PR" | +| Default | Backlog | "No PR, not a regression" | + +**Note**: SR milestones are sorted by due date, so the soonest SR is suggested for regressions. Milestone names change monthly, so they are queried dynamically rather than hardcoded. + +## Applying Triage Decisions + +```bash +# Set milestone only +gh issue edit ISSUE_NUMBER --repo dotnet/maui --milestone "Backlog" + +# Set milestone and add labels +gh issue edit ISSUE_NUMBER --repo dotnet/maui --milestone "MILESTONE_NAME" --add-label "i/regression" + +# Set milestone on both issue AND linked PR +gh issue edit ISSUE_NUMBER --repo dotnet/maui --milestone "MILESTONE_NAME" +gh pr edit PR_NUMBER --repo dotnet/maui --milestone "MILESTONE_NAME" +``` + +## Common Milestone Types + +| Milestone Type | Use When | +|----------------|----------| +| Current SR (e.g., SR3) | Regressions, critical bugs with PRs ready | +| Next SR (e.g., SR4) | Important bugs, regressions being investigated | +| Servicing | General fixes with PRs, non-urgent improvements | +| Backlog | No PR, not a regression, nice-to-have fixes | + +**Note**: Use `init-triage-session.ps1` to see current milestone names. + +## Label Quick Reference + +**Regression Labels:** +- `i/regression` - Confirmed regression +- `regressed-in-10.0.0` - Specific version + +**Priority Labels:** +- `p/0` - Critical +- `p/1` - High +- `p/2` - Medium +- `p/3` - Low + +**iOS 26 / macOS 26:** +- `version/iOS-26` - iOS 26 specific issue + +## Session Tracking (Optional) + +```bash +# Record triaged issue +pwsh .github/skills/issue-triage/scripts/record-triage.ps1 -IssueNumber 33272 -Milestone "Backlog" + +# View session stats +cat CustomAgentLogsTmp/Triage/triage-*.json | jq '.Stats' +``` diff --git a/.github/skills/issue-triage/scripts/init-triage-session.ps1 b/.github/skills/issue-triage/scripts/init-triage-session.ps1 new file mode 100644 index 000000000000..ea5e065862b5 --- /dev/null +++ b/.github/skills/issue-triage/scripts/init-triage-session.ps1 @@ -0,0 +1,203 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Initializes a triage session by loading current milestones, labels, and creating a session tracker. + +.DESCRIPTION + This script prepares for an issue triage session by: + 1. Querying all open milestones from dotnet/maui + 2. Loading common labels for quick reference + 3. Creating a session file to track triaged issues + + Run this at the start of a triage session to have current milestone/label data available. + +.PARAMETER SessionName + Optional name for the triage session (default: timestamp-based) + +.PARAMETER OutputDir + Directory to store session files (default: CustomAgentLogsTmp/Triage) + +.EXAMPLE + ./init-triage-session.ps1 + # Initializes a new triage session with defaults + +.EXAMPLE + ./init-triage-session.ps1 -SessionName "android-triage" + # Creates a named session for Android issue triage +#> + +param( + [Parameter(Mandatory = $false)] + [string]$SessionName = "", + + [Parameter(Mandatory = $false)] + [string]$OutputDir = "CustomAgentLogsTmp/Triage" +) + +$ErrorActionPreference = "Stop" + +Write-Host "╔═══════════════════════════════════════════════════════════╗" -ForegroundColor Cyan +Write-Host "║ Initializing Triage Session ║" -ForegroundColor Cyan +Write-Host "╚═══════════════════════════════════════════════════════════╝" -ForegroundColor Cyan + +# Create output directory +if (-not (Test-Path $OutputDir)) { + New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null +} + +# Generate session name if not provided +if ($SessionName -eq "") { + $SessionName = "triage-$(Get-Date -Format 'yyyy-MM-dd-HHmm')" +} + +$sessionFile = Join-Path $OutputDir "$SessionName.json" + +Write-Host "" +Write-Host "Session: $SessionName" -ForegroundColor Green +Write-Host "Output: $sessionFile" -ForegroundColor DarkGray + +# Query open milestones +Write-Host "" +Write-Host "Fetching open milestones..." -ForegroundColor Cyan + +$milestones = @() +try { + $msResult = gh api repos/dotnet/maui/milestones --jq '.[] | {number, title, due_on, open_issues}' 2>&1 + $msLines = $msResult -split "`n" | Where-Object { $_ -match "^\{" } + + foreach ($line in $msLines) { + $ms = $line | ConvertFrom-Json + $milestones += [PSCustomObject]@{ + Number = $ms.number + Title = $ms.title + DueOn = $ms.due_on + OpenIssues = $ms.open_issues + } + } + + # Sort by title for easy reference + $milestones = $milestones | Sort-Object Title + + Write-Host " Found $($milestones.Count) open milestones:" -ForegroundColor Green + + # Group milestones by type + $srMilestones = $milestones | Where-Object { $_.Title -match "SR\d|Servicing" } + $backlog = $milestones | Where-Object { $_.Title -eq "Backlog" } + $otherMs = $milestones | Where-Object { $_.Title -notmatch "SR\d|Servicing" -and $_.Title -ne "Backlog" } + + Write-Host "" + Write-Host " Servicing Releases:" -ForegroundColor Yellow + foreach ($ms in $srMilestones) { + $dueInfo = "" + if ($ms.DueOn -and $ms.DueOn -is [string] -and $ms.DueOn.Length -ge 10) { + $dueInfo = " (due: $($ms.DueOn.Substring(0, 10)))" + } + Write-Host " - $($ms.Title)$dueInfo [$($ms.OpenIssues) open]" + } + + if ($backlog) { + Write-Host "" + Write-Host " Backlog:" -ForegroundColor Yellow + Write-Host " - $($backlog.Title) [$($backlog.OpenIssues) open]" + } + + if ($otherMs.Count -gt 0) { + Write-Host "" + Write-Host " Other:" -ForegroundColor Yellow + foreach ($ms in $otherMs | Select-Object -First 5) { + Write-Host " - $($ms.Title) [$($ms.OpenIssues) open]" + } + if ($otherMs.Count -gt 5) { + Write-Host " ... and $($otherMs.Count - 5) more" + } + } +} +catch { + Write-Host " Failed to fetch milestones: $_" -ForegroundColor Red +} + +# Query common labels +Write-Host "" +Write-Host "Fetching labels..." -ForegroundColor Cyan + +$labels = @{ + Platforms = @() + Areas = @() + Status = @() + Priority = @() + Regression = @() + Other = @() +} + +try { + $labelResult = gh api repos/dotnet/maui/labels --paginate --jq '.[].name' 2>&1 + $allLabels = $labelResult -split "`n" | Where-Object { $_ -ne "" } + + foreach ($label in $allLabels) { + if ($label -match "^platform/") { + $labels.Platforms += $label + } + elseif ($label -match "^area-") { + $labels.Areas += $label + } + elseif ($label -match "^s/") { + $labels.Status += $label + } + elseif ($label -match "^p/") { + $labels.Priority += $label + } + elseif ($label -match "regression|regressed") { + $labels.Regression += $label + } + } + + Write-Host " Platforms: $($labels.Platforms.Count) labels" + Write-Host " Areas: $($labels.Areas.Count) labels" + Write-Host " Status: $($labels.Status.Count) labels" + Write-Host " Priority: $($labels.Priority.Count) labels" + Write-Host " Regression: $($labels.Regression.Count) labels" +} +catch { + Write-Host " Failed to fetch labels: $_" -ForegroundColor Red +} + +# Create session object +$session = [PSCustomObject]@{ + Name = $SessionName + StartedAt = (Get-Date).ToString("o") + Milestones = $milestones + Labels = $labels + TriagedIssues = @() + Stats = @{ + Total = 0 + Backlog = 0 + Servicing = 0 + SR = 0 + Skipped = 0 + } +} + +# Save session file +$session | ConvertTo-Json -Depth 10 | Out-File -FilePath $sessionFile -Encoding UTF8 + +Write-Host "" +Write-Host "═══════════════════════════════════════════════════════════" -ForegroundColor Cyan +Write-Host "" +Write-Host "Session initialized! Quick reference:" -ForegroundColor Green +Write-Host "" +Write-Host " Common Milestones:" -ForegroundColor Yellow +$srMilestones | ForEach-Object { Write-Host " $($_.Title)" } +Write-Host " Backlog" +Write-Host "" +Write-Host " Priority Labels:" -ForegroundColor Yellow +$labels.Priority | ForEach-Object { Write-Host " $_" } +Write-Host "" +Write-Host " Regression Labels:" -ForegroundColor Yellow +$labels.Regression | Select-Object -First 5 | ForEach-Object { Write-Host " $_" } +Write-Host "" +Write-Host " Session file: $sessionFile" -ForegroundColor DarkGray +Write-Host "" +Write-Host "Ready to triage! Run query-issues.ps1 to load issues." -ForegroundColor Green + +# Return session for pipeline usage +return $session diff --git a/.github/skills/issue-triage/scripts/query-issues.ps1 b/.github/skills/issue-triage/scripts/query-issues.ps1 new file mode 100644 index 000000000000..d9fbd90e1853 --- /dev/null +++ b/.github/skills/issue-triage/scripts/query-issues.ps1 @@ -0,0 +1,588 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Queries open issues from dotnet/maui that need triage. + +.DESCRIPTION + This script queries GitHub for open issues that: + - Have no milestone assigned + - Don't have blocking labels (needs-info, needs-repro, try-latest-version, etc.) + - Aren't Blazor issues (separate triage process) + + The results can be filtered by platform, area, age, and other criteria. + +.PARAMETER Platform + Filter by platform label: "android", "ios", "windows", "maccatalyst", or "all" (default) + +.PARAMETER Area + Filter by area label pattern (e.g., "collectionview", "shell", "navigation") + +.PARAMETER Limit + Maximum number of issues to return (default: 50) + +.PARAMETER SortBy + Sort by: "created", "updated", "comments", "reactions" (default: "created") + +.PARAMETER SortOrder + Sort order: "asc" or "desc" (default: "desc") + +.PARAMETER MinAge + Minimum age in days (e.g., 7 for issues older than a week) + +.PARAMETER MaxAge + Maximum age in days (e.g., 30 for issues newer than a month) + +.PARAMETER OutputFormat + Output format: "table", "json", or "markdown" (default: "table") + +.PARAMETER OutputFile + Optional file path to save results + +.EXAMPLE + ./query-issues.ps1 + # Returns up to 50 issues needing triage, sorted by creation date + +.EXAMPLE + ./query-issues.ps1 -Platform android -Limit 20 + # Returns up to 20 Android issues needing triage + +.EXAMPLE + ./query-issues.ps1 -Area "collectionview" -SortBy reactions -OutputFormat markdown + # Returns CollectionView issues sorted by reactions, formatted as markdown + +.EXAMPLE + ./query-issues.ps1 -MinAge 30 -OutputFile "old-issues.md" -OutputFormat markdown + # Saves issues older than 30 days to a markdown file +#> + +param( + [Parameter(Mandatory = $false)] + [ValidateSet("android", "ios", "windows", "maccatalyst", "all")] + [string]$Platform = "all", + + [Parameter(Mandatory = $false)] + [string]$Area = "", + + [Parameter(Mandatory = $false)] + [int]$Limit = 50, + + [Parameter(Mandatory = $false)] + [ValidateSet("created", "updated", "comments", "reactions")] + [string]$SortBy = "created", + + [Parameter(Mandatory = $false)] + [ValidateSet("asc", "desc")] + [string]$SortOrder = "desc", + + [Parameter(Mandatory = $false)] + [int]$MinAge = 0, + + [Parameter(Mandatory = $false)] + [int]$MaxAge = 0, + + [Parameter(Mandatory = $false)] + [ValidateSet("table", "json", "markdown", "triage")] + [string]$OutputFormat = "table", + + [Parameter(Mandatory = $false)] + [string]$OutputFile = "", + + [Parameter(Mandatory = $false)] + [switch]$SkipDetails, + + [Parameter(Mandatory = $false)] + [int]$Skip = 0, + + [Parameter(Mandatory = $false)] + [switch]$RequireAreaLabel +) + +$ErrorActionPreference = "Stop" + +Write-Host "Querying GitHub issues..." -ForegroundColor Cyan + +# Labels to exclude from triage results +$excludeLabels = @( + "s/needs-info", + "s/needs-repro", + "area-blazor", + "s/try-latest-version", + "s/move-to-vs-feedback" +) + +# Platform label mapping +$platformLabels = @{ + "android" = "platform/android 🤖" + "ios" = "platform/iOS 🍎" + "windows" = "platform/windows 🪟" + "maccatalyst" = "platform/macOS 🍏" +} + +# Query current milestones dynamically to avoid hardcoding +Write-Host "Fetching current milestones..." -ForegroundColor DarkGray +$currentMilestones = @{ + CurrentSR = "" # The soonest SR milestone (for regressions) + NextSR = "" # The next SR milestone (for other important bugs) + Servicing = "" # The Servicing milestone (for PRs) + Backlog = "Backlog" # Always "Backlog" +} + +try { + $msResult = gh api repos/dotnet/maui/milestones --jq '.[] | {title, due_on}' 2>$null + $msLines = $msResult -split "`n" | Where-Object { $_.Trim() -ne "" } + + $srMilestones = @() + $servicingMilestone = "" + + foreach ($line in $msLines) { + try { + $ms = $line | ConvertFrom-Json -ErrorAction Stop + # Match .NET SR milestones (e.g., ".NET 10.0 SR3", ".NET 9.0 SR5") + if ($ms.title -match "\.NET.*SR\d+") { + $srMilestones += [PSCustomObject]@{ + Title = $ms.title + DueOn = $ms.due_on + } + } + # Match Servicing milestones (e.g., ".NET 10 Servicing") + elseif ($ms.title -match "\.NET.*Servicing" -and $ms.title -notmatch "SR") { + $servicingMilestone = $ms.title + } + } + catch { + # Skip lines that aren't valid JSON + continue + } + } + + # Sort SR milestones by due date (soonest first) or by SR number + if ($srMilestones.Count -gt 0) { + $sortedSR = $srMilestones | Sort-Object { + $parsedDate = [DateTime]::MinValue + if ($_.DueOn -and [DateTime]::TryParse($_.DueOn, [ref]$parsedDate)) { + $parsedDate + } else { + [DateTime]::MaxValue + } + } + $currentMilestones.CurrentSR = $sortedSR[0].Title + if ($sortedSR.Count -gt 1) { + $currentMilestones.NextSR = $sortedSR[1].Title + } else { + $currentMilestones.NextSR = $sortedSR[0].Title + } + } + + if ($servicingMilestone) { + $currentMilestones.Servicing = $servicingMilestone + } + + Write-Host " Current SR: $($currentMilestones.CurrentSR)" -ForegroundColor DarkGray + Write-Host " Next SR: $($currentMilestones.NextSR)" -ForegroundColor DarkGray + Write-Host " Servicing: $($currentMilestones.Servicing)" -ForegroundColor DarkGray +} +catch { + Write-Host " Warning: Could not fetch milestones, using defaults" -ForegroundColor Yellow +} + +# Build gh issue list command arguments +$ghArgs = @( + "issue", "list", + "--repo", "dotnet/maui", + "--state", "open", + "--limit", $Limit, + "--json", "number,title,author,createdAt,updatedAt,labels,comments,url,milestone" +) + +# Add platform filter +if ($Platform -ne "all" -and $platformLabels.ContainsKey($Platform)) { + $ghArgs += @("--label", $platformLabels[$Platform]) +} + +# Add area filter +if ($Area -ne "") { + $ghArgs += @("--label", "area-$Area") +} + +Write-Host "Running: gh $($ghArgs -join ' ')" -ForegroundColor DarkGray + +try { + $result = & gh @ghArgs 2>&1 + + if ($LASTEXITCODE -ne 0) { + Write-Error "GitHub CLI error: $result" + exit 1 + } + + $allIssues = $result | ConvertFrom-Json + + # Post-filter: exclude issues with blocking labels, require no milestone + $issues = $allIssues | Where-Object { + # Skip issues that have a milestone + if ($null -ne $_.milestone -and $_.milestone -ne "") { + return $false + } + + $issueLabels = $_.labels | ForEach-Object { $_.name } + $hasBlockingLabel = $false + foreach ($excludeLabel in $excludeLabels) { + if ($issueLabels -contains $excludeLabel) { + $hasBlockingLabel = $true + break + } + } + -not $hasBlockingLabel + } + + # Apply date filters + if ($MinAge -gt 0) { + $minDate = (Get-Date).AddDays(-$MinAge) + $issues = $issues | Where-Object { [DateTime]::Parse($_.createdAt) -lt $minDate } + } + + if ($MaxAge -gt 0) { + $maxDate = (Get-Date).AddDays(-$MaxAge) + $issues = $issues | Where-Object { [DateTime]::Parse($_.createdAt) -gt $maxDate } + } + + # Filter to only issues with area labels if requested + if ($RequireAreaLabel) { + $issues = $issues | Where-Object { + $hasAreaLabel = $false + foreach ($label in $_.labels) { + if ($label.name -like "area-*") { + $hasAreaLabel = $true + break + } + } + $hasAreaLabel + } + } + + # Apply skip for pagination + if ($Skip -gt 0) { + $issues = $issues | Select-Object -Skip $Skip + } + +} +catch { + Write-Error "Failed to query GitHub: $_" + exit 1 +} + +if ($issues.Count -eq 0) { + Write-Host "No issues found matching the criteria." -ForegroundColor Yellow + exit 0 +} + +Write-Host "Found $($issues.Count) issues" -ForegroundColor Green + +# Process and format the results with additional details +if (-not $SkipDetails) { + Write-Host "Fetching additional details for each issue..." -ForegroundColor Cyan +} +$processedIssues = @() +$issueIndex = 0 +$totalIssues = @($issues).Count + +foreach ($issue in $issues) { + $issueIndex++ + if (-not $SkipDetails) { + Write-Host "`r Processing issue $issueIndex of $totalIssues (#$($issue.number))..." -NoNewline -ForegroundColor DarkGray + } + + $labelNames = ($issue.labels | ForEach-Object { $_.name }) -join ", " + $platformLabel = ($issue.labels | Where-Object { $_.name -like "platform/*" } | Select-Object -First 1)?.name ?? "unknown" + $areaLabels = ($issue.labels | Where-Object { $_.name -like "area-*" } | ForEach-Object { $_.name -replace "^area-", "" }) -join ", " + + $createdDate = [DateTime]::Parse($issue.createdAt) + $ageInDays = [Math]::Round(((Get-Date) - $createdDate).TotalDays) + + # gh issue list returns comments as an array, count them + $commentCount = if ($issue.comments) { $issue.comments.Count } else { 0 } + + # Check if author is likely Syncfusion (has partner/syncfusion label) + $isSyncfusion = $labelNames -match "partner/syncfusion" + + # Check for regression labels + $isRegression = $labelNames -match "i/regression|regressed-in" + $regressedIn = ($issue.labels | Where-Object { $_.name -like "regressed-in-*" } | Select-Object -First 1)?.name ?? "" + + # Fetch linked PRs via GraphQL (skip if -SkipDetails) + $linkedPRs = @() + if (-not $SkipDetails) { + try { + $graphqlQuery = @" +{ + repository(owner: "dotnet", name: "maui") { + issue(number: $($issue.number)) { + timelineItems(itemTypes: [CONNECTED_EVENT, CROSS_REFERENCED_EVENT], first: 10) { + nodes { + ... on ConnectedEvent { subject { ... on PullRequest { number title state url milestone { title } } } } + ... on CrossReferencedEvent { source { ... on PullRequest { number title state url milestone { title } } } } + } + } + } + } +} +"@ + $prResult = gh api graphql -f query=$graphqlQuery 2>$null | ConvertFrom-Json + $nodes = $prResult.data.repository.issue.timelineItems.nodes + foreach ($node in $nodes) { + $pr = $null + if ($node.subject) { $pr = $node.subject } + elseif ($node.source) { $pr = $node.source } + if ($pr -and $pr.number) { + $linkedPRs += [PSCustomObject]@{ + Number = $pr.number + Title = $pr.title + State = $pr.state + URL = $pr.url + Milestone = $pr.milestone?.title ?? "" + } + } + } + } + catch { + # Silently continue if GraphQL fails + } + } + + # Fetch project items (skip if -SkipDetails) + $projects = @() + if (-not $SkipDetails) { + try { + $projectResult = gh issue view $issue.number --repo dotnet/maui --json projectItems 2>$null | ConvertFrom-Json + if ($projectResult.projectItems) { + foreach ($proj in $projectResult.projectItems) { + $projects += $proj.title + } + } + } + catch { + # Silently continue if project fetch fails + } + } + + # Fetch and summarize comments (skip if -SkipDetails) + $commentSummary = @() + if (-not $SkipDetails) { + try { + $commentsResult = gh issue view $issue.number --repo dotnet/maui --json comments 2>$null | ConvertFrom-Json + if ($commentsResult.comments -and $commentsResult.comments.Count -gt 0) { + foreach ($comment in $commentsResult.comments) { + # Skip bot comments + if ($comment.author.login -eq "MihuBot") { continue } + $bodyPreview = $comment.body -replace '\r?\n', ' ' -replace '\s+', ' ' + if ($bodyPreview.Length -gt 300) { $bodyPreview = $bodyPreview.Substring(0, 297) + "..." } + $commentSummary += [PSCustomObject]@{ + Author = $comment.author.login + Association = $comment.authorAssociation + CreatedAt = $comment.createdAt + BodyPreview = $bodyPreview + } + } + } + } + catch { + # Silently continue if comment fetch fails + } + } + + # Generate milestone suggestion based on issue characteristics + $suggestedMilestone = $currentMilestones.Backlog + $suggestionReason = "No PR, not a regression" + + # Check if any linked PR has a milestone + $prMilestone = "" + foreach ($pr in $linkedPRs) { + if ($pr.Milestone -and $pr.Milestone -ne "") { + $prMilestone = $pr.Milestone + break + } + } + + if ($prMilestone -ne "") { + $suggestedMilestone = $prMilestone + $suggestionReason = "PR already has milestone" + } + elseif ($isRegression) { + # Use the current (soonest) SR milestone for regressions + if ($currentMilestones.CurrentSR) { + $suggestedMilestone = $currentMilestones.CurrentSR + $suggestionReason = "Regression - current SR milestone" + } else { + $suggestedMilestone = $currentMilestones.Backlog + $suggestionReason = "Regression (no SR milestone found)" + } + } + elseif ($linkedPRs.Count -gt 0) { + $openPRs = $linkedPRs | Where-Object { $_.State -eq "OPEN" } + if ($openPRs.Count -gt 0) { + # Use Servicing milestone for PRs, fallback to next SR + if ($currentMilestones.Servicing) { + $suggestedMilestone = $currentMilestones.Servicing + $suggestionReason = "Has open PR" + } elseif ($currentMilestones.NextSR) { + $suggestedMilestone = $currentMilestones.NextSR + $suggestionReason = "Has open PR" + } + } + } + + $processedIssues += [PSCustomObject]@{ + Number = $issue.number + Title = if ($issue.title.Length -gt 60) { $issue.title.Substring(0, 57) + "..." } else { $issue.title } + FullTitle = $issue.title + Author = $issue.author.login + IsSyncfusion = $isSyncfusion + Created = $createdDate.ToString("yyyy-MM-dd") + Age = "$ageInDays days" + AgeDays = $ageInDays + CommentCount = $commentCount + Comments = $commentSummary + Platform = $platformLabel -replace "^platform/", "" -replace " [🤖🍎🪟🍏]$", "" + Areas = $areaLabels + Labels = $labelNames + IsRegression = $isRegression + RegressedIn = $regressedIn + LinkedPRs = $linkedPRs + Projects = $projects -join ", " + URL = $issue.url + SuggestedMilestone = $suggestedMilestone + SuggestionReason = $suggestionReason + } +} +Write-Host "" # New line after progress + +# Output based on format +function Format-Table-Output { + param($issues) + + Write-Host "" + Write-Host "Issues Needing Triage" -ForegroundColor Cyan + Write-Host ("=" * 100) + + $issues | Format-Table -Property @( + @{Label="Issue"; Expression={$_.Number}; Width=7}, + @{Label="Title"; Expression={$_.Title}; Width=50}, + @{Label="Age"; Expression={$_.Age}; Width=10}, + @{Label="Platform"; Expression={$_.Platform}; Width=12}, + @{Label="Comments"; Expression={$_.CommentCount}; Width=8} + ) -AutoSize +} + +function Format-Markdown-Output { + param($issues) + + $output = @() + $output += "# Issues Needing Triage" + $output += "" + $output += "Generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" + $output += "Query filters: Platform=$Platform, Area=$Area, MinAge=$MinAge, MaxAge=$MaxAge" + $output += "" + $output += "| Issue | Title | Age | Platform | Areas | Comments |" + $output += "|-------|-------|-----|----------|-------|----------|" + + foreach ($issue in $issues) { + $issueLink = "[#$($issue.Number)]($($issue.URL))" + $output += "| $issueLink | $($issue.FullTitle -replace '\|', '\|') | $($issue.Age) | $($issue.Platform) | $($issue.Areas) | $($issue.CommentCount) |" + } + + $output += "" + $output += "---" + $output += "Total: $($issues.Count) issues" + + return $output -join "`n" +} + +function Format-Triage-Output { + param($issues) + + foreach ($issue in $issues) { + Write-Output "===" + Write-Output "Number:$($issue.Number)" + Write-Output "Title:$($issue.FullTitle)" + Write-Output "URL:$($issue.URL)" + Write-Output "Author:$($issue.Author)" + if ($issue.IsSyncfusion) { Write-Output "IsSyncfusion:True" } + Write-Output "Platform:$($issue.Platform)" + Write-Output "Areas:$($issue.Areas)" + Write-Output "Age:$($issue.Age)" + Write-Output "Labels:$($issue.Labels)" + if ($issue.IsRegression) { + Write-Output "IsRegression:True" + if ($issue.RegressedIn) { Write-Output "RegressedIn:$($issue.RegressedIn)" } + } + if ($issue.Projects) { Write-Output "Projects:$($issue.Projects)" } + + # Linked PRs with milestones + if ($issue.LinkedPRs -and $issue.LinkedPRs.Count -gt 0) { + $prList = ($issue.LinkedPRs | ForEach-Object { + $prInfo = "#$($_.Number)[$($_.State)]" + if ($_.Milestone) { $prInfo += "(MS:$($_.Milestone))" } + $prInfo + }) -join "; " + Write-Output "LinkedPRs:$prList" + } + + # Comments summary + Write-Output "CommentCount:$($issue.CommentCount)" + if ($issue.Comments -and $issue.Comments.Count -gt 0) { + Write-Output "Comments:" + foreach ($c in $issue.Comments) { + Write-Output " - [$($c.Author)] $($c.BodyPreview)" + } + } + + # Milestone suggestion + Write-Output "SuggestedMilestone:$($issue.SuggestedMilestone)" + Write-Output "SuggestionReason:$($issue.SuggestionReason)" + } +} + +function Format-Json-Output { + param($issues) + return $issues | ConvertTo-Json -Depth 10 +} + +# Generate output +switch ($OutputFormat) { + "table" { + Format-Table-Output -issues $processedIssues + $outputContent = $null + } + "markdown" { + $outputContent = Format-Markdown-Output -issues $processedIssues + Write-Host $outputContent + } + "triage" { + Format-Triage-Output -issues $processedIssues + $outputContent = $null + } + "json" { + $outputContent = Format-Json-Output -issues $processedIssues + Write-Host $outputContent + } +} + +# Save to file if requested +if ($OutputFile -ne "" -and $outputContent) { + $outputContent | Out-File -FilePath $OutputFile -Encoding UTF8 + Write-Host "" + Write-Host "Results saved to: $OutputFile" -ForegroundColor Green +} + +# Summary statistics +Write-Host "" +Write-Host "Summary:" -ForegroundColor Cyan +$platformStats = $processedIssues | Group-Object Platform | Sort-Object Count -Descending +foreach ($stat in $platformStats) { + Write-Host " $($stat.Name): $($stat.Count) issues" +} + +$avgAge = [Math]::Round(($processedIssues | Measure-Object AgeDays -Average).Average) +Write-Host " Average age: $avgAge days" + +# Return the issues for pipeline usage +return $processedIssues diff --git a/.github/skills/issue-triage/scripts/record-triage.ps1 b/.github/skills/issue-triage/scripts/record-triage.ps1 new file mode 100644 index 000000000000..b6609a7b7238 --- /dev/null +++ b/.github/skills/issue-triage/scripts/record-triage.ps1 @@ -0,0 +1,116 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Records a triaged issue to the current session. + +.DESCRIPTION + This script adds a triaged issue to the session tracker, recording: + - Issue number + - Action taken (milestone, labels added) + - Timestamp + + Use this after triaging each issue to maintain session history. + +.PARAMETER IssueNumber + The GitHub issue number that was triaged + +.PARAMETER Milestone + The milestone that was set (optional) + +.PARAMETER LabelsAdded + Labels that were added (comma-separated, optional) + +.PARAMETER LabelsRemoved + Labels that were removed (comma-separated, optional) + +.PARAMETER Action + The action taken: "milestoned", "labeled", "skipped", "closed" (default: milestoned) + +.PARAMETER SessionFile + Path to the session file (default: most recent session in CustomAgentLogsTmp/Triage) + +.EXAMPLE + ./record-triage.ps1 -IssueNumber 33272 -Milestone "Backlog" + # Records that issue 33272 was set to Backlog + +.EXAMPLE + ./record-triage.ps1 -IssueNumber 33264 -Milestone ".NET 10.0 SR3" -LabelsAdded "i/regression" + # Records milestone and label changes +#> + +param( + [Parameter(Mandatory = $true)] + [int]$IssueNumber, + + [Parameter(Mandatory = $false)] + [string]$Milestone = "", + + [Parameter(Mandatory = $false)] + [string]$LabelsAdded = "", + + [Parameter(Mandatory = $false)] + [string]$LabelsRemoved = "", + + [Parameter(Mandatory = $false)] + [ValidateSet("milestoned", "labeled", "skipped", "closed")] + [string]$Action = "milestoned", + + [Parameter(Mandatory = $false)] + [string]$SessionFile = "" +) + +$ErrorActionPreference = "Stop" + +$outputDir = "CustomAgentLogsTmp/Triage" + +# Find session file if not provided +if ($SessionFile -eq "") { + if (Test-Path $outputDir) { + $latestSession = Get-ChildItem -Path $outputDir -Filter "*.json" | + Sort-Object LastWriteTime -Descending | + Select-Object -First 1 + + if ($latestSession) { + $SessionFile = $latestSession.FullName + } + } +} + +if ($SessionFile -eq "" -or -not (Test-Path $SessionFile)) { + Write-Host "No active session found. Run init-triage-session.ps1 first." -ForegroundColor Yellow + exit 1 +} + +# Load session +$session = Get-Content $SessionFile | ConvertFrom-Json + +# Create triage record +$record = [PSCustomObject]@{ + IssueNumber = $IssueNumber + Action = $Action + Milestone = $Milestone + LabelsAdded = if ($LabelsAdded) { $LabelsAdded -split "," | ForEach-Object { $_.Trim() } } else { @() } + LabelsRemoved = if ($LabelsRemoved) { $LabelsRemoved -split "," | ForEach-Object { $_.Trim() } } else { @() } + TriagedAt = (Get-Date).ToString("o") +} + +# Add to session +$session.TriagedIssues += $record +$session.Stats.Total++ + +# Update stats +switch -Regex ($Milestone) { + "Backlog" { $session.Stats.Backlog++ } + "Servicing" { $session.Stats.Servicing++ } + "SR\d" { $session.Stats.SR++ } +} +if ($Action -eq "skipped") { $session.Stats.Skipped++ } + +# Save session +$session | ConvertTo-Json -Depth 10 | Out-File -FilePath $SessionFile -Encoding UTF8 + +Write-Host "✓ Recorded: #$IssueNumber → $Action" -ForegroundColor Green +if ($Milestone) { Write-Host " Milestone: $Milestone" -ForegroundColor DarkGray } +if ($LabelsAdded) { Write-Host " Labels+: $LabelsAdded" -ForegroundColor DarkGray } +if ($LabelsRemoved) { Write-Host " Labels-: $LabelsRemoved" -ForegroundColor DarkGray } +Write-Host " Session total: $($session.Stats.Total) issues triaged" -ForegroundColor DarkGray