From e56f6153dc09e1493330c737a65d8d0143bf0ae3 Mon Sep 17 00:00:00 2001 From: Reid Beels Date: Wed, 2 Jul 2025 15:24:54 -0700 Subject: [PATCH] Add support for using Jira sprints to schedule and set deadlines This adds basic support for Jira sprints: - The database is updated to add sprint_name, sprint_status, and sprint_end_date - If JIRA_SPRINT_DEADLINES=true is set in the config, the end date of a ticket's sprint will be used to set its deadline in Things - If SCHEDULING_MODE=sprint is set in the config, tasks in the active sprint will be set to `Anytime` and those in past/future/no sprint will be set to `Someday` --- README.md | 37 +++++++++++++++++++++-- config.example | 27 +++++++++++++---- database.py | 79 ++++++++++++++++++++++++++++++++++---------------- jira_client.py | 37 +++++++++++++++++++++-- main.py | 45 +++++++++++++++++++++------- 5 files changed, 178 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 5ad07ed..c7b6715 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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) diff --git a/config.example b/config.example index 80e5d4a..b36a3c2 100644 --- a/config.example +++ b/config.example @@ -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 @@ -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"] \ No newline at end of file diff --git a/database.py b/database.py index d20186a..8b2a00e 100644 --- a/database.py +++ b/database.py @@ -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 @@ -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 @@ -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") @@ -66,7 +85,7 @@ 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() @@ -74,16 +93,21 @@ def save_ticket(self, ticket: JiraTicket) -> None: 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") @@ -92,23 +116,27 @@ 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]: @@ -116,7 +144,7 @@ def get_all_tickets(self) -> List[JiraTicket]: 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]: @@ -124,9 +152,10 @@ def get_unsynced_tickets(self) -> List[JiraTicket]: 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()] @@ -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 \ No newline at end of file + return None diff --git a/jira_client.py b/jira_client.py index bef0898..2cf6370 100644 --- a/jira_client.py +++ b/jira_client.py @@ -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 ) @@ -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) diff --git a/main.py b/main.py index 0f124b7..f9ac6f3 100644 --- a/main.py +++ b/main.py @@ -2,6 +2,7 @@ import sys import argparse import logging +from datetime import datetime from pathlib import Path # Check if running in virtual environment @@ -150,11 +151,16 @@ def update_db(db_manager: DatabaseManager, jira_client: JiraClient, config: dict if existing: # Check if ticket content has changed - has_changes = (existing.summary != issue.summary or - existing.description != issue.description or - existing.status != issue.status or - existing.issue_type != issue.issue_type) - + has_changes = ( + existing.summary != issue.summary or + existing.description != issue.description or + existing.status != issue.status or + existing.issue_type != issue.issue_type or + existing.sprint_name != issue.sprint_name or + existing.sprint_status != issue.sprint_status or + existing.sprint_end_time != issue.sprint_end_time + ) + if has_changes: logging.info(f"Updating ticket {issue.ticket_id}: {issue.summary}") updated_count += 1 @@ -210,12 +216,20 @@ def _build_things_task_data(ticket: JiraTicket, config: dict, today_status: set, type_tag_enabled = config.get('JIRA_TYPE_TAG', 'false').lower() == 'true' if type_tag_enabled and ticket.issue_type: tags.append(ticket.issue_type.lower()) - + + # Add sprint deadline if enabled + end_date = '' + sprint_deadlines_enabled = config.get('JIRA_SPRINT_DEADLINES', 'false').lower() == 'true' + if sprint_deadlines_enabled and ticket.sprint_end_time and ticket.sprint_status != 'closed': + end_time = datetime.fromisoformat(ticket.sprint_end_time) + end_date = end_time.strftime('%Y-%m-%d') + # Build kwargs for Things API kwargs = { 'title': title, 'notes': notes, - 'tags': tags if tags else None + 'tags': tags if tags else None, + 'deadline': end_date, } # Set project if specified @@ -223,14 +237,23 @@ def _build_things_task_data(ticket: JiraTicket, config: dict, today_status: set, if project: kwargs['list_str'] = project + scheduling_mode = config.get('SCHEDULING_MODE', 'status').lower() + # Set scheduling based on ticket status if ticket.status in today_status: kwargs['when'] = 'today' - elif ticket.status in someday_status: - kwargs['when'] = 'someday' else: - kwargs['when'] = 'anytime' - + if scheduling_mode == 'status': + if ticket.status in someday_status: + kwargs['when'] = 'someday' + else: + kwargs['when'] = 'anytime' + elif scheduling_mode == 'sprint': + if ticket.sprint_status == 'active': + kwargs['when'] = 'anytime' + else: + kwargs['when'] = 'someday' + # Mark as completed if needed if ticket.status in completed_status: kwargs['completed'] = True