| 📌 |
AI Issue Triage |
Maintain one concise issue summary with likely duplicates and missing-information guidance. |
| issues |
issue_comment |
roles |
|
|
|
all |
|
| max-runs-per-window |
window |
| 5 |
60 |
|
| group |
cancel-in-progress |
| issue-triage-${{ github.event.issue.number }} |
true |
|
copilot |
small |
5 |
10 |
300 |
|
| contents |
issues |
copilot-requests |
| read |
read |
write |
|
| name |
uses |
with |
| Set up Python |
actions/setup-python@v7.0.0 |
|
|
| name |
id |
env |
run |
| Prepare deterministic issue evidence |
prepare |
| GITHUB_TOKEN |
GH_AW_SAFE_OUTPUTS |
| ${{ github.token }} |
${{ runner.temp }}/gh-aw/safeoutputs/outputs.jsonl |
|
python .github/scripts/issue-triage/issue-context.py "$GITHUB_EVENT_PATH" ".github/issue-context.md" ".github/triage-event.json" |
|
| name |
if |
run |
| Prepare sanitized bug report context |
steps.prepare.outputs.should_process == 'true' |
python .github/scripts/issue-triage/bug-report-analyzer.py ".github/triage-event.json" ".github/bug-report-context.md" |
|
|
| report-failure-as-issue |
noop |
jobs |
| false |
|
| publish-triage-summary |
| description |
runs-on |
if |
output |
permissions |
inputs |
steps |
| Create or update the canonical triage summary for the triggering issue. |
ubuntu-slim |
needs.detection.result == 'success' |
The canonical triage summary was published. |
|
| summary |
input_sha256 |
suggested_area |
product_label |
powertoys_version |
has_missing_information |
missing_information |
duplicate_candidates_json |
issue_kind |
reproduction_quality |
bug_report_requirement |
bug_report_status |
bug_report_findings |
bug_report_confidence |
issue_language |
| description |
required |
type |
| A concise one- or two-sentence summary of the reported problem or request. |
true |
string |
|
| description |
required |
type |
| The exact Input SHA-256 value copied from the deterministic issue evidence. |
true |
string |
|
| description |
required |
type |
| The most likely PowerToys product area or Unknown when unclear. |
true |
string |
|
| description |
required |
type |
| The exact existing Product-* label matching the issue area, or None. |
true |
string |
|
| description |
required |
type |
| The normalized PowerToys version from the bug template, or Not provided. |
true |
string |
|
| description |
required |
type |
| Whether important information needed to investigate the issue is missing. |
true |
boolean |
|
| description |
required |
type |
| A concise sentence naming only the important missing information, or None. |
true |
string |
|
| description |
required |
type |
| JSON array of up to five objects with integer number, short reason, and HIGH/MEDIUM/LOW confidence fields, or []. |
true |
string |
|
| description |
required |
type |
options |
| BUG when the issue follows the PowerToys bug-report template, otherwise OTHER. |
true |
choice |
|
|
| description |
required |
type |
options |
| Whether bug reproduction steps are sufficient for investigation. |
true |
choice |
| SUFFICIENT |
| INSUFFICIENT |
| NOT_APPLICABLE |
|
|
| description |
required |
type |
options |
| Whether a diagnostic report is required, recommended, optional, or not applicable. |
true |
choice |
| REQUIRED |
| RECOMMENDED |
| OPTIONAL |
| NOT_APPLICABLE |
|
|
| description |
required |
type |
options |
| Processing status copied from the sanitized bug report context. |
true |
choice |
| ANALYZED |
| NOT_FOUND |
| REJECTED |
| NOT_APPLICABLE |
|
|
| description |
required |
type |
| Concise evidence-based diagnostic findings, or a short status explanation. |
true |
string |
|
| description |
required |
type |
options |
| Confidence in the diagnostic findings. |
true |
choice |
|
|
| description |
required |
type |
options |
| Whether the author-written issue title and description are English. |
true |
choice |
| ENGLISH |
| NON_ENGLISH |
| UNCERTAIN |
|
|
|
| name |
uses |
with |
| Upsert canonical triage summary |
actions/github-script@v9.0.0 |
| script |
| const fs = require('fs');
const marker = '<!-- powertoys-ai-triage:canonical:v1 -->';
const outputPath = process.env.GH_AW_AGENT_OUTPUT;
if (!outputPath || !fs.existsSync(outputPath)) {
core.setFailed('Agent output is unavailable');
return;
}
const output = JSON.parse(fs.readFileSync(outputPath, 'utf8'));
const item = output.items?.find(
candidate => candidate.type === 'publish_triage_summary'
);
if (!item) {
core.setFailed('The agent did not provide a triage summary');
return;
}
const bounded = (value, max, fallback) => {
if (typeof value !== 'string') return fallback;
const normalized = value.replace(/\0/g, '').trim();
return normalized ? normalized.slice(0, max) : fallback;
};
const escapeMarkdown = value => String(value)
.replaceAll('\', '\\')
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('@', '@\u200B')
.replace(/([`*_{}[]()#+.!|~-])/g, '\$1')
.replace(/\r?\n+/g, ' ')
.trim();
const formatTechnicalMarkdown = value => {
const tokens = [];
const withPlaceholders = String(value)
.replace(/\0/g, '')
.replace(
/\b(?:0x[0-9a-f]{6,}|[\w.-]+.(?:xaml.cs|dll|exe|json|log|xaml|cs))\b/gi,
token => {
const placeholder = `GHCODETOKEN${tokens.length}GH`;
tokens.push(token);
return placeholder;
}
);
let formatted = escapeMarkdown(withPlaceholders);
tokens.forEach((token, index) => {
formatted = formatted.replace(
`GHCODETOKEN${index}GH`,
``${token.replaceAll('`', '')}``
);
});
return formatted;
};
const redactDiagnostic = value => String(value)
.replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+.[A-Z]{2,}\b/gi, '<email>')
.replace(/\b(?:25[0-5]|2[0-4]\d|1?\d?\d)(?:.(?:25[0-5]|2[0-4]\d|1?\d?\d)){3}\b/g, '<ip-address>')
.replace(/[A-Z]:\Users\[^\\s"']+/gi, '<user-profile>')
.replace(//(?:home|Users)/[^/\s"']+/gi, '/<user>')
.replace(/\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b/gi, '<guid>')
.replace(/\bS-1-5-(?:\d+-){1,14}\d+\b/g, '<sid>')
.replace(/\bhttps?://[^\s<>"]+/gi, '<url>')
.replace(/\b(token|secret|password|securitykey)\b\s*[:=]\s*[^\s,;]+/gi, '$1=<redacted>')
.replace(/\b(?:machine|computer|user)(?:name)?\b\s*[:=]\s*[^\s,;]+/gi, '<identity>=<redacted>');
const summary = bounded(item.summary, 800, 'The issue needs maintainer review.');
const inputSha256 = String(item.input_sha256 || '').toLowerCase();
if (!/^[0-9a-f]{64}$/.test(inputSha256)) {
core.setFailed('input_sha256 is invalid');
return;
}
const area = bounded(item.suggested_area, 100, 'Unknown');
const requestedProductLabel = bounded(item.product_label, 100, 'None');
const issueBody = String(context.payload.issue?.body || '');
const versionSection = issueBody.match(
/###\s+Microsoft PowerToys version\s*\r?\n+([\s\S]*?)(?=\r?\n###|\s*$)/i
)?.[1] || '';
const reportedVersion = versionSection.match(
/\b(?:v)?(\d+(?:.\d+){1,3}(?:-[A-Za-z0-9.-]+)?)\b/
)?.[1];
const powertoysVersion = reportedVersion || bounded(
item.powertoys_version,
50,
'Not provided'
);
const numericVersion = value => {
const match = String(value || '').match(
/\b(?:v)?(\d+(?:.\d+){1,3})(?:-[A-Za-z0-9.-]+)?\b/
);
return match
? match[1].split('.').map(part => Number(part))
: null;
};
const compareVersions = (left, right) => {
const leftParts = numericVersion(left);
const rightParts = numericVersion(right);
if (!leftParts || !rightParts) return null;
const width = Math.max(leftParts.length, rightParts.length);
for (let index = 0; index < width; index += 1) {
const difference =
(leftParts[index] || 0) - (rightParts[index] || 0);
if (difference !== 0) return Math.sign(difference);
}
return 0;
};
let latestStableVersion = null;
let latestStableUrl = null;
try {
const latestRelease = await github.request(
'GET /repos/{owner}/{repo}/releases/latest',
{ owner: 'microsoft', repo: 'PowerToys' }
);
if (!latestRelease.data?.prerelease) {
latestStableVersion = String(
latestRelease.data?.tag_name || ''
).match(
/\b(?:v)?(\d+(?:.\d+){1,3}(?:-[A-Za-z0-9.-]+)?)\b/
)?.[1] || null;
latestStableUrl = /^https://github.com/microsoft/PowerToys/releases/tag/[^/\s]+$/.test(
String(latestRelease.data?.html_url || '')
)
? latestRelease.data.html_url
: null;
}
} catch (error) {
core.warning(
`Latest stable PowerToys release could not be checked: ${error.message}`
);
}
const isOutdated =
reportedVersion &&
latestStableVersion &&
compareVersions(reportedVersion, latestStableVersion) === -1;
let hasMissingInformation =
item.has_missing_information === true ||
item.has_missing_information === 'true';
let missingInformation = bounded(
item.missing_information,
800,
hasMissingInformation ? 'Additional investigation details are needed.' : 'None'
);
const requestedIssueKind = String(item.issue_kind || '').toUpperCase();
const issueKind = ['BUG', 'OTHER'].includes(requestedIssueKind)
? requestedIssueKind
: 'OTHER';
if (
issueKind === 'OTHER' &&
/\b(?:bug report|report ZIP|ZIP file)\b/i.test(missingInformation)
) {
hasMissingInformation = false;
missingInformation = 'None';
}
const requestedReproductionQuality = String(
item.reproduction_quality || ''
).toUpperCase();
const reproductionQuality = [
'SUFFICIENT', 'INSUFFICIENT', 'NOT_APPLICABLE'
].includes(requestedReproductionQuality)
? requestedReproductionQuality
: 'NOT_APPLICABLE';
const requestedBugReportRequirement = String(
item.bug_report_requirement || ''
).toUpperCase();
const bugReportRequirement = [
'REQUIRED', 'RECOMMENDED', 'OPTIONAL', 'NOT_APPLICABLE'
].includes(requestedBugReportRequirement)
? requestedBugReportRequirement
: issueKind === 'BUG' ? 'RECOMMENDED' : 'NOT_APPLICABLE';
const requestedBugReportStatus = String(
item.bug_report_status || ''
).toUpperCase();
const bugReportStatus = [
'ANALYZED', 'NOT_FOUND', 'REJECTED', 'NOT_APPLICABLE'
].includes(requestedBugReportStatus)
? requestedBugReportStatus
: 'NOT_APPLICABLE';
const requestedBugReportConfidence = String(
item.bug_report_confidence || ''
).toUpperCase();
const bugReportConfidence = ['HIGH', 'MEDIUM', 'LOW', 'NONE'].includes(
requestedBugReportConfidence
) ? requestedBugReportConfidence : 'NONE';
const requestedIssueLanguage = String(
item.issue_language || ''
).toUpperCase();
const issueLanguage = [
'ENGLISH', 'NON_ENGLISH', 'UNCERTAIN'
].includes(requestedIssueLanguage)
? requestedIssueLanguage
: 'UNCERTAIN';
const needsEnglishTranslation = issueLanguage === 'NON_ENGLISH';
const bugReportFindings = bounded(
redactDiagnostic(
typeof item.bug_report_findings === 'string'
? item.bug_report_findings
: ''
),
1200,
'No diagnostic findings were provided.'
);
if (issueKind === 'BUG') {
const missingReproduction =
!needsEnglishTranslation &&
reproductionQuality !== 'SUFFICIENT';
const missingReport =
bugReportRequirement === 'REQUIRED' &&
bugReportStatus !== 'ANALYZED';
hasMissingInformation = missingReproduction || missingReport;
if (!hasMissingInformation) {
missingInformation = 'None';
} else if (missingReproduction && missingReport) {
missingInformation = bugReportStatus === 'REJECTED'
? 'Please provide concrete steps to reproduce and attach a newly generated PowerToys bug report ZIP.'
: 'Please provide concrete steps to reproduce and attach the PowerToys bug report ZIP.';
} else if (missingReproduction) {
missingInformation = 'Please provide concrete steps to reproduce the issue.';
} else {
missingInformation = bugReportStatus === 'REJECTED'
? 'Please attach a newly generated PowerToys bug report ZIP.'
: 'Please attach the PowerToys bug report ZIP.';
}
}
let requestedDuplicates;
try {
requestedDuplicates = JSON.parse(item.duplicate_candidates_json);
} catch {
core.setFailed('duplicate_candidates_json is not valid JSON');
return;
}
if (!Array.isArray(requestedDuplicates) || requestedDuplicates.length > 5) {
core.setFailed('duplicate_candidates_json must be an array with at most five items');
return;
}
const issueNumber = context.issue.number;
const verifiedDuplicates = [];
const seen = new Set();
for (const candidate of requestedDuplicates) {
const number = Number(candidate?.number);
if (!Number.isSafeInteger(number) || number <= 0 ||
number === issueNumber || seen.has(number)) {
continue;
}
seen.add(number);
try {
const response = await github.rest.issues.get({
...context.repo,
issue_number: number
});
if (response.data.pull_request) continue;
verifiedDuplicates.push({
number,
id: response.data.id,
title: bounded(response.data.title, 300, `Issue ${number}`),
reason: bounded(candidate?.reason, 300, 'Describes the same underlying report.'),
confidence: ['HIGH', 'MEDIUM', 'LOW'].includes(
String(candidate?.confidence || '').toUpperCase()
) ? String(candidate.confidence).toUpperCase() : 'MEDIUM'
});
} catch (error) {
if (error.status !== 404) throw error;
}
}
const confidenceRank = { HIGH: 3, MEDIUM: 2, LOW: 1 };
verifiedDuplicates.sort(
(left, right) =>
confidenceRank[right.confidence] - confidenceRank[left.confidence] ||
left.number - right.number
);
const duplicateSection = verifiedDuplicates.length
? verifiedDuplicates.map(candidate =>
[
'<details>',
`<summary>#${candidate.number} — ${formatTechnicalMarkdown(candidate.title)}</summary>`,
'',
`**Why this may be a duplicate:** ${formatTechnicalMarkdown(candidate.reason)}`,
'</details>'
].join('\n')
).join('\n\n')
: '';
const author = context.payload.issue?.user?.login;
const authorActions = [];
if (hasMissingInformation) {
authorActions.push(
`**Needed:** ${formatTechnicalMarkdown(missingInformation)}`
);
}
if (needsEnglishTranslation) {
authorActions.push(
'**Needed:** Please translate the issue title and description to English.'
);
}
const updateRecommendation =
isOutdated
? [
'**Recommended:** Please update PowerToys',
`from `${reportedVersion.replaceAll('`', '')}``,
latestStableUrl
? `to the latest stable release, [`${latestStableVersion.replaceAll('`', '')}`](${latestStableUrl}),`
: `to the latest stable release, `${latestStableVersion.replaceAll('`', '')}`,`,
'and confirm whether the issue still reproduces.'
].join(' ')
: '';
if (updateRecommendation) {
authorActions.push(updateRecommendation);
}
const authorSection = authorActions.length
? [
author
? `@${author}, please review the following:`
: 'Issue author, please review the following:',
'',
...authorActions.map(action => `- ${action}`)
]
: [];
const repositoryLabels = await github.paginate(
github.rest.issues.listLabelsForRepo,
{ ...context.repo, per_page: 100 }
);
const productLabels = repositoryLabels
.map(label => label.name)
.filter(name => name.startsWith('Product-'));
const versionLabels = repositoryLabels
.map(label => label.name)
.filter(name => /^\d+.\d+(?:.\d+)?(?:-.+)?$/.test(name));
const normalizeLabel = value => String(value)
.toLowerCase()
.replace(/[^a-z0-9]+/g, '');
const desiredProductLabel =
productLabels.find(
label =>
normalizeLabel(label.slice('Product-'.length)) ===
normalizeLabel(area)
) ||
productLabels.find(
label => label.toLowerCase() === requestedProductLabel.toLowerCase()
) ||
null;
const desiredVersionLabel =
versionLabels.find(
label => label.toLowerCase() === powertoysVersion.toLowerCase()
) ||
null;
const bodyLines = [
marker,
`<!-- powertoys-ai-triage:input-sha256:${inputSha256} -->`,
'## 🧭 Triage summary',
''
];
if (authorSection.length) {
bodyLines.push(
'### 🙋 For the issue author',
'',
...authorSection,
''
);
}
bodyLines.push(
'### 🛠️ For the PowerToys team',
'',
[
desiredProductLabel
? `**🧩 ${escapeMarkdown(desiredProductLabel.slice('Product-'.length))}**`
: `**🧩 ${escapeMarkdown(area === 'Unknown' ? 'Unclassified' : area)}**`,
issueKind === 'BUG' ? '**🐞 Bug**' : '**📌 Issue**',
powertoysVersion !== 'Not provided'
? `**📦 PowerToys `${powertoysVersion.replaceAll('`', '')}`**`
: null
].filter(Boolean).join(' · '),
'',
formatTechnicalMarkdown(summary),
''
);
if (issueKind === 'BUG' && bugReportStatus === 'ANALYZED') {
bodyLines.push(
'### 🔎 Diagnostic finding',
'',
`${formatTechnicalMarkdown(bugReportFindings)} _(${bugReportConfidence.toLowerCase()} confidence)_`,
''
);
} else if (issueKind === 'BUG' && bugReportStatus === 'REJECTED') {
bodyLines.push(
'### ⚠️ Bug report',
'',
`The attached report could not be safely analyzed: ${formatTechnicalMarkdown(bugReportFindings)}`,
''
);
}
if (verifiedDuplicates.length) {
bodyLines.push('### 🔁 Possible duplicates', '', duplicateSection, '');
}
if (issueKind === 'BUG') {
const reportDetail = bugReportStatus === 'ANALYZED'
? '✅ Analyzed from a sanitized diagnostic subset; the raw archive was discarded.'
: bugReportRequirement === 'REQUIRED'
? `⚠️ Required for this failure type; ${bugReportStatus.replaceAll('_', ' ').toLowerCase()}`
: bugReportRequirement === 'OPTIONAL'
? 'ℹ️ Not attached; optional for this clear UI/visual report.'
: 'ℹ️ Not attached; may help later but does not block triage.';
const reproductionDetail = reproductionQuality === 'SUFFICIENT'
? '✅ Sufficient'
: needsEnglishTranslation
? '⏳ Reassess after English translation'
: reproductionQuality === 'INSUFFICIENT'
? '⚠️ Needs more detail'
: 'Not applicable';
bodyLines.push(
'<details>',
'<summary>🧪 Investigation details</summary>',
'',
`- **Reproduction:** ${reproductionDetail}`,
`- **Bug report:** ${reportDetail}`,
'</details>',
''
);
}
bodyLines.push(
'_AI-assisted automated triage; PowerToys maintainers make final decisions._',
'',
'<!-- gh-aw-workflow-id: issue-triage -->'
);
const body = bodyLines.join('\n');
const comments = await github.paginate(github.rest.issues.listComments, {
...context.repo,
issue_number: issueNumber,
per_page: 100
});
const canonical = comments
.filter(comment =>
comment.user?.login === 'github-actions[bot]' &&
typeof comment.body === 'string' &&
comment.body.includes(marker)
)
.sort((left, right) => left.id - right.id)[0];
let comment;
if (canonical) {
comment = await github.rest.issues.updateComment({
...context.repo,
comment_id: canonical.id,
body
});
} else {
comment = await github.rest.issues.createComment({
...context.repo,
issue_number: issueNumber,
body
});
}
if (comment.data.pin != null) {
await github.request(
'DELETE /repos/{owner}/{repo}/issues/comments/{comment_id}/pin',
{ ...context.repo, comment_id: comment.data.id }
);
}
const needsAuthorFeedback =
needsEnglishTranslation || hasMissingInformation;
const currentLabels = new Set(
(context.payload.issue?.labels || [])
.map(label => typeof label === 'string' ? label : label?.name)
.filter(Boolean)
);
if (needsAuthorFeedback && !currentLabels.has('Needs-Author-Feedback')) {
await github.rest.issues.addLabels({
...context.repo,
issue_number: issueNumber,
labels: ['Needs-Author-Feedback']
});
} else if (!needsAuthorFeedback && currentLabels.has('Needs-Author-Feedback')) {
try {
await github.rest.issues.removeLabel({
...context.repo,
issue_number: issueNumber,
name: 'Needs-Author-Feedback'
});
} catch (error) {
if (error.status !== 404) throw error;
}
}
if (desiredProductLabel && !currentLabels.has(desiredProductLabel)) {
await github.rest.issues.addLabels({
...context.repo,
issue_number: issueNumber,
labels: [desiredProductLabel]
});
}
for (const currentLabel of currentLabels) {
if (
versionLabels.includes(currentLabel) &&
currentLabel !== desiredVersionLabel
) {
try {
await github.rest.issues.removeLabel({
...context.repo,
issue_number: issueNumber,
name: currentLabel
});
} catch (error) {
if (error.status !== 404) throw error;
}
}
}
if (desiredVersionLabel && !currentLabels.has(desiredVersionLabel)) {
await github.rest.issues.addLabels({
...context.repo,
issue_number: issueNumber,
labels: [desiredVersionLabel]
});
}
if (verifiedDuplicates.length) {
const strongest = verifiedDuplicates[0];
const response = await github.request(
'PATCH /repos/{owner}/{repo}/issues/{issue_number}',
{
...context.repo,
issue_number: issueNumber,
state: {
value: 'closed',
rationale:
`${strongest.reason} Suggested canonical issue: #${strongest.number}.`,
confidence: strongest.confidence,
suggest: true
},
state_reason: 'duplicate',
duplicate_issue_id: strongest.id,
headers: {
accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2026-03-10'
}
}
);
if (response.data?.state === 'closed') {
await github.rest.issues.update({
...context.repo,
issue_number: issueNumber,
state: 'open'
});
core.setFailed(
'GitHub applied the close instead of holding it for review; the issue was reopened'
);
}
}
|
|
|
|
|
|
|