Files
vikunja/.github/workflows/auto-label.yml
8d92393ea3 fix(ci): bump LLM-action fork pin to root-container fix (#3430)
The auto-label workflow's LLM call succeeds, but the docker action ran
as a non-root user (`USER appuser` in the Dockerfile) and could not
write the runner's `GITHUB_OUTPUT` file:

```
Error: failed to set output: ... open /github/file_commands/set_output_...: permission denied
```

GitHub docker actions must run as root — the runner's file-command files
are owned by the runner user. Fixed in the fork
(tink-bot/LLM-action@8526bab removes the non-root user) and bumped the
pin here.

Failing run:
https://github.com/go-vikunja/vikunja/actions/runs/30904804523/job/91977288580

Co-authored-by: kolaente <k@knt.li>
2026-08-04 13:56:36 +00:00

215 lines
8.5 KiB
YAML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 24 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(', ')}`);