Build a GitHub Issue Triager With Free LLMs: Auto-Label, Route Bugs
What We're Building
A GitHub App that listens for new issues, runs a zero-shot classifier on Groq’s blazing-fast inference, then labels and routes the issue automatically. No GPU, no credit card, no monthly bill.
Feature list:
- Listens to
issues.openedwebhook events - Classifies issues into
bug,feature,question,documentation - Applies corresponding GitHub labels
- Assigns
bugissues to an on-call engineer (configurable) - Runs entirely on free tiers: Groq Cloud, Cloudflare Workers AI, GitHub
- Responds in under 2 seconds end-to-end
Architecture Overview
GitHub Repo (new issue)
│
▼
GitHub App Webhook ──► Cloudflare Worker (ingress)
│
├── Validate payload & signature
├── Extract issue title + body
├── Call Groq API (mixtral-8x7b-32768)
├── Parse classification result
├── Call GitHub API (add labels, assign)
└── Return 200 OK
Why this stack:
- Cloudflare Workers – free 100k requests/day, global edge, no cold starts
- Groq – free tier gives ~30 requests/min on Mixtral, inference under 500ms
- GitHub Apps – fine-grained permissions, no personal access tokens
Prerequisites (All Free Tier)
- Cloudflare account – workers.dev subdomain, free tier includes 100k req/day
- Groq API key – sign up at console.groq.com, free tier: 30 req/min, 14,400 req/day
- GitHub account – obviously
- Node.js 18+ – for Wrangler CLI
- Wrangler CLI –
npm install -g wrangler
Step 1: Scaffold a Cloudflare Worker
mkdir gh-issue-triager
cd gh-issue-triager
npm create cloudflare@latest . -- --type simple
Accept defaults. This gives you a bare src/index.js and wrangler.toml.
wrangler.toml (replace with your values):
name = "gh-issue-triager"
main = "src/index.js"
compatibility_date = "2024-12-01"
[vars]
GROQ_API_KEY = "gsk_your_key_here"
GITHUB_APP_ID = "123456"
GITHUB_APP_PRIVATE_KEY = """-----BEGIN RSA PRIVATE KEY-----
...
-----END RSA PRIVATE KEY-----"""
GITHUB_WEBHOOK_SECRET = "your_webhook_secret"
Important: Never commit secrets. Use wrangler secret put in production, but for this guide we’ll use vars for simplicity.
Step 2: Register a GitHub App and Get Credentials
- Go to Settings > Developer settings > GitHub Apps > New GitHub App
- Fill in:
- Name:
Issue Triager (dev) - Homepage URL: your repo URL
- Webhook URL:
https://gh-issue-triager.your-subdomain.workers.dev/webhook - Webhook secret: generate a random string, save it
- Name:
- Permissions:
- Issues: Read & Write
- Metadata: Read-only (auto-selected)
- Subscribe to events: Issues
- Where can this app be installed? Only on this account (for now)
- Create the app, then generate a private key (.pem file)
- Note the App ID at the top
- Install the app on your test repository
Step 3: Implement the Webhook Handler
src/index.js:
import { verify } from '@octokit/webhooks-methods';
export default {
async fetch(request, env) {
if (request.method !== 'POST') {
return new Response('Method not allowed', { status: 405 });
}
const signature = request.headers.get('x-hub-signature-256');
const payload = await request.text();
// Verify webhook signature
const isValid = await verify(env.GITHUB_WEBHOOK_SECRET, payload, signature);
if (!isValid) {
return new Response('Invalid signature', { status: 401 });
}
const event = request.headers.get('x-github-event');
const body = JSON.parse(payload);
// Only process newly opened issues
if (event === 'issues' && body.action === 'opened') {
// Fire and forget – we respond 200 immediately
const ctx = new ExecutionContext();
ctx.waitUntil(handleIssue(body, env));
}
return new Response('OK', { status: 200 });
},
};
Why fire-and-forget: GitHub expects a response within 10 seconds. Groq is fast, but network + GitHub API calls can push it. We acknowledge the webhook immediately and process asynchronously via waitUntil.
Step 4: Call Groq for Issue Classification
Add this function to src/index.js:
async function classifyIssue(title, body, env) {
const prompt = `Classify this GitHub issue into exactly one category: bug, feature, question, documentation.
Respond with ONLY the category name, nothing else.
Title: ${title}
Body: ${body.substring(0, 1000)}
Category:`;
const response = await fetch('https://api.groq.com/openai/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${env.GROQ_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'mixtral-8x7b-32768',
messages: [{ role: 'user', content: prompt }],
temperature: 0.1,
max_tokens: 10,
stop: ['\n'],
}),
});
const data = await response.json();
const category = data.choices[0].message.content.trim().toLowerCase();
// Validate output
const validCategories = ['bug', 'feature', 'question', 'documentation'];
return validCategories.includes(category) ? category : 'question';
}
Why Mixtral: It’s the best free model on Groq for instruction-following. Temperature 0.1 keeps it deterministic. We truncate body to 1000 chars to stay under token limits.
Step 5: Apply Labels and Assignees via GitHub API
Add the handleIssue function:
async function handleIssue(payload, env) {
const { repository, issue } = payload;
const [owner, repo] = repository.full_name.split('/');
try {
const category = await classifyIssue(issue.title, issue.body, env);
// Get installation access token
const token = await getInstallationToken(payload.installation.id, env);
// Apply label
await applyLabel(owner, repo, issue.number, category, token);
// Assign if bug
if (category === 'bug') {
await assignIssue(owner, repo, issue.number, token);
}
console.log(`Issue #${issue.number} classified as ${category}`);
} catch (error) {
console.error('Error handling issue:', error);
}
}
async function getInstallationToken(installationId, env) {
const appToken = await getAppJwt(env);
const response = await fetch(
`https://api.github.com/app/installations/${installationId}/access_tokens`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${appToken}`,
'Accept': 'application/vnd.github.v3+json',
},
}
);
const data = await response.json();
return data.token;
}
async function getAppJwt(env) {
// Generate JWT for GitHub App authentication
const now = Math.floor(Date.now() / 1000);
const payload = {
iat: now - 60,
exp: now + 600,
iss: env.GITHUB_APP_ID,
};
const encoder = new TextEncoder();
const keyData = encoder.encode(env.GITHUB_APP_PRIVATE_KEY);
const key = await crypto.subtle.importKey(
'pkcs8',
pemToArrayBuffer(env.GITHUB_APP_PRIVATE_KEY),
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
false,
['sign']
);
const header = { alg: 'RS256', typ: 'JWT' };
const segments = [
btoa(JSON.stringify(header)),
btoa(JSON.stringify(payload)),
];
const signingInput = segments.join('.');
const signature = await crypto.subtle.sign(
'RSASSA-PKCS1-v1_5',
key,
encoder.encode(signingInput)
);
segments.push(btoa(String.fromCharCode(...new Uint8Array(signature))));
return segments.join('.');
}
function pemToArrayBuffer(pem) {
const b64 = pem
.replace('-----BEGIN RSA PRIVATE KEY-----', '')
.replace('-----END RSA PRIVATE KEY-----', '')
.replace(/\s/g, '');
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
async function applyLabel(owner, repo, issueNumber, label, token) {
// First ensure the label exists
const existingLabels = await fetch(
`https://api.github.com/repos/${owner}/${repo}/labels`,
{ headers: { Authorization: `token ${token}` } }
).then(r => r.json());
const labelExists = existingLabels.some(l => l.name === label);
if (!labelExists) {
// Create label with a default color
const colors = { bug: 'd73a4a', feature: 'a2eeef', question: 'd876e3', documentation: '0075ca' };
await fetch(`https://api.github.com/repos/${owner}/${repo}/labels`, {
method: 'POST',
headers: { Authorization: `token ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ name: label, color: colors[label] || 'ededed' }),
});
}
// Apply label to issue
await fetch(
`https://api.github.com/repos/${owner}/${repo}/issues/${issueNumber}/labels`,
{
method: 'POST',
headers: { Authorization: `token ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ labels: [label] }),
}
);
}
async function assignIssue(owner, repo, issueNumber, token) {
// Assign to a default user – configure this in env vars
const assignee = 'your-oncall-engineer'; // Replace or use env var
await fetch(
`https://api.github.com/repos/${owner}/${repo}/issues/${issueNumber}/assignees`,
{
method: 'POST',
headers: { Authorization: `token ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ assignees: [assignee] }),
}
);
}
Label colors: We auto-create labels with standard GitHub colors if they don’t exist. You can customize the palette.
Step 6: Deploy and Test End-to-End
# Install dependencies
npm install @octokit/webhooks-methods
# Deploy
wrangler deploy
# Tail logs to debug
wrangler tail
Testing:
- Go to your test repo
- Create a new issue: "App crashes when clicking Save button"
- Within 2 seconds, the
buglabel should appear, and your on-call engineer gets assigned - Try a feature request: "Add dark mode toggle" →
featurelabel
Verify in Groq Console: Check usage at console.groq.com – each classification uses ~50 tokens.
Sensible Extensions
- Route by team: Extend classification to
frontend,backend,infraand assign to different teams - Add priority: Use Groq to also output
P0throughP3based on keywords like "crash", "data loss" - Auto-close spam: Classify as
spamand close with a comment - Sentiment analysis: Flag angry users for priority response
- Slack notification: Post to a channel when a
bugis filed – use Cloudflare Workers AI free tier - Dashboard: Log classifications to Cloudflare D1 (free tier) for analytics
Common Pitfalls
- Webhook timeout: Always use
waitUntil– GitHub retries if you don’t respond in 10s - Rate limits: Groq free tier is 30 req/min. If you get bursts of issues, implement a queue or exponential backoff
- PEM formatting: The private key must include
\nnewlines in TOML. Use triple-quoted strings - Label already exists: GitHub returns 422 if you try to create a duplicate label. We check first, but add error handling for race conditions
- Mixtral hallucination: Occasionally outputs "Category:" prefix. Our validation catches it and defaults to
question - Installation token expiry: Tokens expire after 1 hour. Since we generate fresh per webhook, this isn’t an issue
FAQ
Q: Can I use this on a public repo with many issues? A: Yes. Groq’s free tier handles ~14k issues/day. For larger volumes, batch process or upgrade to Groq’s paid tier ($0.27/1M tokens).
Q: What if Groq is down? A: The worker will throw, and GitHub will retry the webhook up to 3 times. Consider adding a fallback to Cloudflare Workers AI with Llama 3.
Q: Can I customize the classification categories?
A: Absolutely. Edit the prompt and validCategories array. The model handles arbitrary labels well.
Q: Is the private key secure in wrangler.toml?
A: No. Use wrangler secret put GITHUB_APP_PRIVATE_KEY for production. We used vars for tutorial simplicity.
Q: How do I add more complex routing logic?
A: Replace the simple classifyIssue prompt with a structured output approach using JSON mode – available on Groq’s paid tier, or parse the free response carefully.
Q: Can I run this on a schedule instead of webhooks? A: Yes, use Cloudflare Cron Triggers to poll GitHub issues API periodically – useful for repos where you can’t install apps.
Next steps: Grab the complete source from our GitHub template repo and have this running in 10 minutes. For production hardening, read our guide on securing Cloudflare Workers.
Want to build like a Forward Deployed Engineer?
FDE Coach is a cohort-based program in frontend, backend, AWS, and AI. Build real products and get referred to 200+ hiring partners.
Explore the program