chore(deps): update dependency js-yaml to v5.4.0 #1035
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 | |
| 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| 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@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 | |
| 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); | |
| // LLM-action renders prompt files as Go templates — escape stray {{ so | |
| // label descriptions can't break parsing. | |
| const escaped = rendered.replaceAll('{{', '{{"{{"}}'); | |
| // The docker-based action only sees the mounted workspace, not the host's | |
| // RUNNER_TEMP — write into the workspace and pass a relative path. | |
| const outDir = path.join(process.env.GITHUB_WORKSPACE, 'ai-prompts'); | |
| fs.mkdirSync(outDir, { recursive: true }); | |
| fs.writeFileSync(path.join(outDir, 'system-prompt.md'), escaped); | |
| core.setOutput('system_prompt_path', 'ai-prompts/system-prompt.md'); | |
| core.info(`Rendered ${managed.length} labels into ai-prompts/system-prompt.md`); | |
| - 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 ai-prompts | |
| python3 - <<'PY' > ai-prompts/user-prompt.txt | |
| import os | |
| # LLM-action renders prompts as Go templates — escape {{ so user content can't break parsing | |
| def esc(s): | |
| return s.replace('{{', '{{"{{"}}') | |
| title = esc(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 ...]" | |
| body = esc(body) | |
| 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=ai-prompts/user-prompt.txt" >> "$GITHUB_OUTPUT" | |
| - name: Classify with AI | |
| id: classify | |
| # Fork pin until appleboy/LLM-action#24 (max_completion_tokens support | |
| # for reasoning models) is merged and released. | |
| uses: tink-bot/LLM-action@8526bab69af4c4aa3eab141c4220e64c692ed761 # feat-max-completion-tokens | |
| with: | |
| api_key: ${{ secrets.LLM_API_KEY }} | |
| model: 'gpt-5.6-luna' | |
| temperature: '0.2' | |
| max_tokens: '4000' | |
| system_prompt: ${{ steps.render.outputs.system_prompt_path }} | |
| input_prompt: ${{ steps.prep.outputs.prompt_path }} | |
| - name: Apply labels | |
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 | |
| 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(', ')}`); |