79031da543
Spell checking / Check Spelling (push) Has been cancelled
Spell checking / Update PR (push) Has been cancelled
Publish Dev Docs Website / build (push) Failing after 1s
Publish Dev Docs Website / deploy (push) Has been skipped
Spell checking / Report (Push) (push) Has been cancelled
Spell checking / Report (PR) (push) Has been cancelled
241 lines
9.9 KiB
YAML
241 lines
9.9 KiB
YAML
name: Automatic Triaging on Issue/PR Creation
|
|
|
|
on:
|
|
issues:
|
|
types: [opened, reopened]
|
|
pull_request_target:
|
|
types: [opened, reopened, synchronize]
|
|
# Manual trigger: go to Actions → "Automatic Triaging on Issue Creation" → Run workflow.
|
|
# Enter one or more comma-separated issue numbers (e.g. "1234" or "1234,1235,1236")
|
|
# to apply AI-generated area labels to existing untriaged issues.
|
|
workflow_dispatch:
|
|
inputs:
|
|
issue_numbers:
|
|
description: 'Comma-separated issue number(s) to label (e.g. 1234 or 1234,1235)'
|
|
required: true
|
|
|
|
permissions:
|
|
models: read
|
|
issues: write
|
|
pull-requests: write
|
|
|
|
concurrency:
|
|
# Each workflow run gets its own concurrency group.
|
|
# For issue events, group by issue number so a rapid close+reopen only runs once.
|
|
# For pull request events, group by PR number so rapid updates coalesce.
|
|
# For manual dispatch (which may cover multiple issues), use the unique run ID.
|
|
group: ${{ github.event_name == 'issues' && format('{0}-issues-{1}', github.workflow, github.event.issue.number) || github.event_name == 'pull_request_target' && format('{0}-pr-{1}', github.workflow, github.event.pull_request.number) || github.run_id }}
|
|
cancel-in-progress: true
|
|
|
|
jobs:
|
|
label:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- name: Apply area labels with AI
|
|
uses: actions/github-script@v9
|
|
env:
|
|
# actions/github-script does not propagate `github-token` to
|
|
# process.env. Expose it explicitly so the inline script can
|
|
# authenticate against the GitHub Models inference endpoint.
|
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
with:
|
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
script: |
|
|
// When triggered manually, process each supplied issue number in turn.
|
|
// When triggered by an issue or PR event, use the event's number.
|
|
let issueNumbers;
|
|
if (context.eventName === 'workflow_dispatch') {
|
|
issueNumbers = String(context.payload.inputs.issue_numbers)
|
|
.split(',')
|
|
.map(s => parseInt(s.trim(), 10))
|
|
.filter(n => Number.isFinite(n) && n > 0);
|
|
} else {
|
|
issueNumbers = [context.issue.number];
|
|
}
|
|
|
|
if (issueNumbers.length === 0) {
|
|
console.log('No valid issue numbers to process; skipping.');
|
|
return;
|
|
}
|
|
|
|
for (const issueNumber of issueNumbers) {
|
|
console.log(`\n--- Processing item #${issueNumber} ---`);
|
|
await labelIssue(issueNumber);
|
|
}
|
|
|
|
async function labelIssue(issueNumber) {
|
|
// Fetch as an issue resource; PRs are represented by issues with a pull_request field.
|
|
const { data: issue } = await github.rest.issues.get({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
issue_number: issueNumber,
|
|
});
|
|
|
|
const itemType = issue.pull_request ? 'Pull request' : 'Issue';
|
|
|
|
// Skip pull requests that already have labels applied.
|
|
if (issue.pull_request && issue.labels && issue.labels.length > 0) {
|
|
const existingLabels = issue.labels.map(l => l.name).join(', ');
|
|
console.log(`${itemType} #${issueNumber} already has labels (${existingLabels}); skipping.`);
|
|
return;
|
|
}
|
|
|
|
const title = issue.title ?? '';
|
|
const body = issue.body ?? '';
|
|
|
|
if (!title && !body) {
|
|
console.log(`${itemType} #${issueNumber} has no title or body; skipping.`);
|
|
return;
|
|
}
|
|
|
|
// Truncation limit for issue body sent to the model. Keeps the
|
|
// prompt within the model's context window and avoids high token usage.
|
|
const MAX_BODY_LENGTH = 4000;
|
|
|
|
// Upper bound on model response tokens. A JSON array of label strings
|
|
// is compact; 200 tokens is more than enough for any realistic response.
|
|
const MAX_TOKENS = 200;
|
|
|
|
// All valid Product-* and Area-* labels the agent may choose from.
|
|
const VALID_LABELS = [
|
|
'Product-Advanced Paste',
|
|
'Product-Always On Top',
|
|
'Product-Awake',
|
|
'Product-Color Picker',
|
|
'Product-CommandNotFound',
|
|
'Product-Command Palette',
|
|
'Product-CropAndLock',
|
|
'Product-Environment Variables',
|
|
'Product-FancyZones',
|
|
'Product-File Explorer',
|
|
'Product-File Locksmith',
|
|
'Product-Find My Mouse',
|
|
'Product-Grab And Move',
|
|
'Product-Hosts File Editor',
|
|
'Product-Image Resizer',
|
|
'Product-Keyboard Manager',
|
|
'Product-LightSwitch',
|
|
'Product-Mouse Highlighter',
|
|
'Product-Mouse Jump',
|
|
'Product-Mouse Pointer Crosshairs',
|
|
'Product-Mouse Utilities',
|
|
'Product-Mouse Without Borders',
|
|
'Product-New+',
|
|
'Product-Peek',
|
|
'Product-PowerDisplay',
|
|
'Product-PowerRename',
|
|
'Product-PowerToys Run',
|
|
'Product-Quick Accent',
|
|
'Product-Registry Preview',
|
|
'Product-Screen Ruler',
|
|
'Product-Settings',
|
|
'Product-Shortcut Guide',
|
|
'Product-Text Extractor',
|
|
'Product-Workspaces',
|
|
'Product-ZoomIt',
|
|
'Area-Setup/Install',
|
|
'Area-Localization',
|
|
];
|
|
|
|
const systemPrompt = `You are a GitHub triage assistant for the microsoft/PowerToys repository.
|
|
Your job is to classify issues and pull requests by assigning the correct area label(s).
|
|
|
|
Rules:
|
|
- Only return labels from the following list, exactly as written:
|
|
${VALID_LABELS.map(l => ` • ${l}`).join('\n')}
|
|
- Choose only the labels that clearly match the issue content.
|
|
- If the issue mentions multiple areas, include a label for each one.
|
|
- If no label fits, return an empty array.
|
|
- Respond with ONLY a JSON array of label strings, no explanation.
|
|
Example: ["Product-FancyZones","Product-Settings"]`;
|
|
|
|
const userPrompt = `${itemType} title: ${title}
|
|
|
|
${itemType} body:
|
|
${body.slice(0, MAX_BODY_LENGTH)}`;
|
|
|
|
// Validate that the token is available before making the API call.
|
|
const token = process.env.GITHUB_TOKEN;
|
|
if (!token) {
|
|
console.log('GITHUB_TOKEN is not set; skipping.');
|
|
return;
|
|
}
|
|
|
|
// Call the GitHub Models inference endpoint (OpenAI-compatible).
|
|
const response = await fetch(
|
|
'https://models.inference.ai.azure.com/chat/completions',
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
model: 'gpt-4o-mini',
|
|
messages: [
|
|
{ role: 'system', content: systemPrompt },
|
|
{ role: 'user', content: userPrompt },
|
|
],
|
|
max_tokens: MAX_TOKENS,
|
|
// temperature: 0 ensures deterministic, consistent label
|
|
// classification across similar issues.
|
|
temperature: 0,
|
|
}),
|
|
}
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const errorBody = await response.text();
|
|
console.log(`GitHub Models API error: ${response.status} ${response.statusText} — ${errorBody}`);
|
|
return;
|
|
}
|
|
|
|
const data = await response.json();
|
|
const text = data.choices?.[0]?.message?.content?.trim() ?? '';
|
|
console.log(`Model response: ${text}`);
|
|
|
|
let suggested;
|
|
try {
|
|
suggested = JSON.parse(text);
|
|
} catch {
|
|
console.log('Could not parse model response as JSON; skipping.');
|
|
return;
|
|
}
|
|
|
|
if (!Array.isArray(suggested) || suggested.length === 0) {
|
|
console.log('No labels suggested by the model.');
|
|
return;
|
|
}
|
|
|
|
// Only apply labels that are in the allow-list.
|
|
const validSet = new Set(VALID_LABELS);
|
|
const toApply = [...new Set(suggested.filter(l => validSet.has(l)))];
|
|
|
|
if (toApply.length === 0) {
|
|
console.log('Model returned no valid labels.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await github.rest.issues.addLabels({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
issue_number: issueNumber,
|
|
labels: toApply,
|
|
});
|
|
console.log(`${itemType} #${issueNumber}: added labels: ${toApply.join(', ')}`);
|
|
} catch (error) {
|
|
// Some contexts (for example, restricted integrations) can deny
|
|
// label writes even when workflow permissions request write scope.
|
|
// Skip without failing the entire triage workflow.
|
|
const status = error?.status;
|
|
const message = error?.message ?? String(error);
|
|
if (status === 403 && message.includes('Resource not accessible by integration')) {
|
|
console.log(`${itemType} #${issueNumber}: skipping label write due to restricted token context (403).`);
|
|
return;
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|