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>
This commit is contained in:
Muyuan Li
2026-05-12 17:36:08 +08:00
committed by GitHub
parent 14f2ac1b78
commit be1f9dd2d8
3 changed files with 468 additions and 11 deletions

View File

@@ -266,16 +266,5 @@ configuration:
- addReply:
reply: Hi! Your last comment indicates to our system, that you might want to contribute to this feature/fix this bug. Thank you! Please make us aware on our ["Would you like to contribute to PowerToys?" thread](https://github.com/microsoft/PowerToys/issues/28769), as we don't see all the comments. <br /><br />_I'm a bot (beep!) so please excuse any mistakes I may make_
description:
- if:
- payloadType: Issues
- isAction:
action: Opened
- bodyContains:
pattern: 'Area\(s\) with issue\?\s*\nWorkspaces'
isRegex: True
then:
- addLabel:
label: Product-Workspaces
description:
onFailure:
onSuccess:

275
.github/workflows/auto-label-product.yml vendored Normal file
View File

@@ -0,0 +1,275 @@
name: Auto Label Product on Issue Creation
on:
issues:
types: [opened]
workflow_dispatch:
inputs:
mode:
description: 'single: label one issue, batch: label all issues missing Product- labels'
required: true
type: choice
options:
- single
- batch
default: single
issue_number:
description: 'Issue number (only used in single mode)'
required: false
type: number
dry_run:
description: 'If true, only log what labels would be applied without applying them'
required: false
type: boolean
default: true
batch_limit:
description: 'Max issues to process in batch mode (default: 50)'
required: false
type: number
default: 50
permissions:
issues: write
models: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.issue.number || github.event.inputs.issue_number || 'batch' }}
cancel-in-progress: true
jobs:
label-product:
runs-on: ubuntu-latest
steps:
- name: Auto-apply Product labels
uses: actions/github-script@v7
with:
script: |
const isManual = context.eventName === 'workflow_dispatch';
const dryRun = isManual ? (context.payload.inputs.dry_run === 'true') : false;
const batchMode = isManual && context.payload.inputs.mode === 'batch';
const batchLimit = isManual ? parseInt(context.payload.inputs.batch_limit || '50') : 1;
// Mapping from issue template "Area(s) with issue?" values to Product- labels
const 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',
};
const ALL_PRODUCT_LABELS = [...new Set(Object.values(AREA_TO_LABEL))].sort();
// ─── Collect issues to process ───
let issues = [];
if (batchMode) {
// Fetch open issues that have no Product-* label
core.info(`Batch mode: fetching up to ${batchLimit} issues without Product- labels...`);
let page = 1;
while (issues.length < batchLimit) {
const { data } = await github.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
per_page: 100,
page: page++,
sort: 'created',
direction: 'desc',
});
if (data.length === 0) break;
for (const issue of data) {
if (issue.pull_request) continue; // skip PRs
const hasProductLabel = issue.labels.some(l => l.name.startsWith('Product-'));
if (!hasProductLabel) {
issues.push(issue);
if (issues.length >= batchLimit) break;
}
}
}
core.info(`Found ${issues.length} issues to process.`);
} else if (isManual) {
const issueNumber = parseInt(context.payload.inputs.issue_number);
if (!issueNumber) { core.setFailed('issue_number is required in single mode'); return; }
const { data } = await github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
});
issues = [data];
} else {
issues = [context.payload.issue];
}
// ─── Process each issue ───
const summaryRows = [];
const labelExistsCache = new Map();
for (const issue of issues) {
const body = issue.body || '';
const title = issue.title || '';
// Parse the "Area(s) with issue?" field
const areaMatch = body.match(/### Area\(s\) with issue\?\s*\r?\n\r?\n([\s\S]*?)(?=\r?\n\r?\n###|\r?\n*$)/);
let selectedAreas = [];
if (areaMatch) {
const areaText = areaMatch[1].trim();
selectedAreas = areaText.split(',').map(s => s.trim()).filter(Boolean);
}
// Resolve labels from the structured field
const resolvedLabels = new Set();
for (const area of selectedAreas) {
if (AREA_TO_LABEL[area]) {
resolvedLabels.add(AREA_TO_LABEL[area]);
}
}
// AI fallback if no deterministic match
if (resolvedLabels.size === 0) {
core.info(`#${issue.number}: No deterministic match, trying AI inference...`);
try {
const prompt = `You are a GitHub issue triage assistant for the PowerToys project.
Given the following issue title and body, determine which PowerToys product(s) this issue is PRIMARILY about.
Rules:
- Only include products the issue is directly reporting a bug for or requesting a feature in.
- Do NOT include products that are merely mentioned as examples or comparisons.
- When in doubt, prefer fewer labels over more. One correct label is better than many guesses.
- If the issue is about general PowerToys infrastructure (installer, settings app, system tray), use "Product-General" or "Product-Settings" as appropriate.
Respond with ONLY a JSON array of label strings from this list:
${JSON.stringify(ALL_PRODUCT_LABELS)}
If you cannot determine the product, respond with an empty array: []
Issue title: ${title}
Issue body (first 2000 chars):
${body.substring(0, 2000)}`;
const response = await fetch('https://models.github.ai/inference/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.GITHUB_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'openai/gpt-4.1-mini',
messages: [{ role: 'user', content: prompt }],
temperature: 0,
}),
});
if (response.ok) {
const data = await response.json();
const content = data.choices?.[0]?.message?.content || '';
const jsonMatch = content.match(/\[[\s\S]*?\]/);
if (jsonMatch) {
const inferred = JSON.parse(jsonMatch[0]);
for (const label of inferred) {
if (ALL_PRODUCT_LABELS.includes(label)) {
resolvedLabels.add(label);
}
}
}
core.info(`#${issue.number}: AI inferred: ${[...resolvedLabels].join(', ') || '(none)'}`);
} else {
core.warning(`#${issue.number}: AI inference failed (${response.status})`);
}
} catch (err) {
core.warning(`#${issue.number}: AI error: ${err.message}`);
}
}
if (resolvedLabels.size === 0) {
summaryRows.push([`#${issue.number}`, title.substring(0, 60), '(none)', 'skipped']);
continue;
}
// Validate labels exist (cached to reduce API calls in batch mode)
const labelsToApply = [];
for (const label of resolvedLabels) {
let exists = labelExistsCache.get(label);
if (exists === undefined) {
try {
await github.rest.issues.getLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: label,
});
exists = true;
} catch (err) {
if (err.status === 404) {
exists = false;
} else {
throw err;
}
}
labelExistsCache.set(label, exists);
}
if (exists) {
labelsToApply.push(label);
} else {
core.warning(`Label "${label}" not found in repo, skipping.`);
}
}
if (labelsToApply.length === 0) {
summaryRows.push([`#${issue.number}`, title.substring(0, 60), '(labels not found)', 'skipped']);
continue;
}
// Apply or dry-run
if (dryRun) {
core.info(`[DRY RUN] #${issue.number}: would apply ${labelsToApply.join(', ')}`);
summaryRows.push([`#${issue.number}`, title.substring(0, 60), labelsToApply.join(', '), 'dry-run']);
} else {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: labelsToApply,
});
core.info(`#${issue.number}: applied ${labelsToApply.join(', ')}`);
summaryRows.push([`#${issue.number}`, title.substring(0, 60), labelsToApply.join(', '), 'applied']);
}
}
// Write job summary
if (summaryRows.length > 0) {
core.summary.addHeading(`Auto-Label Results (${dryRun ? 'Dry Run' : 'Applied'})`, 3);
core.summary.addTable([
[{data: 'Issue', header: true}, {data: 'Title', header: true}, {data: 'Labels', header: true}, {data: 'Status', header: true}],
...summaryRows,
]);
await core.summary.write();
}

View File

@@ -0,0 +1,193 @@
<#
.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
}