๐ŸŽ‰ DevOps Interview Prep Bundle is live โ€” 1000+ Q&A across 20 topicsGet it โ†’
All Articles

Build an AI GitHub Issue Triage Bot with Claude API

Automatically label, prioritize, and route GitHub issues using Claude API. Save your team hours of manual triage every week with this Python bot.

DevOpsBoys5 min read
Share:Tweet

GitHub issue triage is repetitive work that eats engineering time. This bot reads new issues, classifies them (bug/feature/question/docs), assigns severity (P1/P2/P3), suggests which team should own it, and adds labels โ€” automatically.

What It Does

  • Reads new GitHub issues via webhook
  • Sends issue title + body to Claude API
  • Gets back: category, severity, team, suggested response
  • Applies labels via GitHub API
  • Posts a triage comment on the issue

Setup

bash
pip install anthropic PyGithub flask python-dotenv
bash
# .env
ANTHROPIC_API_KEY=sk-ant-...
GITHUB_TOKEN=ghp_...
GITHUB_REPO=owner/repo-name
WEBHOOK_SECRET=your-webhook-secret

The Triage Bot

python
#!/usr/bin/env python3
"""
AI GitHub Issue Triage Bot using Claude API
"""
 
import hmac
import hashlib
import json
import os
from flask import Flask, request, jsonify, abort
from anthropic import Anthropic
from github import Github
from dotenv import load_dotenv
 
load_dotenv()
 
app = Flask(__name__)
claude = Anthropic()
gh = Github(os.environ["GITHUB_TOKEN"])
repo = gh.get_repo(os.environ["GITHUB_REPO"])
 
TRIAGE_PROMPT = """You are an expert software engineer performing issue triage for a DevOps/Kubernetes project.
 
Analyze this GitHub issue and provide structured triage data.
 
Issue Title: {title}
Issue Body: {body}
Existing Labels: {labels}
 
Respond ONLY with valid JSON in this exact format:
{{
  "category": "bug|feature|question|documentation|enhancement|security",
  "severity": "P1|P2|P3|P4",
  "severity_reason": "one sentence explaining why",
  "component": "kubernetes|docker|terraform|ci-cd|aws|monitoring|other",
  "teams": ["platform", "sre", "backend", "frontend"],
  "labels_to_add": ["list", "of", "github", "label", "names"],
  "needs_more_info": true|false,
  "suggested_response": "A helpful first response to post on the issue (2-3 sentences, friendly tone)",
  "triage_notes": "Internal notes for the team (1-2 sentences)"
}}
 
Severity guide:
- P1: Production outage, security vulnerability, data loss risk
- P2: Major feature broken, significant user impact
- P3: Minor feature issue, workaround exists
- P4: Enhancement request, nice to have, cosmetic issues"""
 
 
def verify_webhook_signature(payload: bytes, signature: str) -> bool:
    """Verify GitHub webhook signature."""
    secret = os.environ.get("WEBHOOK_SECRET", "").encode()
    if not secret:
        return True  # Skip verification if no secret set
    
    expected = "sha256=" + hmac.new(secret, payload, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)
 
 
def triage_issue(title: str, body: str, existing_labels: list) -> dict:
    """Send issue to Claude for triage analysis."""
    prompt = TRIAGE_PROMPT.format(
        title=title,
        body=body[:3000],  # limit body to avoid token overflow
        labels=", ".join(existing_labels) if existing_labels else "none"
    )
    
    message = claude.messages.create(
        model="claude-haiku-4-5-20251001",  # fast and cheap for triage
        max_tokens=1000,
        messages=[{"role": "user", "content": prompt}]
    )
    
    response_text = message.content[0].text.strip()
    
    # Extract JSON
    start = response_text.find("{")
    end = response_text.rfind("}") + 1
    return json.loads(response_text[start:end])
 
 
def apply_triage(issue_number: int, triage: dict):
    """Apply triage results to GitHub issue."""
    issue = repo.get_issue(issue_number)
    
    # Add labels
    labels_to_add = triage.get("labels_to_add", [])
    severity_label = triage.get("severity", "P3").lower()
    category_label = triage.get("category", "question")
    
    all_labels = labels_to_add + [severity_label, category_label]
    
    for label_name in all_labels:
        try:
            label = repo.get_label(label_name)
            issue.add_to_labels(label)
        except Exception:
            # Label doesn't exist, skip
            pass
    
    # Post triage comment
    comment = format_triage_comment(triage)
    issue.create_comment(comment)
    
    # Assign to team if P1
    if triage.get("severity") == "P1":
        # Add critical/urgent label
        try:
            issue.add_to_labels(repo.get_label("urgent"))
        except Exception:
            pass
 
 
def format_triage_comment(triage: dict) -> str:
    severity_emoji = {
        "P1": "๐Ÿ”ด",
        "P2": "๐ŸŸ ", 
        "P3": "๐ŸŸก",
        "P4": "๐ŸŸข"
    }
    
    category_emoji = {
        "bug": "๐Ÿ›",
        "feature": "โœจ",
        "question": "โ“",
        "documentation": "๐Ÿ“š",
        "security": "๐Ÿ”’",
        "enhancement": "โšก"
    }
    
    severity = triage.get("severity", "P3")
    category = triage.get("category", "question")
    
    return f"""## ๐Ÿค– Automated Triage
 
{category_emoji.get(category, "๐Ÿ“‹")} **Category:** {category.capitalize()}  
{severity_emoji.get(severity, "๐ŸŸก")} **Severity:** {severity} โ€” {triage.get("severity_reason", "")}  
๐Ÿท๏ธ **Component:** {triage.get("component", "other")}  
๐Ÿ‘ฅ **Suggested Team:** {", ".join(triage.get("teams", ["platform"]))}
 
---
 
{triage.get("suggested_response", "Thank you for filing this issue! We will review it shortly.")}
 
{"โš ๏ธ **Note:** Additional information may be needed to reproduce/address this issue." if triage.get("needs_more_info") else ""}
 
---
*Triaged by AI ยท Review and override labels as needed*"""
 
 
@app.route("/webhook", methods=["POST"])
def github_webhook():
    # Verify signature
    signature = request.headers.get("X-Hub-Signature-256", "")
    if not verify_webhook_signature(request.data, signature):
        abort(401, "Invalid signature")
    
    event = request.headers.get("X-GitHub-Event")
    payload = request.json
    
    # Only handle issue opened events
    if event != "issues" or payload.get("action") != "opened":
        return jsonify({"status": "ignored"})
    
    issue = payload["issue"]
    issue_number = issue["number"]
    title = issue["title"]
    body = issue.get("body", "") or ""
    existing_labels = [l["name"] for l in issue.get("labels", [])]
    
    print(f"Triaging issue #{issue_number}: {title}")
    
    try:
        triage = triage_issue(title, body, existing_labels)
        apply_triage(issue_number, triage)
        print(f"Triage applied: {triage['severity']} {triage['category']}")
        return jsonify({"status": "triaged", "result": triage})
    except Exception as e:
        print(f"Triage failed: {e}")
        return jsonify({"status": "error", "message": str(e)}), 500
 
 
if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8080, debug=False)

Deploy on Kubernetes

yaml
# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: issue-triage-bot
  namespace: tools
spec:
  replicas: 1
  template:
    spec:
      containers:
        - name: bot
          image: ghcr.io/yourorg/issue-triage-bot:latest
          ports:
            - containerPort: 8080
          envFrom:
            - secretRef:
                name: triage-bot-secrets
          resources:
            requests:
              memory: "128Mi"
              cpu: "100m"
---
apiVersion: v1
kind: Service
metadata:
  name: issue-triage-bot
  namespace: tools
spec:
  selector:
    app: issue-triage-bot
  ports:
    - port: 80
      targetPort: 8080

Set Up the GitHub Webhook

  1. Go to your repo โ†’ Settings โ†’ Webhooks โ†’ Add webhook
  2. Payload URL: https://your-bot.domain.com/webhook
  3. Content type: application/json
  4. Secret: same as WEBHOOK_SECRET
  5. Events: select "Issues" only

GitHub Actions Version (No Server Needed)

If you don't want to run a server, use GitHub Actions instead:

yaml
# .github/workflows/triage-issues.yml
name: AI Issue Triage
 
on:
  issues:
    types: [opened]
 
jobs:
  triage:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.12'
      
      - name: Install dependencies
        run: pip install anthropic PyGithub
      
      - name: Run triage
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          ISSUE_NUMBER: ${{ github.event.issue.number }}
          ISSUE_TITLE: ${{ github.event.issue.title }}
          ISSUE_BODY: ${{ github.event.issue.body }}
          GITHUB_REPO: ${{ github.repository }}
        run: python triage_action.py

The GitHub Actions version runs per-issue with no server needed โ€” perfect for smaller repositories.

Cost estimate: Using claude-haiku-4-5-20251001, each issue triage costs ~$0.001. For 1000 issues/month, that's $1. Extremely affordable.

Anthropic Console | PyGithub docs

๐Ÿ”ง

Today I Fixed

Short real fixes from production โ€” posted daily

Browse fixes
Newsletter

Stay ahead of the curve

Get the latest DevOps, Kubernetes, AWS, and AI/ML guides delivered straight to your inbox. No spam โ€” just practical engineering content.

Related Articles

Comments