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:

  1. Go to https://id.atlassian.com/manage-profile/security/api-tokens
  2. Click "Create API token"
  3. Give it a name like "Ritvik Integration"
  4. 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:

  1. Read the JIRA docs more carefully first. I wasted time trying endpoints that didn't work.
  2. Test with curl before writing Python. Debugging Python is harder than debugging curl commands.
  3. Start with minimal fields. I initially tried to import EVERYTHING. That was overwhelming.
  4. 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:

  1. API documentation isn't always helpful. Sometimes you have to just try things.
  2. Data transformation is 80% of integration work. Fetching is easy. Mapping fields is hard.
  3. Start simple. I could've saved time by building a minimal version first.
  4. 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 wrapper
  • backend/src/main.py (lines 4459-4650) - API endpoints
  • backend/src/models.py - Database models

It's not perfect, but it works. And sometimes that's good enough.