Skip to content

refactor(metrics): count entities on demand instead of via event counters #209

refactor(metrics): count entities on demand instead of via event counters

refactor(metrics): count entities on demand instead of via event counters #209

Workflow file for this run

name: Auto-label new issues and PRs
on:
issues:
types: [opened]
pull_request_target:
types: [opened]
permissions:
contents: read
issues: write
pull-requests: write
models: read
concurrency:
group: auto-label-${{ github.event.issue.number || github.event.pull_request.number }}
cancel-in-progress: false
jobs:
classify:
runs-on: ubuntu-latest
steps:
- name: Checkout (for prompt template)
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
sparse-checkout: |
.github/workflows/auto-label.prompt.md
sparse-checkout-cone-mode: false
- name: Render system prompt from live labels
id: render
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
PROMPT_TEMPLATE_PATH: .github/workflows/auto-label.prompt.md
with:
script: |
const fs = require('fs');
const path = require('path');
// Fetch every label in the repo, keep only the managed namespaces.
const managedPrefixes = ['area/', 'integration/', 'db/', 'concern/'];
const all = await github.paginate(
github.rest.issues.listLabelsForRepo,
{ owner: context.repo.owner, repo: context.repo.repo, per_page: 100 }
);
const managed = all
.filter(l => managedPrefixes.some(p => l.name.startsWith(p)))
.sort((a, b) => a.name.localeCompare(b.name));
if (managed.length === 0) {
core.setFailed('No managed labels found on the repo — cannot build taxonomy.');
return;
}
// Warn about labels without descriptions — they confuse the classifier.
const undescribed = managed.filter(l => !l.description || !l.description.trim());
if (undescribed.length > 0) {
core.warning(
`Labels without descriptions will be skipped: ${undescribed.map(l => l.name).join(', ')}`
);
}
// Group by namespace for readability in the prompt.
const groups = {};
for (const l of managed) {
if (!l.description || !l.description.trim()) continue;
const prefix = managedPrefixes.find(p => l.name.startsWith(p));
(groups[prefix] ||= []).push(l);
}
const sections = [];
for (const prefix of managedPrefixes) {
const entries = groups[prefix] || [];
if (entries.length === 0) continue;
sections.push(`## ${prefix}*\n`);
for (const l of entries) {
sections.push(`- \`${l.name}\` — ${l.description.trim()}`);
}
sections.push('');
}
const taxonomy = sections.join('\n');
// Expand the template.
const templatePath = process.env.PROMPT_TEMPLATE_PATH;
const template = fs.readFileSync(templatePath, 'utf8');
if (!template.includes('{{TAXONOMY}}')) {
core.setFailed(`Template ${templatePath} is missing the {{TAXONOMY}} placeholder.`);
return;
}
const rendered = template.replace('{{TAXONOMY}}', taxonomy);
const outPath = path.join(process.env.RUNNER_TEMP, 'system-prompt.md');
fs.writeFileSync(outPath, rendered);
core.setOutput('system_prompt_path', outPath);
core.info(`Rendered ${managed.length} labels into ${outPath}`);
- name: Build user prompt
id: prep
env:
TITLE: ${{ github.event.issue.title || github.event.pull_request.title }}
BODY: ${{ github.event.issue.body || github.event.pull_request.body }}
KIND: ${{ github.event_name == 'issues' && 'issue' || 'pull request' }}
run: |
mkdir -p "$RUNNER_TEMP/ai"
python3 - <<'PY' > "$RUNNER_TEMP/ai/user-prompt.txt"
import os
title = os.environ.get("TITLE", "").strip()
body = (os.environ.get("BODY", "") or "").strip() or "(no description)"
kind = os.environ.get("KIND", "issue")
# Truncate very long bodies to keep token usage predictable
if len(body) > 8000:
body = body[:8000] + "\n\n[... truncated ...]"
print(f"Classify the following {kind}. Return ONLY a JSON array of labels.\n")
print("--- TITLE ---")
print(title)
print()
print("--- BODY ---")
print(body)
print("--- END ---")
PY
echo "prompt_path=$RUNNER_TEMP/ai/user-prompt.txt" >> "$GITHUB_OUTPUT"
- name: Classify with AI
id: classify
uses: actions/ai-inference@e09e65981758de8b2fdab13c2bfb7c7d5493b0b6 # v2.0.7
with:
model: openai/gpt-4.1-mini
# GPT-5 is a reasoning model: output tokens include reasoning, so budget generously.
# Temperature is ignored by reasoning models and intentionally omitted.
max-completion-tokens: 2000
system-prompt-file: ${{ steps.render.outputs.system_prompt_path }}
prompt-file: ${{ steps.prep.outputs.prompt_path }}
- name: Apply labels
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
AI_RESPONSE: ${{ steps.classify.outputs.response }}
with:
script: |
const raw = (process.env.AI_RESPONSE || '').trim();
core.info(`Raw AI response:\n${raw}`);
// Extract the first JSON array from the response (tolerates stray prose or code fences)
const match = raw.match(/\[[\s\S]*\]/);
if (!match) {
core.warning('No JSON array found in AI response — skipping labeling.');
return;
}
let parsed;
try {
parsed = JSON.parse(match[0]);
} catch (e) {
core.warning(`Failed to parse JSON array: ${e.message}`);
return;
}
if (!Array.isArray(parsed)) {
core.warning('AI response JSON is not an array — skipping.');
return;
}
// Re-validate against live repo labels. Same source of truth as the prompt renderer,
// so drift is impossible — any label the model picks MUST exist in the repo.
const managedPrefixes = ['area/', 'integration/', 'db/', 'concern/'];
const allRepoLabels = await github.paginate(
github.rest.issues.listLabelsForRepo,
{ owner: context.repo.owner, repo: context.repo.repo, per_page: 100 }
);
const allowed = new Set(
allRepoLabels
.map(l => l.name)
.filter(n => managedPrefixes.some(p => n.startsWith(p)))
);
const valid = [...new Set(parsed)].filter(
l => typeof l === 'string' && allowed.has(l)
);
const rejected = parsed.filter(l => !valid.includes(l));
if (rejected.length > 0) {
core.warning(`Ignored unknown labels: ${JSON.stringify(rejected)}`);
}
// Cap at 6 labels — our taxonomy rule says 2–4 is typical, 6 is the ceiling.
const toApply = valid.slice(0, 6);
if (toApply.length === 0) {
core.info('No valid labels selected — leaving item unlabeled for human triage.');
return;
}
const number =
context.payload.issue?.number ?? context.payload.pull_request.number;
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: number,
labels: toApply,
});
core.info(`Applied labels to #${number}: ${toApply.join(', ')}`);