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 sprintsissueFunction in linkedIssues('STORY-123')- Linked issuesstatus 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:
- Fetch all bugs (up to 1000)
- Group by assignee
- 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:
-
**Use GPT-3.5 from the start.**Gemini is overkill for parsing questions.
-
Add more query examples. I only gave 3. Should've given 10.
-
Build better error messages. When parsing fails, tell users what went wrong.
-
Add query history. Let users re-ask previous questions.
Resources That Helped
- JIRA REST API v3 Docs - Complete reference
- JIRA Agile API - For sprints
- JQL Reference - Essential
- OAuth 2.0 Guide - For auth
- OpenAI API Docs - ForGemini
Final Thoughts
Building this chatbot taught me:
-
Don't hardcode queries. Use AI to parse intent, build queries dynamically.
-
JQL is powerful. Once you learn it, you can query anything.
-
OAuth is easier than custom auth. Let JIRA handle permissions.
-
Aggregation happens client-side. JIRA gives raw data. You process it.
-
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 chatbotbackend/src/jql_builder.py- JQL builderbackend/src/api/jira_routes.py- API endpoints
It's not perfect. But it works. And that's what matters.