Files
PowerToys/tools/Test-AutoLabelProduct.ps1
Muyuan Li be1f9dd2d8 Add auto-label-product GitHub Action for issue triage (#47485)
## Summary

Adds a GitHub Action workflow that **automatically applies `Product-*`
labels** to issues, reducing manual triage effort.

### How it works

**Two-tier approach:**
1. **Deterministic mapping** — Parses the structured "Area(s) with
issue?" dropdown from bug report templates and maps selections to the
correct `Product-` label via a hardcoded lookup table.
2. **AI inference (Copilot fallback)** — When no product is resolved
from the structured field (e.g., feature requests without the area
field), calls GitHub Models API (`gpt-4.1-mini`) to infer the product
from issue title + body.

### Trigger modes

| Trigger | Use case |
|---------|----------|
| `issues: [opened]` | Auto-labels every new issue |
| `workflow_dispatch` (single) | Test on one specific issue with dry-run
|
| `workflow_dispatch` (batch) | Process all open issues missing Product-
labels |

### Safety features
- **Label validation** — checks each label exists in the repo before
applying
- **Dry-run mode** — logs what would happen without modifying issues
- **Concurrency control** — prevents duplicate runs
- **Conservative AI prompt** — only labels products the issue is
*primarily* about

### Testing performed

Tested locally against 10 real issues with `Needs-Triage` and no
`Product-*` label:
- **6/10 resolved deterministically** (correct labels applied via `gh
issue edit`)
- **4/10 tested AI inference** via GitHub Models API:
  - #47482 (CmdPal Dock) → `Product-Command Palette` 
  - #47474 (grab and move + fancy zones) → `Product-FancyZones` 
  - #47476 (modular download) → `[]` (correctly abstained) 
- #47478 (Quick Access pinning) → improved prompt to avoid over-labeling

### Files changed

| File | Purpose |
|------|---------|
| `.github/workflows/auto-label-product.yml` | The GitHub Action |
| `.github/policies/resourceManagement.yml` | Removed redundant
Workspaces-only regex rule |
| `tools/Test-AutoLabelProduct.ps1` | Local PowerShell test script for
dry-run testing |

### Mapping validated against actual repo labels
Confirmed all label names in the mapping exist in the repo via `gh label
list --search "Product-"`.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-12 11:36:08 +02:00

194 lines
8.0 KiB
PowerShell

<#
.SYNOPSIS
Test the auto-label-product workflow logic locally against real issues.
.DESCRIPTION
Fetches issues with "Needs-Triage" label but no "Product-*" label from the
PowerToys repo and simulates what the GitHub Action would do (without actually
applying labels). This lets you validate the mapping and AI inference before
merging the workflow.
.PARAMETER Apply
Actually apply the labels via `gh issue edit`. Requires gh auth.
.PARAMETER Limit
Number of issues to process (default: 10).
.EXAMPLE
# Dry run - see what would happen
.\Test-AutoLabelProduct.ps1
.EXAMPLE
# Apply labels to first 5 issues
.\Test-AutoLabelProduct.ps1 -Apply -Limit 5
.NOTES
Prerequisites:
- gh CLI authenticated: `gh auth login`
- PowerShell 7+
#>
param(
[switch]$Apply,
[int]$Limit = 10
)
$ErrorActionPreference = 'Stop'
# ─── Mapping (must match the workflow) ────────────────────────────────────────
$AREA_TO_LABEL = @{
'Advanced Paste' = 'Product-Advanced Paste'
'Always on Top' = 'Product-Always On Top'
'Awake' = 'Product-Awake'
'ColorPicker' = 'Product-Color Picker'
'Command not found' = 'Product-CommandNotFound'
'Command Palette' = 'Product-Command Palette'
'Crop and Lock' = 'Product-CropAndLock'
'Environment Variables' = 'Product-Environment Variables'
'FancyZones' = 'Product-FancyZones'
'FancyZones Editor' = 'Product-FancyZones'
'File Locksmith' = 'Product-File Locksmith'
'File Explorer: Preview Pane' = 'Product-File Explorer'
'File Explorer: Thumbnail preview' = 'Product-File Explorer'
'Hosts File Editor' = 'Product-Hosts File Editor'
'Image Resizer' = 'Product-Image Resizer'
'Keyboard Manager' = 'Product-Keyboard Shortcut Manager'
'Light Switch' = 'Product-LightSwitch'
'Mouse Utilities' = 'Product-Mouse Utilities'
'Mouse Without Borders' = 'Product-Mouse Without Borders'
'New+' = 'Product-New+'
'Peek' = 'Product-Peek'
'Power Display' = 'Product-PowerDisplay'
'PowerRename' = 'Product-PowerRename'
'PowerToys Run' = 'Product-PowerToys Run'
'Quick Accent' = 'Product-Quick Accent'
'Registry Preview' = 'Product-Registry Preview'
'Screen ruler' = 'Product-Screen Ruler'
'Settings' = 'Product-Settings'
'Shortcut Guide' = 'Product-Shortcut Guide'
'TextExtractor' = 'Product-Text Extractor'
'Workspaces' = 'Product-Workspaces'
'ZoomIt' = 'Product-ZoomIt'
'General' = 'Product-General'
'Grab And Move' = 'Product-Grab And Move'
}
# Non-product areas (no label applied, AI fallback triggers)
$NON_PRODUCT_AREAS = @('Installer', 'System tray interaction', 'Welcome / PowerToys Tour window')
# ─── Fetch issues ────────────────────────────────────────────────────────────
Write-Host "`n🔍 Fetching issues with 'Needs-Triage' and no 'Product-*' label (limit: $Limit)..." -ForegroundColor Cyan
# gh search finds issues with Needs-Triage; we filter out those that already have Product- labels
$ghStderrPath = [System.IO.Path]::GetTempFileName()
try {
$issuesJson = gh issue list --repo microsoft/PowerToys --label "Needs-Triage" --limit 100 --json number,title,body,labels --state open 2> $ghStderrPath
$ghExitCode = $LASTEXITCODE
$ghErrorOutput = Get-Content -Path $ghStderrPath -Raw
}
finally {
Remove-Item -Path $ghStderrPath -ErrorAction SilentlyContinue
}
if ($ghExitCode -ne 0) {
Write-Host "❌ Failed to fetch issues. Ensure 'gh auth login' is done." -ForegroundColor Red
if (-not [string]::IsNullOrWhiteSpace($ghErrorOutput)) {
Write-Host $ghErrorOutput
}
exit 1
}
if (-not [string]::IsNullOrWhiteSpace($ghErrorOutput)) {
Write-Host "⚠️ gh emitted stderr output while fetching issues:" -ForegroundColor Yellow
Write-Host $ghErrorOutput
}
$issues = $issuesJson | ConvertFrom-Json
# Filter: only issues WITHOUT any Product-* label
$issues = $issues | Where-Object {
$labels = $_.labels | ForEach-Object { $_.name }
-not ($labels | Where-Object { $_ -like 'Product-*' })
} | Select-Object -First $Limit
Write-Host "📋 Found $($issues.Count) issues to process.`n" -ForegroundColor Green
# ─── Process each issue ──────────────────────────────────────────────────────
$results = @()
foreach ($issue in $issues) {
$body = $issue.body
$title = $issue.title
$number = $issue.number
Write-Host "--- Issue #${number}: ${title} ---" -ForegroundColor Yellow
# Parse "Area(s) with issue?" field
$selectedAreas = @()
if ($body -match '### Area\(s\) with issue\?\s*\r?\n\r?\n([\s\S]*?)(?=\r?\n\r?\n###|\s*$)') {
$areaText = $Matches[1].Trim()
$selectedAreas = $areaText -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }
}
if ($selectedAreas.Count -eq 0) {
Write-Host " ⚠️ No 'Area(s) with issue?' field found in body." -ForegroundColor DarkYellow
} else {
Write-Host " 📌 Areas selected: $($selectedAreas -join ', ')" -ForegroundColor DarkCyan
}
# Resolve labels
$resolvedLabels = @()
$unmapped = @()
foreach ($area in $selectedAreas) {
if ($AREA_TO_LABEL.ContainsKey($area)) {
$resolvedLabels += $AREA_TO_LABEL[$area]
} elseif ($area -notin $NON_PRODUCT_AREAS) {
$unmapped += $area
}
}
$resolvedLabels = $resolvedLabels | Sort-Object -Unique
if ($unmapped.Count -gt 0) {
Write-Host " ⚠️ Unmapped areas (need mapping update): $($unmapped -join ', ')" -ForegroundColor DarkYellow
}
if ($resolvedLabels.Count -eq 0) {
Write-Host " 🤖 No deterministic match → AI inference would trigger in workflow" -ForegroundColor Magenta
} else {
Write-Host " ✅ Would apply: $($resolvedLabels -join ', ')" -ForegroundColor Green
}
# Apply if requested
if ($Apply -and $resolvedLabels.Count -gt 0) {
foreach ($label in $resolvedLabels) {
Write-Host " 🏷️ Applying label: $label" -ForegroundColor White
gh issue edit $number --repo microsoft/PowerToys --add-label $label 2>&1 | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-Host " ❌ Failed to apply '$label' (may not exist in repo)" -ForegroundColor Red
}
}
}
$results += [PSCustomObject]@{
Issue = $number
Title = $title.Substring(0, [Math]::Min(60, $title.Length))
Areas = ($selectedAreas -join ', ')
Labels = ($resolvedLabels -join ', ')
NeedsAI = ($resolvedLabels.Count -eq 0)
}
Write-Host ""
}
# ─── Summary ─────────────────────────────────────────────────────────────────
Write-Host "`n═══ SUMMARY ═══" -ForegroundColor Cyan
$results | Format-Table -AutoSize -Wrap
$aiNeeded = ($results | Where-Object { $_.NeedsAI }).Count
$mapped = ($results | Where-Object { -not $_.NeedsAI }).Count
Write-Host "Deterministic: $mapped | AI fallback needed: $aiNeeded | Total: $($results.Count)" -ForegroundColor Cyan
if (-not $Apply) {
Write-Host "`n💡 This was a DRY RUN. Use -Apply to actually add labels." -ForegroundColor Yellow
}