Building a JIRA Chatbot: The AI-Powered Way

People kept asking me JIRA questions. Every. Single. Day.

  • "How many bugs did we close last week?"
  • "Which stories have blockers?"
  • "What's our velocity trend?"

I'd stop what I was doing, write a JQL query, send results. Repeat 10 times daily.

I thought: there has to be a better way. So I built a chatbot that could answer these questions automatically.

Turns out, it was way harder than I expected.

The Problem

My day looked like this:

10:15 AM - PM asks: "How many high-priority bugs in ProjectX?" I write: project = 'PROJX' AND type = Bug AND priority = High Send results.

10:47 AM - Same PM: "What about critical bugs?" Write another query...

11:23 AM - Dev lead: "Who has the most open issues?" Write aggregation logic...

2:15 PM - Product owner: "Show me velocity over last 4 sprints" Pull sprint data, calculate points...

It never stopped. And I couldn't scale myself.

My First (Bad) Solution

I tried hardcoding common queries:

def get_high_priority_bugs():
    return execute_jql("type = Bug AND priority = High")

def get_closed_issues_last_week():
    return execute_jql("status = Closed AND resolved >= -7d")

def get_velocity_last_sprint():
    # ... complicated sprint logic ...
    pass

This worked for exactly 5 questions. Then people asked:

  • "Show me bugs reported by external users in last 3 days"
  • "Which developer has the most overdue tasks?"
  • "What's the trend of P1 issues over 2 months?"

I can't write a function for every possible question. There are infinite variations.

The Breakthrough

The solution hit me while reading about ChatGPT: I don't need to predict questions. I need to translate them.

Here's what I built:

User asks: "How many bugs were closed last week?"
    ↓
Gemini  understands: count query, Bug type, Closed status, -7 days
    ↓
Build JQL: "type = Bug AND status = Closed AND resolved >= -7d"
    ↓
Call JIRA API
    ↓
Return: "Found 23 bugs closed last week"

It's like having a translator between English and JQL.

Understanding JIRA's APIs (The Boring But Necessary Part)

Before building the chatbot, I spent a week with JIRA's API documentation.

OAuth 2.0: The Enterprise Authentication

Our company uses SSO. API tokens don't work with SSO. Had to use OAuth 2.0.

This confused me because every tutorial uses API tokens. But OAuth is better for chatbots:

  • Users log in with their own credentials
  • Chatbot only sees what they can see
  • Permissions handled automatically by JIRA

Setting up OAuth:

# Step 1: Redirect user to JIRA login
authorization_url = (
    f"https://auth.atlassian.com/authorize?"
    f"audience=api.atlassian.com&"
    f"client_id={CLIENT_ID}&"
    f"scope=read:jira-work write:jira-work&"
    f"redirect_uri={REDIRECT_URI}&"
    f"response_type=code"
)

# Step 2: JIRA redirects back with code
# Step 3: Exchange code for token
token_response = requests.post(
    "https://auth.atlassian.com/oauth/token",
    json={
        "grant_type": "authorization_code",
        "client_id": CLIENT_ID,
        "client_secret": CLIENT_SECRET,
        "code": authorization_code,
        "redirect_uri": REDIRECT_URI
    }
)

access_token = token_response.json()['access_token']

Key point: The access token is scoped to the logged-in user. Perfect.

The Search API: Does 90% of the Work

After trying various endpoints, most questions use just one:

POST /rest/api/3/search

It takes JQL queries, returns issues. Simple.

response = requests.post(
    f"{jira_url}/rest/api/3/search",
    headers={"Authorization": f"Bearer {access_token}"},
    json={
        "jql": "project = 'PROJX' AND type = Bug",
        "maxResults": 100,
        "fields": ["summary", "status", "assignee"]
    }
)

issues = response.json()['issues']

JQL: The Weird Query Language

JQL (JIRA Query Language) is like SQL but... different. The syntax rules are inconsistent.

Examples that confused me:

# Emails need quotes, types don't
assignee = "john@company.com" AND type IN (Bug, Story)

# currentUser() has NO quotes
assignee = currentUser() AND priority = High

# Date magic
created >= -7d AND duedate <= endOfWeek()

# Text search
text ~ "authentication"

# Linked issues
issue in linkedIssues('STORY-123')

Took me hours of trial and error to learn these rules.

The Agile API: For Velocity Questions

Sprint-related questions need a different API:

GET /rest/agile/1.0/sprint/{sprintId}/issue

# Get done issues in sprint
response = requests.get(
    f"{jira_url}/rest/agile/1.0/sprint/{sprint_id}/issue?jql=status=Done",
    headers={"Authorization": f"Bearer {access_token}"}
)

# Sum story points
total_points = sum(
    issue['fields'].get('customfield_10016', 0) or 0
    for issue in response.json()['issues']
)

Gotcha: Story Points are a custom field. The field ID (customfield_10016) varies by JIRA instance.

To find yours:

GET /rest/api/3/field
# Search response for "Story Points"

Building the Chatbot

Now the fun part: converting English to JQL.

Step 1: UsingGemini to Parse Questions

I useGemini to understand what users are asking.

import openai
import json

def parse_question(question, user_context):
    """AskGemini to parse the user's question"""
    
    prompt = f"""
Convert this JIRA question to structured parameters.

Question: "{question}"

Context:
- User: {user_context['user_email']}
- Projects: {user_context['projects']}

Return JSON with:
- query_type: "count" | "list" | "aggregate" | "trend"
- filters: project, type, status, priority, assignee, date_range
- group_by: field name (for aggregations)
- chart_type: "bar" | "line" | "pie" (if needed)

Examples:

Q: "How many bugs closed last week?"
{{"query_type": "count", "filters": {{"type": "Bug", "status": "Closed", "date_range": "resolved >= -7d"}}}}

Q: "Who has the most open bugs?"
{{"query_type": "aggregate", "filters": {{"type": "Bug", "status": "Open"}}, "group_by": "assignee"}}

Parse this question. Return only JSON.
"""
    
    response = openai.ChatCompletion.create(
        model="gpt-4",
        messages=[
            {"role": "system", "content": "You parse JIRA questions. Return only JSON."},
            {"role": "user", "content": prompt}
        ],
        temperature=0
    )
    
    return json.loads(response.choices[0].message.content)

Why this works: -Gemini understands "last week" means -7d

  • It knows "my tasks" means currentUser()
  • It maps "high-priority" to priority = High

I gave it 3 examples in the prompt. More didn't help.

Step 2: Building JQL Dynamically

Once I have structured params, building JQL is straightforward:

def build_jql(filters):
    """Convert filters dict to JQL string"""
    conditions = []
    
    if filters.get('project'):
        conditions.append(f"project = '{filters['project']}'")
    
    if filters.get('type'):
        types = filters['type'] if isinstance(filters['type'], list) else [filters['type']]
        conditions.append(f"type IN ({', '.join(types)})")
    
    if filters.get('status'):
        conditions.append(f"status = '{filters['status']}'")
    
    if filters.get('priority'):
        conditions.append(f"priority = {filters['priority']}")
    
    if filters.get('assignee'):
        if filters['assignee'] == "currentUser()":
            conditions.append("assignee = currentUser()")
        else:
            conditions.append(f"assignee = '{filters['assignee']}'")
    
    if filters.get('date_range'):
        conditions.append(filters['date_range'])
    
    if filters.get('text_search'):
        conditions.append(f"text ~ '{filters['text_search']}'")
    
    return " AND ".join(conditions)

Step 3: Handling Different Query Types

Not all questions are simple searches. I built handlers for each type:

Type 1: Count Query

def handle_count(filters):
    jql = build_jql(filters)
    
    response = requests.post(
        f"{jira_url}/rest/api/3/search",
        headers={"Authorization": f"Bearer {token}"},
        json={"jql": jql, "maxResults": 0}  # Just get count
    )
    
    return {
        "answer": f"Found {response.json()['total']} issues",
        "count": response.json()['total']
    }

Type 2: List Query

def handle_list(filters):
    jql = build_jql(filters)
    
    response = requests.post(
        f"{jira_url}/rest/api/3/search",
        headers={"Authorization": f"Bearer {token}"},
        json={"jql": jql, "maxResults": 10}
    )
    
    issues = response.json()['issues']
    
    # Format nicely
    lines = []
    for issue in issues:
        lines.append(
            f"• **{issue['key']}**: {issue['fields']['summary']}\n"
            f"  Status: {issue['fields']['status']['name']}"
        )
    
    return {
        "answer": "\n\n".join(lines),
        "issues": issues
    }

Type 3: Aggregation Query

def handle_aggregate(filters, group_by):
    jql = build_jql(filters)
    
    response = requests.post(
        f"{jira_url}/rest/api/3/search",
        headers={"Authorization": f"Bearer {token}"},
        json={"jql": jql, "maxResults": 1000, "fields": [group_by]}
    )
    
    # Count by field
    counts = {}
    for issue in response.json()['issues']:
        field = issue['fields'].get(group_by)
        
        if group_by == 'assignee' and field:
            key = field.get('displayName', 'Unassigned')
        else:
            key = str(field) if field else 'None'
        
        counts[key] = counts.get(key, 0) + 1
    
    # Sort
    sorted_counts = sorted(counts.items(), key=lambda x: x[1], reverse=True)
    
    # Format
    lines = [f"• **{name}**: {count} issues" for name, count in sorted_counts[:5]]
    
    return {
        "answer": "\n".join(lines),
        "data": dict(sorted_counts)
    }

Type 4: Velocity Trend

def handle_velocity(board_id, num_sprints=4):
    # Get recent sprints
    sprints_resp = requests.get(
        f"{jira_url}/rest/agile/1.0/board/{board_id}/sprint?state=closed",
        headers={"Authorization": f"Bearer {token}"}
    )
    
    sprints = sprints_resp.json()['values'][-num_sprints:]
    
    velocity_data = []
    for sprint in sprints:
        # Get done issues
        issues_resp = requests.get(
            f"{jira_url}/rest/agile/1.0/sprint/{sprint['id']}/issue?jql=status=Done",
            headers={"Authorization": f"Bearer {token}"}
        )
        
        # Sum story points
        points = sum(
            issue['fields'].get('customfield_10016', 0) or 0
            for issue in issues_resp.json()['issues']
        )
        
        velocity_data.append({
            "sprint": sprint['name'],
            "points": points
        })
    
    # Format
    lines = [f"• {item['sprint']}: **{item['points']}** points" for item in velocity_data]
    
    return {
        "answer": "**Velocity:**\n\n" + "\n".join(lines),
        "chart_data": velocity_data
    }

Step 4: Putting It Together

The complete chatbot:

class JIRAChatbot:
    def __init__(self, jira_url, user_token, user_context):
        self.jira_url = jira_url
        self.token = user_token
        self.context = user_context
    
    def answer(self, question):
        """Answer any JIRA question"""
        
        try:
            # Parse withGemini
            parsed = parse_question(question, self.context)
            
            # Execute based on type
            if parsed['query_type'] == 'count':
                result = handle_count(parsed['filters'])
            elif parsed['query_type'] == 'list':
                result = handle_list(parsed['filters'])
            elif parsed['query_type'] == 'aggregate':
                result = handle_aggregate(parsed['filters'], parsed['group_by'])
            elif parsed['query_type'] == 'trend':
                result = handle_velocity(parsed['filters'].get('board_id'))
            
            return {
                "success": True,
                "answer": result['answer'],
                "data": result
            }
            
        except Exception as e:
            return {
                "success": False,
                "error": str(e)
            }

The API Endpoints

I exposed this as REST APIs:

from fastapi import FastAPI, Depends
from pydantic import BaseModel

app = FastAPI()

class QuestionRequest(BaseModel):
    question: str

@app.post("/jira/ask")
async def ask_question(
    request: QuestionRequest,
    user_token: str = Depends(get_user_token)
):
    """
    Answer any JIRA question
    
    Example:
    POST /jira/ask
    {"question": "How many bugs closed last week?"}
    """
    
    user_context = {
        "user_email": get_user_email(user_token),
        "projects": get_user_projects(user_token)
    }
    
    chatbot = JIRAChatbot(
        jira_url=settings.JIRA_URL,
        user_token=user_token,
        user_context=user_context
    )
    
    return chatbot.answer(request.question)

The Frontend

Simple React chat interface:

function JIRAChatbot() {
  const [messages, setMessages] = useState([]);
  const [input, setInput] = useState('');

  const ask = async () => {
    // Add user message
    setMessages(prev => [...prev, { type: 'user', text: input }]);
    
    // Call API
    const response = await fetch('/jira/ask', {
      method: 'POST',
      body: JSON.stringify({ question: input })
    });
    
    const data = await response.json();
    
    // Add bot response
    setMessages(prev => [...prev, {
      type: 'bot',
      text: data.answer
    }]);
    
    setInput('');
  };

  return (
    <div>
      {messages.map((msg, i) => (
        <div key={i} className={msg.type}>
          {msg.text}
        </div>
      ))}
      
      <input 
        value={input}
        onChange={e => setInput(e.target.value)}
        onKeyPress={e => e.key === 'Enter' && ask()}
        placeholder="Ask about JIRA..."
      />
    </div>
  );
}

Real Examples

Here's how it handles real questions:

Example 1: Simple Count

User: "How many bugs were closed last week?"

GPT-4 parses:
{
  "query_type": "count",
  "filters": {
    "type": "Bug",
    "status": "Closed",
    "date_range": "resolved >= -7d"
  }
}

JQL built: type = Bug AND status = Closed AND resolved >= -7d

Answer: "Found 23 issues"

Example 2: User Aggregation

User: "Who has the most open bugs?"

GPT-4 parses:
{
  "query_type": "aggregate",
  "filters": {"type": "Bug", "status": "Open"},
  "group_by": "assignee"
}

Answer:
"• John Doe: 15 issues
 • Jane Smith: 12 issues
 • Mike Johnson: 8 issues"

Example 3: Velocity Trend

User: "Show our velocity over last 4 sprints"

GPT-4 parses:
{
  "query_type": "trend",
  "filters": {"num_sprints": 4}
}

Answer:
"Velocity:
 • Sprint 20: 28 points
 • Sprint 21: 34 points
 • Sprint 22: 31 points
 • Sprint 23: 36 points"

What I Learned

1.Gemini Is Really Good at This

The LLM consistently understood:

  • Time references: "last week", "past month", "this sprint"
  • User context: "my tasks", "assigned to John"
  • Comparisons: "most bugs", "highest priority"

It rarely made mistakes.

2. JQL Is More Powerful Than Expected

Once I learned the syntax, JQL handles complex stuff:

  • sprint in closedSprints() - All closed sprints
  • issueFunction in linkedIssues('STORY-123') - Linked issues
  • status changed to Done after startOfWeek() - Status changes

3. JIRA Doesn't Aggregate Data

JIRA returns raw data. You aggregate it yourself.

For "who has most bugs?", I have to:

  1. Fetch all bugs (up to 1000)
  2. Group by assignee
  3. Count myself

For large datasets, need pagination:

def fetch_all(jql):
    all_issues = []
    start_at = 0
    
    while True:
        resp = requests.post(
            f"{jira_url}/rest/api/3/search",
            json={"jql": jql, "startAt": start_at, "maxResults": 100}
        )
        
        data = resp.json()
        all_issues.extend(data['issues'])
        
        if len(data['issues']) < 100:
            break
        
        start_at += 100
    
    return all_issues

4. OAuth Is Great for Security

With OAuth 2.0, permissions are automatic:

  • Users only see their projects
  • JIRA enforces access control
  • I don't write permission logic

If they ask about forbidden projects, JIRA returns 403. Done.

5. Caching Saves Money

Some queries don't change often. Cache results:

from functools import lru_cache

@lru_cache(maxsize=100)
def cached_query(jql, cache_time):
    return execute_jira_query(jql)

# Cache for 15 minutes
cache_key = datetime.now().replace(minute=datetime.now().minute // 15 * 15)
results = cached_query(jql, str(cache_key))

6. Show the JQL

I show the generated JQL to users. This:

  • Builds trust
  • Helps them learn JQL
  • Makes debugging easier

Mistakes I Made

Mistake 1: Not Handling Null Fields

JIRA fields are optional. My code crashed on null values.

# Bad
email = issue['fields']['assignee']['emailAddress']

# Good
assignee = issue['fields'].get('assignee')
email = assignee.get('emailAddress', 'Unassigned') if assignee else 'Unassigned'

Mistake 2: Assuming Story Points Field

Story Points is customfield_10016 in most JIRAs, but not all.

Fix: Discover it dynamically:

def find_story_points_field():
    resp = requests.get(f"{jira_url}/rest/api/3/field")
    for field in resp.json():
        if field['name'] == 'Story Points':
            return field['id']
    return None

Mistake 3: Too Long LLM Prompt

My first prompt was 500 lines with 20 examples. Didn't help.

Fix: Keep it under 200 lines with 3-5 examples.

Mistake 4: Ignoring Rate Limits

JIRA has rate limits. Hit them during testing.

Fix: Retry with backoff:

def execute_with_retry(func, max_tries=3):
    for attempt in range(max_tries):
        try:
            return func()
        except requests.HTTPError as e:
            if e.response.status_code == 429:  # Rate limited
                time.sleep(2 ** attempt)  # Exponential backoff
            else:
                raise

Performance & Costs

API Response Times:

  • Simple query: ~200ms
  • Aggregation (1000 issues): ~1-2 seconds
  • Velocity (5 sprints): ~3-5 seconds

LLM Costs: -Gemini: ~$0.03 per question

  • GPT-3.5: ~$0.002 per question (works 95% as well)

For 1000 questions/day: -Gemini: ~$30/month

  • GPT-3.5: ~$2/month

I use GPT-3.5 in production. It's cheaper and good enough.

Is It Worth It?

For us: Hell yes.

Before:

  • 10+ JIRA questions per day
  • 5-10 minutes each to answer
  • 1-2 hours daily spent on JIRA queries

After:

  • Chatbot answers instantly
  • Users self-serve
  • I saved 2 hours per day

What Would I Do Differently?

If I started over:

  1. **Use GPT-3.5 from the start.**Gemini is overkill for parsing questions.

  2. Add more query examples. I only gave 3. Should've given 10.

  3. Build better error messages. When parsing fails, tell users what went wrong.

  4. Add query history. Let users re-ask previous questions.

Resources That Helped

Final Thoughts

Building this chatbot taught me:

  1. Don't hardcode queries. Use AI to parse intent, build queries dynamically.

  2. JQL is powerful. Once you learn it, you can query anything.

  3. OAuth is easier than custom auth. Let JIRA handle permissions.

  4. Aggregation happens client-side. JIRA gives raw data. You process it.

  5. Transparency builds trust. Show the JQL query.

The hardest part wasn't the JIRA API. It was parsing natural language. Once I figured out theGemini prompt, everything clicked.

Now anyone on our team can ask:

  • "What's our bug trend over 3 months?"
  • "Which epic has most unresolved issues?"
  • "Show all auth-related tasks"

Instant answers. No more manual JQL.

Would I build this again? Absolutely.

For anyone building JIRA integrations: Learn JQL first, use GPT for parsing, keep it simple. Everything else is just plumbing.

Code Reference

The actual implementation:

  • backend/src/jira_chatbot.py - Main chatbot
  • backend/src/jql_builder.py - JQL builder
  • backend/src/api/jira_routes.py - API endpoints

It's not perfect. But it works. And that's what matters.