mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-08-30 09:07:40 -05:00
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>
215 lines
8.5 KiB
YAML
215 lines
8.5 KiB
YAML
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(', ')}`);
|