# ============================================================================= # 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'); }