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
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,36 @@ ANYTIME_STATUS=['To Do', 'Open', 'New']
COMPLETED_STATUS=['Done', 'Closed', 'Resolved']
```

### Multiple Projects

You can configure additional queries, each mapped to seperate Things projects

Additional projects are defined by adding `THINGS_PROJECT__<name>` and `JIRA_JQL_QUERY__<name>` entries to the config.

For example, to create separate projects for web and API tickets, you can add:

```ini
THINGS_PROJECT__API=API Tickets
JIRA_JQL_QUERY__API=assignee = currentUser() AND updated >= -14d AND project = API

THINGS_PROJECT__WEB=Web Tickets
JIRA_JQL_QUERY__WEB=assignee = currentUser() AND updated >= -14d AND project = WEB
```

Since there's a 1:1 mapping between Jira tickets and Things tasks, tickets can only be assigned to one project at a time. Because of this, these queries should be non-overlapping, both between each other and with the main JIRA_JQL_QUERY.

This can also be used to separate tickets in the active sprint from those in the backlog:

Configure the "main" project/query as the backlog and add a second project/query for the active sprint:

```ini
THINGS_PROJECT=Backlog
JIRA_JQL_QUERY=assignee = currentUser() AND updated >= -14d AND (Sprint IS null OR Sprint NOT IN openSprints())

THINGS_PROJECT__ACTIVE=Current Sprint
JIRA_JQL_QUERY__ACTIVE=assignee = currentUser() AND Sprint IN openSprints()
```

## Status Mapping Logic

The app maps JIRA ticket statuses to Things 3 scheduling areas:
Expand Down
32 changes: 30 additions & 2 deletions config.example
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,36 @@ TODAY_STATUS=["In Progress", "Active", "Doing"]
# 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
# 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"]
COMPLETED_STATUS=["Done", "Closed", "Resolved", "Completed"]

# Optional: Configure additional queries to map to different Things projects
#
# Additional projects are defined by adding THINGS_PROJECT__<name> and JIRA_JQL_QUERY__<name>
#
# For example, to create separate projects for web and API tickets, you can add:
#
# THINGS_PROJECT__API=API Tickets
# JIRA_JQL_QUERY__API=assignee = currentUser() AND updated >= -14d AND project = API
#
# THINGS_PROJECT__WEB=Web Tickets
# JIRA_JQL_QUERY__WEB=assignee = currentUser() AND updated >= -14d AND project = WEB
#
# Since there's a 1:1 mapping between Jira tickets and Things tasks, tickets
# can only be assigned to one project at a time. Because of this, these queries
# should be non-overlapping, both between each other and with the main JIRA_JQL_QUERY.
#
# This can also be used to separate tickets in the active sprint from those in the backlog:
#
# Configure the "main" project/query as the backlog:
#
# THINGS_PROJECT=Backlog
# JIRA_JQL_QUERY=assignee = currentUser() AND updated >= -14d AND (Sprint IS null OR Sprint NOT IN openSprints())
#
# Add a second project for the active sprint:
#
# THINGS_PROJECT__ACTIVE=Current Sprint
# JIRA_JQL_QUERY__ACTIVE=assignee = currentUser() AND Sprint IN openSprints()
15 changes: 7 additions & 8 deletions config.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,40 +4,39 @@

@dataclass
class JiraConfig:
"""Configuration for JIRA connection and queries."""
"""Configuration for JIRA connection."""
base_url: str
api_token: str
user_email: str
jql_query: str = "project = DEMO AND status != Done ORDER BY created DESC"

@classmethod
def from_file(cls, filename="config"):
"""Load JIRA configuration from file."""
config = load_config_vars(filename)

# Validate required fields
required_fields = ['JIRA_BASE_URL', 'JIRA_API_TOKEN', 'JIRA_USER_EMAIL']
missing_fields = [field for field in required_fields if field not in config]
if missing_fields:
raise ValueError(f"Missing required JIRA configuration fields: {', '.join(missing_fields)}")



return cls(
base_url=config["JIRA_BASE_URL"],
api_token=config["JIRA_API_TOKEN"],
user_email=config["JIRA_USER_EMAIL"],
jql_query=config.get("JIRA_JQL_QUERY", "project = DEMO AND status != Done ORDER BY created DESC")
)

@dataclass
class DatabaseConfig:
"""Configuration for SQLite database."""
db_path: str = "jira_tasks.db"
db_path: str = "jira_tasks.db"

def load_config_vars(filename="config"):
"""Load configuration variables from a key=value file."""
if not os.path.exists(filename):
raise FileNotFoundError(f"Configuration file '{filename}' not found")

config = {}
try:
with open(filename, "r") as f:
Expand All @@ -55,5 +54,5 @@ def load_config_vars(filename="config"):
print(f"Warning: Malformed line {line_num} in {filename}: {line}")
except Exception as e:
raise RuntimeError(f"Error reading configuration file '{filename}': {e}")

return config
73 changes: 44 additions & 29 deletions database.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,12 @@ class JiraTicket:
status: str
issue_type: str = None
things_id: str = None
things_project: Optional[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 @@ -50,40 +51,54 @@ def _init_db(self) -> None:
status TEXT,
issue_type TEXT,
things_id TEXT,
things_project 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 ['things_project 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")

def save_ticket(self, ticket: JiraTicket) -> None:
"""Save or update a ticket in the database.

Only updates timestamps and sync status when actual changes are detected.
"""
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, things_id, things_project, 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_things_id, existing_things_project, 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_things_project != ticket.things_project)

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 +107,41 @@ 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 = ?, things_id = ?, things_project = ?, 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, things_id, ticket.things_project, '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,
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'))
INSERT INTO jira_tickets
(ticket_id, summary, description, has_subtasks, status, issue_type,
things_id, things_project, 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, ticket.things_project, '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, things_id, things_project, 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, things_id, things_project, last_updated
FROM jira_tickets
WHERE synced_to_things = 'not synced'
''')
return [JiraTicket(*row) for row in cursor.fetchall()]
Expand All @@ -135,8 +150,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, things_id, things_project, last_updated FROM jira_tickets WHERE ticket_id = ?', (ticket_id,))
row = cursor.fetchone()
if row:
return JiraTicket(*row)
return None
return None
31 changes: 15 additions & 16 deletions jira_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@

class JiraClient:
"""Client for connecting to and retrieving data from JIRA."""

def __init__(self, config: JiraConfig = None):
if config is None:
config = JiraConfig.from_file()
self.config = config
self.base_url = config.base_url.rstrip('/')

logging.debug(f"JIRA Client Init - Server URL: {self.base_url}")
logging.debug(f"JIRA Client Init - User Email: {self.config.user_email}")
# Avoid logging token directly for security
Expand All @@ -21,7 +21,7 @@ def __init__(self, config: JiraConfig = None):
try:
self.jira = JIRA(
server=self.base_url,
basic_auth=(self.config.user_email, self.config.api_token)
basic_auth=(self.config.user_email, self.config.api_token)
)
logging.debug("Initialized JIRA client instance.")
self._verify_connection()
Expand All @@ -38,35 +38,34 @@ def _verify_connection(self):
logging.error(f"Failed to connect to JIRA: {str(e)}")
raise

def get_issues(self) -> List[JiraTicket]:
"""Retrieve issues from JIRA based on configured JQL query."""
jql_query = self.config.jql_query

def get_issues(self, jql_query: str) -> List[JiraTicket]:
"""Retrieve issues from JIRA based on given JQL query."""

# Replace currentUser() placeholder if present
if 'currentUser()' in jql_query and self.config.user_email:
jql_query = jql_query.replace('currentUser()', f'"{self.config.user_email}"')

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

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

total_issues = len(issues)
if total_issues == 0:
logging.warning(f"No issues found matching the JQL query: {jql_query}")
return []
else:
logging.info(f"Retrieved {total_issues} issues from JIRA")

tickets = []
for issue in issues:
# Safely extract field values with defaults
summary = getattr(issue.fields, 'summary', 'No Summary')
description = getattr(issue.fields, 'description', '') or ""
description = getattr(issue.fields, 'description', '') or ""
subtasks = getattr(issue.fields, 'subtasks', [])
status = getattr(issue.fields, 'status', None)
status_name = status.name if status else ''
Expand All @@ -83,9 +82,9 @@ def get_issues(self) -> List[JiraTicket]:
)
logging.debug(f"Processing issue {ticket.ticket_id}: {ticket.summary}")
tickets.append(ticket)

return tickets

except Exception as e:
logging.error(f"Error fetching issues from JIRA: {str(e)}")
raise
raise
Loading