mirror of
https://github.com/harvard-edge/cs249r_book.git
synced 2026-03-11 17:49:25 -05:00
271 lines
10 KiB
YAML
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');
|
|
}
|