Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 34 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,25 @@ THINGS_TAGS=['jira', 'work']
JIRA_TYPE_TAG=true
```

### Status Mapping
### Sprint Deadlines

Set task deadlines based on the end dates of Jira sprints. Only active and future sprints are considered.

```ini
JIRA_SPRINT_DEADLINES=true
```

### Status Mapping / Scheduling

Tickets can be scheduled using one of two scheduling modes: `status` or `sprint`.

#### Status Mode

In status mode, tickets are scheduled based soley on their Jira status. This is the default mode.

```ini
SCHEDULING_MODE=status
```

Configure how JIRA statuses map to Things scheduling:

Expand All @@ -134,9 +152,22 @@ ANYTIME_STATUS=['To Do', 'Open', 'New']
COMPLETED_STATUS=['Done', 'Closed', 'Resolved']
```

## Status Mapping Logic
#### Sprint Mode

```ini
SCHEDULING_MODE=sprint
```

In `sprint` mode, tickets are scheduled based on their sprint and status:

- Tickets in an active sprint are scheduled to "Anytime"
- Tickets in past/future sprints are scheduled to "Someday"
- Any tickets with a status in `TODAY_STATUS` are scheduled to "Today"
- Tickets with a status in `COMPLETED_STATUS` are marked as completed in Things

## Status / Scheduling Logic

The app maps JIRA ticket statuses to Things 3 scheduling areas:
The app maps JIRA ticket statuses or sprints to Things 3 scheduling areas:

- **Today**: Urgent/active work (appears in Today view)
- **Anytime**: Ready to work on (appears in Anytime list)
Expand Down
27 changes: 22 additions & 5 deletions config.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,13 @@
JIRA_BASE_URL=https://your-company.atlassian.net
JIRA_API_TOKEN=your_jira_api_token_here
JIRA_USER_EMAIL=your.email@company.com

# JQL Query to select Jira issues for syncing
JIRA_JQL_QUERY=assignee = currentUser() AND updated >= -14d

# If using Sprints, you may want to modify the query to ensure all items in the current sprint are included:
# JIRA_JQL_QUERY=assignee = currentUser() AND (updated >= -14d or Sprint IN openSprints())

# Things 3 Configuration - Required for Things sync
THINGS_AUTH_TOKEN=your_things_auth_token_here

Expand All @@ -15,15 +20,27 @@ THINGS_TAGS=["jira", "work"]
# Make sure these tags exist in Things first!
JIRA_TYPE_TAG=true

# Set task deadlines based on the end date of active/future Jira sprints
JIRA_SPRINT_DEADLINES=true

# Status Mapping - Configure how JIRA statuses map to Things scheduling

# Tickets with these statuses go to "Today" in Things
TODAY_STATUS=["In Progress", "Active", "Doing"]

# Tickets with these statuses go to "Anytime" in Things (default for unlisted statuses)
# Tickets with these statuses are marked as completed in Things
COMPLETED_STATUS=["Done", "Closed", "Resolved", "Completed"]

# Set the scheduling mode for Things tasks
#
# When set to "status", the lists below determine how tasks are scheduled
# between "Anytime" and "Someday". When set to "sprint", tasks in the active
# sprint are set to "Anytime" and all other tasks are set to "Someday".
SCHEDULING_MODE=status

# In "status" mode, tickets with these statuses go to "Anytime" in Things
# (default for unlisted statuses)
ANYTIME_STATUS=["To Do", "Open", "Ready", "Dev Ready"]

# Tickets with these statuses go to "Someday" in Things
# In "status" mode, tickets with these statuses go to "Someday" in Things
SOMEDAY_STATUS=["Backlog", "Future", "Product Backlog", "Icebox"]

# Tickets with these statuses are marked as completed in Things
COMPLETED_STATUS=["Done", "Closed", "Resolved", "Completed"]
79 changes: 54 additions & 25 deletions database.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import sqlite3
import logging
from contextlib import contextmanager
from datetime import datetime
from typing import Dict, List, Optional
from dataclasses import dataclass

Expand All @@ -13,12 +14,15 @@ class JiraTicket:
has_subtasks: bool
status: str
issue_type: str = None
sprint_name: Optional[str] = None
sprint_status: Optional[str] = None
sprint_end_time: Optional[str] = None
things_id: str = None
last_updated: str = None

class DatabaseManager:
"""Manages SQLite database operations for JIRA tickets."""

def __init__(self, db_path: str, jira_base_url: str):
self.db_path = db_path
self.jira_base_url = jira_base_url.rstrip('/') # Remove trailing slash if present
Expand Down Expand Up @@ -49,12 +53,27 @@ def _init_db(self) -> None:
has_subtasks BOOLEAN NOT NULL,
status TEXT,
issue_type TEXT,
sprint_name TEXT,
sprint_status TEXT,
sprint_end_time TIMESTAMP,
things_id TEXT,
added_to_db TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
synced_to_things TEXT DEFAULT 'not synced' CHECK(synced_to_things IN ('synced', 'not synced', 'unknown')),
last_updated TIMESTAMP
)
''')

# We don't have a full schema migration system, so this just attempts to add
# any new columns from after the 1.0 release and skips if they already exist.
for field in ['sprint_name TEXT', 'sprint_status TEXT', 'sprint_end_time TEXT']:
try:
cursor.execute(f"ALTER TABLE jira_tickets ADD COLUMN {field};")
except sqlite3.OperationalError as e:
if "duplicate column name" not in str(e):
raise

logging.debug(f"Column '{field}' already exists, skipping addition")

conn.commit()
logging.info("Database initialization complete")

Expand All @@ -66,24 +85,29 @@ def save_ticket(self, ticket: JiraTicket) -> None:
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT summary, description, status, issue_type, things_id, synced_to_things
SELECT summary, description, status, issue_type, sprint_name, sprint_status, sprint_end_time, things_id, synced_to_things
FROM jira_tickets WHERE ticket_id = ?
''', (ticket.ticket_id,))
row = cursor.fetchone()
things_id = ticket.things_id

if row:
# Ticket exists - check for content changes
existing_summary, existing_description, existing_status, existing_issue_type, existing_things_id, existing_synced = row
existing_summary, existing_description, existing_status, existing_issue_type, existing_sprint_name, existing_sprint_status, existing_sprint_end_time, existing_things_id, existing_synced = row
if not things_id:
things_id = existing_things_id

# Compare all relevant fields for changes
has_changes = (existing_summary != ticket.summary or
existing_description != ticket.description or
existing_status != ticket.status or
existing_issue_type != ticket.issue_type)

has_changes = (
existing_summary != ticket.summary or
existing_description != ticket.description or
existing_status != ticket.status or
existing_issue_type != ticket.issue_type or
existing_sprint_name != ticket.sprint_name or
existing_sprint_status != ticket.sprint_status or
existing_sprint_end_time != ticket.sprint_end_time
)

if not has_changes:
# No changes detected - exit early without DB writes
logging.debug(f"No changes detected for ticket {ticket.ticket_id}, preserving sync status")
Expand All @@ -92,41 +116,46 @@ def save_ticket(self, ticket: JiraTicket) -> None:
# Changes detected - update ticket and mark as unsynced
logging.info(f"Changes detected for ticket {ticket.ticket_id}, marking as not synced")
cursor.execute('''
UPDATE jira_tickets
SET summary = ?, description = ?, has_subtasks = ?, status = ?,
issue_type = ?, things_id = ?, synced_to_things = ?, last_updated = CURRENT_TIMESTAMP
UPDATE jira_tickets
SET summary = ?, description = ?, has_subtasks = ?, status = ?,
issue_type = ?, sprint_name = ?, sprint_status = ?, sprint_end_time = ?,
things_id = ?, synced_to_things = ?, last_updated = CURRENT_TIMESTAMP
WHERE ticket_id = ?
''', (ticket.summary, ticket.description, ticket.has_subtasks, ticket.status,
ticket.issue_type, things_id, 'not synced', ticket.ticket_id))
''', (ticket.summary, ticket.description, ticket.has_subtasks, ticket.status,
ticket.issue_type, ticket.sprint_name, ticket.sprint_status, ticket.sprint_end_time,
things_id, 'not synced', ticket.ticket_id))
else:
# New ticket - insert with current timestamps
logging.debug(f"Inserting new ticket {ticket.ticket_id}")
cursor.execute('''
INSERT INTO jira_tickets
(ticket_id, summary, description, has_subtasks, status, issue_type,
INSERT INTO jira_tickets
(ticket_id, summary, description, has_subtasks, status, issue_type,
sprint_name, sprint_status, sprint_end_time,
things_id, synced_to_things, added_to_db, last_updated)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
''', (ticket.ticket_id, ticket.summary, ticket.description, ticket.has_subtasks,
ticket.status, ticket.issue_type, things_id, 'not synced'))

VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
''', (ticket.ticket_id, ticket.summary, ticket.description, ticket.has_subtasks,
ticket.status, ticket.issue_type, ticket.sprint_name, ticket.sprint_status,
ticket.sprint_end_time, things_id, 'not synced'))

conn.commit()

def get_all_tickets(self) -> List[JiraTicket]:
"""Retrieve all tickets from the database."""
with self.get_connection() as conn:
cursor = conn.cursor()
logging.debug("Retrieving all tickets from database")
cursor.execute('SELECT ticket_id, summary, description, has_subtasks, status, issue_type, things_id, last_updated FROM jira_tickets')
cursor.execute('SELECT ticket_id, summary, description, has_subtasks, status, issue_type, sprint_name, sprint_status, sprint_end_time, things_id, last_updated FROM jira_tickets')
return [JiraTicket(*row) for row in cursor.fetchall()]

def get_unsynced_tickets(self) -> List[JiraTicket]:
"""Get tickets that haven't been synced to Things (status = 'not synced')."""
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT ticket_id, summary, description, has_subtasks, status,
issue_type, things_id, last_updated
FROM jira_tickets
SELECT ticket_id, summary, description, has_subtasks, status,
issue_type, sprint_name, sprint_status, sprint_end_time,
things_id, last_updated
FROM jira_tickets
WHERE synced_to_things = 'not synced'
''')
return [JiraTicket(*row) for row in cursor.fetchall()]
Expand All @@ -135,8 +164,8 @@ def get_ticket_by_id(self, ticket_id: str) -> Optional[JiraTicket]:
"""Retrieve a specific ticket by its ID. Returns None if not found."""
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute('SELECT ticket_id, summary, description, has_subtasks, status, issue_type, things_id, last_updated FROM jira_tickets WHERE ticket_id = ?', (ticket_id,))
cursor.execute('SELECT ticket_id, summary, description, has_subtasks, status, issue_type, sprint_name, sprint_status, sprint_end_time, things_id, last_updated FROM jira_tickets WHERE ticket_id = ?', (ticket_id,))
row = cursor.fetchone()
if row:
return JiraTicket(*row)
return None
return None
37 changes: 34 additions & 3 deletions jira_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,29 @@ def get_issues(self) -> List[JiraTicket]:
jql_query = jql_query.replace('currentUser()', f'"{self.config.user_email}"')

logging.debug(f"Using JQL query: {jql_query}")


try:
fields = self.jira.fields()

# find sprint field if it exists
sprint_field = next((field for field in fields if field.get('schema', {}).get('custom', '') == 'com.pyxis.greenhopper.jira:gh-sprint'), None)
except Exception as e:
logging.error(f"Error fetching fields from JIRA: {str(e)}")
raise

fields = [
'summary',
'description',
'subtasks',
'status',
'issuetype',
sprint_field['id'] if sprint_field else None
]

try:
issues = self.jira.search_issues(
jql_query,
fields='summary,description,subtasks,status,issuetype',
fields=','.join(filter(None, fields)),
maxResults=100 # Consider making this configurable
)

Expand All @@ -73,13 +91,26 @@ def get_issues(self) -> List[JiraTicket]:
issue_type = getattr(issue.fields, 'issuetype', None)
issue_type_name = issue_type.name if issue_type else ''

sprints = getattr(issue.fields, sprint_field['id'], []) if sprint_field else []
latest_sprint = sorted(
sprints,
key=lambda s: s.endDate if hasattr(s, 'endDate') else datetime.min.isoformat(),
reverse=True
)[0] if sprints else None
sprint_name = getattr(latest_sprint, 'name', None)
sprint_status = getattr(latest_sprint, 'state', None)
sprint_end_time = getattr(latest_sprint, 'endDate', '').replace('Z', '+00:00')

ticket = JiraTicket(
ticket_id=issue.key,
summary=summary,
description=description,
has_subtasks=len(subtasks) > 0,
status=status_name,
issue_type=issue_type_name
issue_type=issue_type_name,
sprint_name=sprint_name,
sprint_status=sprint_status,
sprint_end_time=sprint_end_time,
)
logging.debug(f"Processing issue {ticket.ticket_id}: {ticket.summary}")
tickets.append(ticket)
Expand Down
Loading