Files
cs249r_book/.github/workflows/auto-label.yml
Salman Muin Kayser Chishti 4cf7a3aca8 Upgrade GitHub Actions for Node 24 compatibility
Signed-off-by: Salman Muin Kayser Chishti <13schishti@gmail.com>
2026-02-19 09:19:52 +00:00

271 lines
10 KiB
YAML

# =============================================================================
# AUTO-LABEL WORKFLOW (LLM-Powered)
# =============================================================================
# Automatically labels issues and PRs using Ollama LLM analysis.
#
# When an issue or PR is created:
# 1. Fetches all labels from the repository dynamically
# 2. Groups them by prefix (area:, type:, format:, etc.)
# 3. Sends to LLM to analyze and pick appropriate labels
# 4. Applies the selected labels
#
# NO STATIC LABEL LISTS - everything is fetched from GitHub at runtime.
# Just add/remove labels in GitHub and this workflow adapts automatically.
# =============================================================================
name: '🏷️ Auto Label'
on:
issues:
types: [opened]
pull_request_target:
types: [opened]
# Manual trigger for testing on existing issues/PRs
workflow_dispatch:
inputs:
issue_number:
description: 'Issue or PR number to label'
required: true
type: number
jobs:
auto-label:
name: Auto Label with LLM
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- name: Get issue/PR details
id: get-details
uses: actions/github-script@v8
with:
script: |
// Determine which issue/PR to label
let number, title, body;
if (context.eventName === 'workflow_dispatch') {
// Manual trigger - fetch the specified issue/PR
number = ${{ inputs.issue_number || 0 }};
if (!number) {
core.setFailed('No issue_number provided for manual trigger');
return;
}
try {
const { data: issue } = await github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: number
});
title = issue.title;
body = issue.body || '';
console.log(`Manual trigger for #${number}: ${title}`);
} catch (e) {
core.setFailed(`Could not fetch issue/PR #${number}: ${e.message}`);
return;
}
} else if (context.eventName === 'issues') {
number = context.payload.issue.number;
title = context.payload.issue.title;
body = context.payload.issue.body || '';
} else {
number = context.payload.pull_request.number;
title = context.payload.pull_request.title;
body = context.payload.pull_request.body || '';
}
core.setOutput('number', number);
core.setOutput('title', title);
core.setOutput('body', body);
- name: Fetch labels from GitHub
id: fetch-labels
uses: actions/github-script@v8
with:
script: |
// Fetch all labels from the repository
const { data: labels } = await github.rest.issues.listLabelsForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 100
});
console.log(`Found ${labels.length} labels in repository`);
// Group labels by prefix
const grouped = {
area: [],
type: [],
format: [],
other: []
};
for (const label of labels) {
const name = label.name;
const desc = label.description || '';
if (name.startsWith('area:')) {
grouped.area.push({ name, description: desc });
} else if (name.startsWith('type:')) {
grouped.type.push({ name, description: desc });
} else if (name.startsWith('format:')) {
grouped.format.push({ name, description: desc });
} else {
grouped.other.push({ name, description: desc });
}
}
// Format for LLM prompt
const formatGroup = (items) => items
.map(l => `${l.name} - ${l.description}`)
.join('\n');
const areaLabels = formatGroup(grouped.area);
const typeLabels = formatGroup(grouped.type);
const otherLabels = formatGroup(grouped.other);
// Store all valid label names for validation
const allLabelNames = labels.map(l => l.name);
// Set outputs
core.setOutput('area_labels', areaLabels);
core.setOutput('type_labels', typeLabels);
core.setOutput('other_labels', otherLabels);
core.setOutput('all_labels_json', JSON.stringify(allLabelNames));
// Find defaults (first of each type, or fallback)
const defaultArea = grouped.area[0]?.name || '';
const defaultType = grouped.type[0]?.name || '';
core.setOutput('default_area', defaultArea);
core.setOutput('default_type', defaultType);
console.log('Area labels:', grouped.area.length);
console.log('Type labels:', grouped.type.length);
console.log('Other labels:', grouped.other.length);
- name: Analyze with LLM
uses: ai-action/ollama-action@v2
id: llm
with:
model: llama3.1:8b
prompt: |
You are a GitHub issue labeler. Analyze this issue/PR and select the most appropriate labels.
TITLE: ${{ steps.get-details.outputs.title }}
BODY: ${{ steps.get-details.outputs.body }}
AVAILABLE AREA LABELS (pick exactly ONE - these indicate which part of the project):
${{ steps.fetch-labels.outputs.area_labels }}
AVAILABLE TYPE LABELS (pick exactly ONE - these indicate what kind of issue):
${{ steps.fetch-labels.outputs.type_labels }}
OTHER LABELS (pick any that clearly apply, or none):
${{ steps.fetch-labels.outputs.other_labels }}
Instructions:
- Pick ONE area label based on which project/component this relates to
- Pick ONE type label based on what kind of issue this is
- Only add other labels if they clearly and obviously apply
- If unsure about area, use "${{ steps.fetch-labels.outputs.default_area }}"
- If unsure about type, use "${{ steps.fetch-labels.outputs.default_type }}"
Return ONLY a JSON object with this exact format (no other text):
{"area": "area: book", "type": "type: bug", "other": []}
The "other" array should be empty [] or contain label names that apply.
- name: Parse and apply labels
uses: actions/github-script@v8
env:
LLM_RESPONSE: ${{ steps.llm.outputs.response }}
ALL_LABELS_JSON: ${{ steps.fetch-labels.outputs.all_labels_json }}
DEFAULT_AREA: ${{ steps.fetch-labels.outputs.default_area }}
DEFAULT_TYPE: ${{ steps.fetch-labels.outputs.default_type }}
ISSUE_NUMBER: ${{ steps.get-details.outputs.number }}
with:
script: |
const response = process.env.LLM_RESPONSE || '';
console.log('LLM response:', response);
// Get all valid labels from the fetch step
const allValidLabels = JSON.parse(process.env.ALL_LABELS_JSON || '[]');
const defaultArea = process.env.DEFAULT_AREA || '';
const defaultType = process.env.DEFAULT_TYPE || '';
console.log(`Valid labels: ${allValidLabels.length}`);
console.log(`Default area: ${defaultArea}`);
console.log(`Default type: ${defaultType}`);
// Helper to normalize label (add space after colon if missing)
const normalizeLabel = (label) => {
if (!label) return label;
// If label has "prefix:value" without space, add space
return label.replace(/^(area|type|format):(?! )/, '$1: ');
};
// Parse LLM response
let result = { area: defaultArea, type: defaultType, other: [] };
try {
// Find JSON in response (LLM might add extra text)
const jsonMatch = response.match(/\{[\s\S]*?\}/);
if (jsonMatch) {
const parsed = JSON.parse(jsonMatch[0]);
if (parsed.area) result.area = normalizeLabel(parsed.area);
if (parsed.type) result.type = normalizeLabel(parsed.type);
if (parsed.other) result.other = parsed.other.map(normalizeLabel);
}
} catch (e) {
console.log('Failed to parse JSON, using defaults:', e.message);
}
// Validate and collect labels (only allow labels that exist in repo)
const labels = [];
// Validate area label
if (allValidLabels.includes(result.area)) {
labels.push(result.area);
} else if (defaultArea) {
console.log(`Invalid area label "${result.area}", using default "${defaultArea}"`);
labels.push(defaultArea);
}
// Validate type label
if (allValidLabels.includes(result.type)) {
labels.push(result.type);
} else if (defaultType) {
console.log(`Invalid type label "${result.type}", using default "${defaultType}"`);
labels.push(defaultType);
}
// Validate other labels
if (Array.isArray(result.other)) {
for (const label of result.other) {
if (allValidLabels.includes(label)) {
labels.push(label);
} else {
console.log(`Ignoring invalid label: "${label}"`);
}
}
}
// Apply labels
const number = parseInt(process.env.ISSUE_NUMBER, 10);
if (labels.length > 0) {
console.log(`Applying labels to #${number}: ${labels.join(', ')}`);
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: number,
labels: labels
});
} else {
console.log('No valid labels to apply');
}