refactor(metrics): count entities on demand instead of via event counters #209
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(', ')}`); |