Connecting JIRA to Our Task Manager: The Messy Reality
Our team uses JIRA for sprint planning but a custom task manager (Ritvik) for daily work. Everyone complained about copying tasks between systems. I volunteered to build an integration, thinking "how hard can it be?"
Turns out, pretty hard.
The Problem
Here's what was actually happening:
- Project manager creates JIRA stories during sprint planning
- Developers copy them manually into Ritvik (our daily task tracker)
- Updates happen in Ritvik, but JIRA gets stale
- End of sprint: manually update JIRA statuses for reporting
It was tedious. And people forgot to sync. So I decided to build an API bridge.
What I Learned About JIRA's API
JIRA has like 50+ different API endpoints. The documentation is comprehensive but overwhelming. I spent the first day just figuring out which APIs I actually needed.
The Only 3 Endpoints That Mattered
After trying various approaches, I ended up using just 3:
1. Test the connection:
GET /rest/api/3/myself
This just checks if my credentials work. Simple.
2. Get someone's issues:
POST /rest/api/3/search
Uses JQL (JIRA Query Language) to find issues. Example:
{
"jql": "assignee = currentUser() AND type IN (Epic, Story)",
"maxResults": 50
}
3. Get a single issue's details:
GET /rest/api/3/issue/PROJ-123
That's it. Everything else (comments, attachments, transitions) can wait for v2.
Getting API Credentials (The Annoying Part)
JIRA uses "API tokens" instead of passwords. Here's how to get one:
- Go to https://id.atlassian.com/manage-profile/security/api-tokens
- Click "Create API token"
- Give it a name like "Ritvik Integration"
- Copy the token immediately (you can't see it again)
The token looks like: ATATT3xFfGF0xxxxxxxxx
You also need:
- Your JIRA URL (e.g.,
https://divamilabs.atlassian.net) - Your email (the one you use to login to JIRA)
The Code (What Actually Worked)
Setting Up Authentication
JIRA uses HTTP Basic Auth, but instead of your password, you use the API token:
from requests.auth import HTTPBasicAuth
import requests
jira_url = "https://your-domain.atlassian.net"
email = "your@email.com"
api_token = "your-api-token"
# Test connection
response = requests.get(
f"{jira_url}/rest/api/3/myself",
auth=HTTPBasicAuth(email, api_token),
headers={"Accept": "application/json"}
)
if response.status_code == 200:
print(f"Connected as: {response.json()['displayName']}")
else:
print(f"Failed: {response.status_code}")
Fetching Someone's Issues
This was trickier than expected. JIRA returns A LOT of data. I had to figure out which fields I actually cared about:
def get_user_issues(user_email):
jql_query = f'assignee = "{user_email}" AND type IN (Epic, Story) ORDER BY created DESC'
response = requests.post(
f"{jira_url}/rest/api/3/search",
auth=HTTPBasicAuth(email, api_token),
headers={"Accept": "application/json", "Content-Type": "application/json"},
json={
"jql": jql_query,
"maxResults": 50,
"fields": ["summary", "status", "priority", "assignee", "description"]
}
)
return response.json()['issues']
The JQL syntax is weird. assignee = "{email}" must have quotes, but type IN (Epic, Story) doesn't. Took me an hour to figure that out.
The Import Function (Where It Got Complicated)
Importing a JIRA issue into our task manager was harder than fetching it. Here's why:
Problem 1: JIRA's Description Format
JIRA doesn't store descriptions as plain text or HTML. They use "Atlassian Document Format" (ADF), which is JSON:
{
"type": "doc",
"content": [
{
"type": "paragraph",
"content": [
{"type": "text", "text": "This is my description"}
]
}
]
}
I had to write a parser to extract plain text:
def parse_adf_to_text(adf):
if not adf or 'content' not in adf:
return ""
text_parts = []
for node in adf['content']:
if node['type'] == 'paragraph' and 'content' in node:
for item in node['content']:
if item.get('type') == 'text':
text_parts.append(item['text'])
return ' '.join(text_parts)
Problem 2: Priority Mapping
JIRA has 5 priority levels: Highest, High, Medium, Low, Lowest. Our task manager has 3: HIGH, MEDIUM, LOW.
I had to map them:
priority_map = {
'Highest': 'HIGH',
'High': 'HIGH',
'Medium': 'MEDIUM',
'Low': 'LOW',
'Lowest': 'LOW'
}
Problem 3: Status Mapping
JIRA statuses are customizable per project. One project has "To Do", another has "Backlog". I went with common patterns:
def map_jira_status(jira_status):
status_lower = jira_status.lower()
if status_lower in ['to do', 'open', 'backlog']:
return 'TO_DO'
elif status_lower in ['in progress', 'in review']:
return 'IN_PROGRESS'
elif status_lower in ['done', 'closed', 'resolved']:
return 'COMPLETED'
else:
return 'TO_DO' # Default
Problem 4: Time Estimates
JIRA stores time in seconds. "3 hours" is 10800. I needed human-readable text:
def seconds_to_readable(seconds):
if not seconds:
return None
hours = seconds // 3600
minutes = (seconds % 3600) // 60
if hours > 0:
return f"{hours}h {minutes}m" if minutes > 0 else f"{hours}h"
else:
return f"{minutes}m"
The Final Import Function
def import_jira_issue(issue_key, assignee_id, project_id):
# Fetch from JIRA
response = requests.get(
f"{jira_url}/rest/api/3/issue/{issue_key}",
auth=HTTPBasicAuth(email, api_token),
headers={"Accept": "application/json"}
)
if response.status_code != 200:
return {"error": "Issue not found"}
jira_issue = response.json()
fields = jira_issue['fields']
# Extract and transform data
task_data = {
"title": fields['summary'],
"description": parse_adf_to_text(fields.get('description')),
"priority": priority_map.get(fields['priority']['name'], 'MEDIUM'),
"status": map_jira_status(fields['status']['name']),
"original_estimate": seconds_to_readable(fields.get('timeoriginalestimate')),
"time_spent": seconds_to_readable(fields.get('timespent')),
"jira_issue_key": issue_key,
"jira_url": f"{jira_url}/browse/{issue_key}",
"assignee_id": assignee_id,
"project_id": project_id
}
# Save to database
# ... database code here ...
return {"success": True, "task_id": new_task.id}
What I Screwed Up
Mistake 1: Not Checking for Duplicates
My first version didn't check if an issue was already imported. People clicked "Import" twice and got duplicate tasks.
Fix: Added a database check before importing:
existing = db.query(Task).filter(Task.jira_issue_key == issue_key).first()
if existing:
return {"error": "Already imported"}
Mistake 2: Assuming All Fields Exist
JIRA fields are optional. My code crashed when timeoriginalestimate was null.
Fix: Used .get() everywhere:
# Bad
time_estimate = fields['timeoriginalestimate']
# Good
time_estimate = fields.get('timeoriginalestimate')
Mistake 3: Hardcoding the JIRA URL
I initially hardcoded our JIRA URL in the code. Then we realized other teams might use different JIRA instances.
Fix: Environment variables:
import os
JIRA_URL = os.getenv('JIRA_URL')
JIRA_EMAIL = os.getenv('JIRA_EMAIL')
JIRA_API_TOKEN = os.getenv('JIRA_API_TOKEN')
What I Didn't Build (Yet)
Two-way sync: Right now, updates in Ritvik don't flow back to JIRA. That's phase 2.
Comments: JIRA has comments. I'm not syncing them. Most of our discussions happen in Slack anyway.
Attachments: People attach screenshots to JIRA. I'm not importing those. File handling is a whole other problem.
Webhooks: JIRA can send real-time updates via webhooks. I'm not using them yet. The current "click to import" approach is good enough.
Performance Notes
Fetching 50 issues from JIRA takes about 2-3 seconds. Not instant, but acceptable.
The import process (fetch + save) takes about 1 second per issue. I didn't bother optimizing because we typically import 5-10 issues at a time.
JIRA has rate limits: 10,000 requests per hour. We're nowhere near that.
What Would I Do Differently?
If I started over, I'd:
- Read the JIRA docs more carefully first. I wasted time trying endpoints that didn't work.
- Test with curl before writing Python. Debugging Python is harder than debugging curl commands.
- Start with minimal fields. I initially tried to import EVERYTHING. That was overwhelming.
- Ask someone who's done JIRA integrations before. Would've saved days.
Is It Worth It?
For our team? Yes. We went from manually copying 20-30 tasks per sprint to clicking one button.
The integration isn't perfect. There's still manual work. But it's way better than before.
Setup Instructions (For Our Team)
If you're trying to set this up:
1. Get API credentials:
- Go to Atlassian account settings
- Generate API token
- Save it somewhere safe
2. Set environment variables:
export JIRA_URL="https://your-domain.atlassian.net"
export JIRA_EMAIL="your@email.com"
export JIRA_API_TOKEN="your-token"
3. Test the connection:
curl -u "your@email.com:your-token" \
"https://your-domain.atlassian.net/rest/api/3/myself"
If that returns your name, it's working.
4. Use the API endpoints:
Fetch your issues:
POST /jira/issues?user_email=your@email.com
Import an issue:
POST /jira/import
{
"jira_issue_key": "PROJ-123",
"assignee_id": "uuid",
"project_id": "uuid"
}
Things That Confused Me
JQL (JIRA Query Language): It's like SQL but weirder. Example: assignee = currentUser() AND type = Story. Took time to learn.
REST API v3 vs v2: JIRA has multiple API versions. I used v3 because it's newer. Some old tutorials use v2.
Custom fields: JIRA projects can have custom fields like "Story Points" or "Sprint". These are named customfield_10016 and you have to discover the numbers by making API calls. It's annoying.
Atlassian Document Format (ADF): Why couldn't they just use Markdown? Converting ADF to plain text was unnecessarily hard.
Resources That Actually Helped
- JIRA REST API v3 docs - Comprehensive but overwhelming
- JQL Reference - For building queries
- Stack Overflow - For "why is this failing?" questions
- Our backend codebase:
backend/src/jira_service.py- Where the actual code lives
Final Thoughts
Building this integration taught me:
- API documentation isn't always helpful. Sometimes you have to just try things.
- Data transformation is 80% of integration work. Fetching is easy. Mapping fields is hard.
- Start simple. I could've saved time by building a minimal version first.
- Error handling matters. My first version crashed on missing fields.
Would I use JIRA again? Not by choice. But if I have to, at least now I know how it works.
For anyone else building JIRA integrations: start with the 3 endpoints I mentioned, ignore everything else until you need it, and test with real data early.
Code Reference
The actual implementation is in our backend:
backend/src/jira_service.py- JIRA API wrapperbackend/src/main.py(lines 4459-4650) - API endpointsbackend/src/models.py- Database models
It's not perfect, but it works. And sometimes that's good enough.