Build a GitHub Issue Triager That Auto-Labels and Routes to the Right Owner
What We're Building
We're building a GitHub Action that fires on every new issue. Instead of manually reading, labeling, and routing each one, a Groq-hosted Llama 3 model does it in under a second. The bot reads the issue body and title, classifies the type (bug, feature, docs, question), estimates priority (P0-P3), and suggests an assignee based on a CODEOWNERS-style mapping you define. It then updates the issue via the GitHub API—adding labels, setting priority, and assigning the right human.
Feature list:
- Triggered automatically on
issues: opened - Uses Groq's free-tier API with Llama 3 8B for near-instant classification
- Applies structured labels:
type/*,priority/* - Assigns the issue to a team member based on file paths or area keywords
- Posts a friendly bot comment summarizing the triage decision
- Entirely free to run on public repos (GitHub Actions free tier + Groq free tier)
Architecture Overview
Here's how the pieces fit together. No fluff—just the flow.
The runner spins up on issues: opened, executes our Node.js script, which calls Groq with a carefully engineered prompt. Groq returns structured JSON. The script then calls GitHub's REST API to apply labels and assign the issue. That's it. No databases, no queues, no infrastructure.
Prerequisites (All Free Tier)
Before writing a single line, grab these:
- GitHub Repository – public or private. Free Actions minutes: 2,000/month for private repos, unlimited for public.
- Groq API Key – Sign up at console.groq.com. Free tier gives you generous requests per minute on Llama 3 8B. Create an API key under "API Keys".
- GitHub Personal Access Token – Needed if you want the action to update issues (label, assign). Go to Settings > Developer settings > Personal access tokens > Fine-grained tokens. Give it read/write access to issues and metadata. For public repos, the built-in
GITHUB_TOKENworks but has limited permission scope; a PAT is more predictable. - Repository Secrets – Add
GROQ_API_KEYand (if using a PAT)GH_PATto your repo's Settings > Secrets and variables > Actions.
Step 1: Scaffold the GitHub Action
Create .github/workflows/triage.yml:
name: Issue Triage
on:
issues:
types: [opened]
jobs:
triage:
runs-on: ubuntu-latest
permissions:
issues: write
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm install @actions/core @actions/github node-fetch
- name: Run Triage
env:
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
GH_TOKEN: ${{ secrets.GH_PAT || secrets.GITHUB_TOKEN }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
ISSUE_TITLE: ${{ github.event.issue.title }}
ISSUE_BODY: ${{ github.event.issue.body }}
REPO_OWNER: ${{ github.repository_owner }}
REPO_NAME: ${{ github.event.repository.name }}
run: node triage.js
This is standard Actions boilerplate. The key detail: we pass the issue context as environment variables so the script stays clean and testable locally.
Step 2: Write the Core Triage Script
Create triage.js at the repo root. We'll build it in layers. Start with the skeleton:
const core = require('@actions/core');
const github = require('@actions/github');
async function run() {
try {
const issueNumber = process.env.ISSUE_NUMBER;
const title = process.env.ISSUE_TITLE;
const body = process.env.ISSUE_BODY || '';
const owner = process.env.REPO_OWNER;
const repo = process.env.REPO_NAME;
// 1. Classify via Groq
const classification = await classifyIssue(title, body);
core.info(`Classification: ${JSON.stringify(classification)}`);
// 2. Apply labels and assignee via GitHub API
await updateIssue(owner, repo, issueNumber, classification);
core.setOutput('result', 'success');
} catch (error) {
core.setFailed(error.message);
}
}
run();
This is the orchestration layer. Two functions do the heavy lifting: classifyIssue and updateIssue. Let's build each.
Step 3: The Groq Inference Module
Add the Groq call. We're using Llama 3 8B because it's fast (sub-second responses on Groq) and free. The prompt engineering is where the magic happens:
async function classifyIssue(title, body) {
const fetch = (await import('node-fetch')).default;
const apiKey = process.env.GROQ_API_KEY;
const prompt = `You are an expert issue triage bot. Analyze the following GitHub issue and return ONLY valid JSON (no markdown fences, no extra text).
Return format:
{
"type": "bug|feature|docs|question|other",
"priority": "P0|P1|P2|P3",
"assignee": "username or 'unassigned'",
"summary": "one-sentence summary of the issue"
}
Priority guidelines:
- P0: Critical, production down, security vulnerability
- P1: High impact, blocking work, needs attention this sprint
- P2: Medium impact, should fix soon
- P3: Low priority, nice-to-have, cosmetic
Assignee mapping (choose the best fit based on issue content):
- Frontend/UI/CSS/React/Vue/component issues → assignee: "frontend-lead"
- Backend/API/database/performance/server issues → assignee: "backend-lead"
- Documentation/README/typo → assignee: "docs-lead"
- DevOps/CI/CD/deployment/infrastructure → assignee: "devops-lead"
- If unclear, use "unassigned"
Issue Title: ${title}
Issue Body: ${body}`;
const response = await fetch('https://api.groq.com/openai/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'llama3-8b-8192',
messages: [{ role: 'user', content: prompt }],
temperature: 0.1,
max_tokens: 200
})
});
if (!response.ok) {
throw new Error(`Groq API error: ${response.status} ${await response.text()}`);
}
const data = await response.json();
const raw = data.choices[0].message.content.trim();
// Parse the JSON, handling occasional markdown fences
let json = raw;
if (json.startsWith('```')) {
json = json.replace(/```json?\n?/g, '').replace(/```/g, '').trim();
}
return JSON.parse(json);
}
Key decisions here: temperature: 0.1 keeps classifications deterministic. The prompt explicitly maps areas to assignees—you'll replace those usernames with your actual team's GitHub handles. The output parsing handles the case where Llama occasionally wraps JSON in markdown fences despite being told not to.
Step 4: Wire Everything Together
Now the GitHub API update function. It applies labels, sets assignee, and posts a comment:
async function updateIssue(owner, repo, issueNumber, classification) {
const octokit = github.getOctokit(process.env.GH_TOKEN);
// Apply type label
const typeLabel = `type:${classification.type}`;
const priorityLabel = `priority:${classification.priority}`;
// Ensure labels exist (create if they don't)
await ensureLabel(octokit, owner, repo, typeLabel, 'bfd4f2');
await ensureLabel(octokit, owner, repo, priorityLabel, getPriorityColor(classification.priority));
// Add labels to issue
await octokit.rest.issues.addLabels({
owner,
repo,
issue_number: issueNumber,
labels: [typeLabel, priorityLabel]
});
// Assign if we have a specific assignee and they're not "unassigned"
if (classification.assignee && classification.assignee !== 'unassigned') {
try {
await octokit.rest.issues.addAssignees({
owner,
repo,
issue_number: issueNumber,
assignees: [classification.assignee]
});
} catch (e) {
core.warning(`Could not assign ${classification.assignee}: ${e.message}`);
}
}
// Post triage comment
const comment = `🤖 **Automated Triage**\n\n` +
`- **Type:** \`${classification.type}\`\n` +
`- **Priority:** \`${classification.priority}\`\n` +
`- **Assignee:** @${classification.assignee}\n` +
`- **Summary:** ${classification.summary}\n\n` +
`_Triaged by Groq Llama 3. Adjust labels/assignee if needed._`;
await octokit.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body: comment
});
}
async function ensureLabel(octokit, owner, repo, name, color) {
try {
await octokit.rest.issues.getLabel({ owner, repo, name });
} catch {
await octokit.rest.issues.createLabel({ owner, repo, name, color });
}
}
function getPriorityColor(priority) {
const colors = { P0: 'd73a4a', P1: 'f9d0c4', P2: 'fef2c0', P3: 'c5def5' };
return colors[priority] || 'ededed';
}
The ensureLabel helper creates labels if they don't exist yet—so your repo doesn't need pre-configured labels. Priority colors follow GitHub's standard severity palette: red for P0, light red for P1, yellow for P2, blue for P3.
Full triage.js file combines all three functions under the run() orchestrator. Install dependencies with npm init -y && npm install @actions/core @actions/github node-fetch (or add a package.json).
Running and Testing the Action
Local testing before pushing:
# Set env vars manually
export GROQ_API_KEY="gsk_your_key_here"
export GH_TOKEN="github_pat_..."
export ISSUE_NUMBER="1"
export ISSUE_TITLE="Login button broken on mobile"
export ISSUE_BODY="When tapping the login button on iOS Safari, nothing happens. Expected: redirect to auth page."
export REPO_OWNER="your-username"
export REPO_NAME="your-repo"
node triage.js
If it works, you'll see the classification JSON logged and the issue updated on GitHub.
In production: Push to your default branch. Open a test issue. Within seconds, the action fires, Groq classifies, and labels appear. Check the Actions tab for logs if anything goes sideways.
Sensible Extensions
Once the basic triager works, here's where to take it:
- Slack/Discord notifications – Post to a channel when a P0 issue lands. Use the Slack incoming webhook action or a simple
fetchcall in the script. - Auto-close spam – If Groq classifies something as
type:spamor the content is empty/low-effort, auto-close with a canned response. - CODEOWNERS integration – Parse your actual
CODEOWNERSfile instead of hardcoding the mapping. Read the file in the action, extract path patterns, and include them in the Groq prompt. - Fine-tuning on your repo's history – Collect 100+ labeled issues from your repo, format them as prompt-completion pairs, and fine-tune a smaller model. This improves accuracy on your specific taxonomy.
- Multi-model fallback – If Groq is down or rate-limited, fall back to a free Hugging Face inference endpoint. Check out our guide on using free vision models on Hugging Face for patterns on routing across providers.
If you're thinking about building more AI-powered automation agents, this pattern—event trigger → LLM classification → API action—is the same one we use in our YouTube-to-blog repurposing agent. The architecture is identical; only the prompt and target API change.
Common Pitfalls and Fixes
"Groq API returned 401" – Your API key is wrong or missing. Double-check the secret name in your action YAML matches exactly (GROQ_API_KEY). Groq free-tier keys start with gsk_.
"Cannot read properties of undefined (reading 'content')" – Groq's response shape changed or the model returned empty. Add error handling: log data.choices before accessing [0]. Occasionally Llama returns an empty response on malformed prompts.
Labels not appearing – The GITHUB_TOKEN doesn't have issues: write permission. Either add permissions: issues: write to your workflow YAML or use a PAT with proper scopes.
Assignee not found – The username in your assignee mapping doesn't exist or isn't a repo collaborator. The script catches this and logs a warning rather than failing. Check the Actions log for the specific error.
Groq rate limiting – Free tier allows ~30 requests/minute on Llama 3 8B. For a modest repo, this is plenty. If you're getting 429s, add exponential backoff or queue issues for batch processing.
Model hallucinating assignees – If Llama invents usernames not in your mapping, tighten the prompt. Add: "Only assign to the exact usernames listed above. Never invent new usernames." Temperature 0.1 helps but isn't a guarantee.
FAQ
Q: Why Groq instead of OpenAI or Anthropic? A: Groq's free tier is genuinely free and fast enough for real-time triage (sub-500ms responses). No credit card required, no expiration. For a deeper dive into running LLMs efficiently at scale, check out our piece on Gemini 3.5 Flash Cyber for security-focused workloads—different domain, same principle of matching model speed to task latency requirements.
Q: Can this work on private repositories? A: Yes. GitHub Actions has 2,000 free minutes/month for private repos. The Groq API call is external, so your issue content leaves GitHub's boundary—consider that for sensitive codebases. For an on-prem alternative, you could self-host Llama 3 via Ollama, but that's no longer free-tier compute.
Q: How accurate is Llama 3 8B for issue classification? A: In our testing, ~85-90% accuracy on type and priority for well-written issues. It struggles with vague one-liners ("it's broken"). The more structured your issue templates, the better it performs. If you need higher accuracy, consider the personal finance categorizer approach which uses few-shot examples to dramatically improve classification consistency.
Q: What if I want to route based on files mentioned, not just text?
A: Extend the prompt to include the output of git diff or the issue's linked PRs. You can also parse the issue body for file paths and pass those to Groq. For a full file-change-aware monitoring setup, see our competitor monitoring agent guide.
Q: Is this really production-ready?
A: For small-to-medium teams, absolutely. The biggest risk is Groq API downtime. Add a try/catch that falls back to a default label (type:unclassified) and a manual triage request comment. For mission-critical repos, run this as a first-pass filter with human override—not as the final word.
Q: How do I become the engineer who builds systems like this in production, directly with customers? A: This pattern—wiring LLMs to real business workflows, shipping fast, iterating based on user feedback—is exactly what Forward Deployed Engineers do daily. If you're curious what that looks like week-to-week, our FDE time audit breaks down the actual hours. And if you're thinking longer-term, FDE to founder explains why this role is arguably the best preparation for building a startup.
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