Skip to content

Commit c4cc6d3

Browse files
committed
ci: add AI-powered auto-labeling for new issues and PRs
Uses actions/ai-inference with GPT-5 to classify newly opened issues and pull requests against the area/*, integration/*, db/*, and concern/* label namespaces. The system prompt is rendered at runtime from the live repo label list plus descriptions, so GitHub label state is the single source of truth for the taxonomy. Suggested labels are re-validated against the live list before being applied, capped at 6 per item.
1 parent 2796fff commit c4cc6d3

2 files changed

Lines changed: 249 additions & 0 deletions

File tree

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
You are a triage assistant for the Vikunja repository. Your job is to classify a single issue or pull request using the label taxonomy below, and return ONLY a JSON array of chosen label names — nothing else.
2+
3+
# Output format
4+
5+
Return exactly a JSON array of strings, e.g.:
6+
7+
["area/kanban", "area/recurring-tasks", "concern/regression"]
8+
9+
No prose, no markdown fences, no explanation. If you cannot confidently classify, return an empty array: []
10+
11+
# Rules
12+
13+
1. Every well-formed item gets at least one `area/*` label. If you truly cannot pick one, return [].
14+
2. Multi-label is the norm. 2–4 labels is typical, occasionally up to 6.
15+
3. `concern/*` is additive — it describes a cross-cutting quality (UX polish, performance, a11y, regression) on top of the feature area.
16+
4. `integration/*` applies only when the item is about connecting to a *specific third-party system* (Slack, Gotify, Apprise, external webhooks, WeKan import, Todoist import, add-task-from-email, MCP, etc.).
17+
- CalDAV is its own `area/caldav` — do NOT also tag `integration/*`.
18+
- Generic webhook infrastructure is `area/webhooks`; a PR adding Slack delivery is `area/webhooks` + `integration/outbound`.
19+
5. `db/mysql`, `db/postgres`, `db/sqlite` ONLY when the item is explicitly engine-specific (e.g. "fails on MySQL 8"). General DB issues get `area/database` with no engine tag.
20+
6. `concern/regression` ONLY if the body explicitly says it worked in a prior version and is broken now.
21+
7. Do NOT invent labels. Only use names from the taxonomy below — anything else will be discarded.
22+
23+
# Taxonomy
24+
25+
The following labels are available. Each line is `label-name — description`. Pick only from this list.
26+
27+
{{TAXONOMY}}
28+
29+
# Examples
30+
31+
Input:
32+
TITLE: SQL syntax error on MySQL due to CAST in is_archived computation
33+
BODY: After upgrading to 2.3.0 I get SQL syntax errors on MySQL 8. Worked fine on 2.2.x.
34+
Output:
35+
["area/database", "db/mysql", "concern/regression"]
36+
37+
Input:
38+
TITLE: feat: add Slack webhook support
39+
BODY: Adds outbound Slack notifications when tasks change.
40+
Output:
41+
["area/webhooks", "area/notifications", "integration/outbound"]
42+
43+
Input:
44+
TITLE: Mobile: "Mark task done" should be easier to find
45+
BODY: The checkbox is too small on phones.
46+
Output:
47+
["area/mobile", "area/task-editor", "concern/ux"]

.github/workflows/auto-label.yml

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
name: Auto-label new issues and PRs
2+
3+
on:
4+
issues:
5+
types: [opened]
6+
pull_request_target:
7+
types: [opened]
8+
9+
permissions:
10+
contents: read
11+
issues: write
12+
pull-requests: write
13+
models: read
14+
15+
concurrency:
16+
group: auto-label-${{ github.event.issue.number || github.event.pull_request.number }}
17+
cancel-in-progress: false
18+
19+
jobs:
20+
classify:
21+
runs-on: ubuntu-latest
22+
steps:
23+
- name: Checkout (for prompt template)
24+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
25+
with:
26+
sparse-checkout: |
27+
.github/workflows/auto-label.prompt.md
28+
sparse-checkout-cone-mode: false
29+
30+
- name: Render system prompt from live labels
31+
id: render
32+
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
33+
env:
34+
PROMPT_TEMPLATE_PATH: .github/workflows/auto-label.prompt.md
35+
with:
36+
script: |
37+
const fs = require('fs');
38+
const path = require('path');
39+
40+
// Fetch every label in the repo, keep only the managed namespaces.
41+
const managedPrefixes = ['area/', 'integration/', 'db/', 'concern/'];
42+
const all = await github.paginate(
43+
github.rest.issues.listLabelsForRepo,
44+
{ owner: context.repo.owner, repo: context.repo.repo, per_page: 100 }
45+
);
46+
const managed = all
47+
.filter(l => managedPrefixes.some(p => l.name.startsWith(p)))
48+
.sort((a, b) => a.name.localeCompare(b.name));
49+
50+
if (managed.length === 0) {
51+
core.setFailed('No managed labels found on the repo — cannot build taxonomy.');
52+
return;
53+
}
54+
55+
// Warn about labels without descriptions — they confuse the classifier.
56+
const undescribed = managed.filter(l => !l.description || !l.description.trim());
57+
if (undescribed.length > 0) {
58+
core.warning(
59+
`Labels without descriptions will be skipped: ${undescribed.map(l => l.name).join(', ')}`
60+
);
61+
}
62+
63+
// Group by namespace for readability in the prompt.
64+
const groups = {};
65+
for (const l of managed) {
66+
if (!l.description || !l.description.trim()) continue;
67+
const prefix = managedPrefixes.find(p => l.name.startsWith(p));
68+
(groups[prefix] ||= []).push(l);
69+
}
70+
71+
const sections = [];
72+
for (const prefix of managedPrefixes) {
73+
const entries = groups[prefix] || [];
74+
if (entries.length === 0) continue;
75+
sections.push(`## ${prefix}*\n`);
76+
for (const l of entries) {
77+
sections.push(`- \`${l.name}\` — ${l.description.trim()}`);
78+
}
79+
sections.push('');
80+
}
81+
const taxonomy = sections.join('\n');
82+
83+
// Expand the template.
84+
const templatePath = process.env.PROMPT_TEMPLATE_PATH;
85+
const template = fs.readFileSync(templatePath, 'utf8');
86+
if (!template.includes('{{TAXONOMY}}')) {
87+
core.setFailed(`Template ${templatePath} is missing the {{TAXONOMY}} placeholder.`);
88+
return;
89+
}
90+
const rendered = template.replace('{{TAXONOMY}}', taxonomy);
91+
92+
const outPath = path.join(process.env.RUNNER_TEMP, 'system-prompt.md');
93+
fs.writeFileSync(outPath, rendered);
94+
core.setOutput('system_prompt_path', outPath);
95+
core.info(`Rendered ${managed.length} labels into ${outPath}`);
96+
97+
- name: Build user prompt
98+
id: prep
99+
env:
100+
TITLE: ${{ github.event.issue.title || github.event.pull_request.title }}
101+
BODY: ${{ github.event.issue.body || github.event.pull_request.body }}
102+
KIND: ${{ github.event_name == 'issues' && 'issue' || 'pull request' }}
103+
run: |
104+
mkdir -p "$RUNNER_TEMP/ai"
105+
python3 - <<'PY' > "$RUNNER_TEMP/ai/user-prompt.txt"
106+
import os
107+
title = os.environ.get("TITLE", "").strip()
108+
body = (os.environ.get("BODY", "") or "").strip() or "(no description)"
109+
kind = os.environ.get("KIND", "issue")
110+
# Truncate very long bodies to keep token usage predictable
111+
if len(body) > 8000:
112+
body = body[:8000] + "\n\n[... truncated ...]"
113+
print(f"Classify the following {kind}. Return ONLY a JSON array of labels.\n")
114+
print("--- TITLE ---")
115+
print(title)
116+
print()
117+
print("--- BODY ---")
118+
print(body)
119+
print("--- END ---")
120+
PY
121+
echo "prompt_path=$RUNNER_TEMP/ai/user-prompt.txt" >> "$GITHUB_OUTPUT"
122+
123+
- name: Classify with AI
124+
id: classify
125+
uses: actions/ai-inference@e09e65981758de8b2fdab13c2bfb7c7d5493b0b6 # v2.0.7
126+
with:
127+
model: openai/gpt-5
128+
# GPT-5 is a reasoning model: output tokens include reasoning, so budget generously.
129+
# Temperature is ignored by reasoning models and intentionally omitted.
130+
max-completion-tokens: 2000
131+
system-prompt-file: ${{ steps.render.outputs.system_prompt_path }}
132+
prompt-file: ${{ steps.prep.outputs.prompt_path }}
133+
134+
- name: Apply labels
135+
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
136+
env:
137+
AI_RESPONSE: ${{ steps.classify.outputs.response }}
138+
with:
139+
script: |
140+
const raw = (process.env.AI_RESPONSE || '').trim();
141+
core.info(`Raw AI response:\n${raw}`);
142+
143+
// Extract the first JSON array from the response (tolerates stray prose or code fences)
144+
const match = raw.match(/\[[\s\S]*\]/);
145+
if (!match) {
146+
core.warning('No JSON array found in AI response — skipping labeling.');
147+
return;
148+
}
149+
150+
let parsed;
151+
try {
152+
parsed = JSON.parse(match[0]);
153+
} catch (e) {
154+
core.warning(`Failed to parse JSON array: ${e.message}`);
155+
return;
156+
}
157+
if (!Array.isArray(parsed)) {
158+
core.warning('AI response JSON is not an array — skipping.');
159+
return;
160+
}
161+
162+
// Re-validate against live repo labels. Same source of truth as the prompt renderer,
163+
// so drift is impossible — any label the model picks MUST exist in the repo.
164+
const managedPrefixes = ['area/', 'integration/', 'db/', 'concern/'];
165+
const allRepoLabels = await github.paginate(
166+
github.rest.issues.listLabelsForRepo,
167+
{ owner: context.repo.owner, repo: context.repo.repo, per_page: 100 }
168+
);
169+
const allowed = new Set(
170+
allRepoLabels
171+
.map(l => l.name)
172+
.filter(n => managedPrefixes.some(p => n.startsWith(p)))
173+
);
174+
175+
const valid = [...new Set(parsed)].filter(
176+
l => typeof l === 'string' && allowed.has(l)
177+
);
178+
const rejected = parsed.filter(l => !valid.includes(l));
179+
180+
if (rejected.length > 0) {
181+
core.warning(`Ignored unknown labels: ${JSON.stringify(rejected)}`);
182+
}
183+
184+
// Cap at 6 labels — our taxonomy rule says 2–4 is typical, 6 is the ceiling.
185+
const toApply = valid.slice(0, 6);
186+
187+
if (toApply.length === 0) {
188+
core.info('No valid labels selected — leaving item unlabeled for human triage.');
189+
return;
190+
}
191+
192+
const number =
193+
context.payload.issue?.number ?? context.payload.pull_request.number;
194+
195+
await github.rest.issues.addLabels({
196+
owner: context.repo.owner,
197+
repo: context.repo.repo,
198+
issue_number: number,
199+
labels: toApply,
200+
});
201+
202+
core.info(`Applied labels to #${number}: ${toApply.join(', ')}`);

0 commit comments

Comments
 (0)