From 6ec0e1ffb91548132a71b79644cafa0ce6303928 Mon Sep 17 00:00:00 2001 From: Reid Beels Date: Thu, 3 Jul 2025 10:12:59 -0700 Subject: [PATCH 1/2] Refactor JiraClient.get_issues to take a JQL query argument --- config.py | 15 +++++++-------- jira_client.py | 7 +++---- main.py | 4 +++- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/config.py b/config.py index 1dcb571..fc86f08 100644 --- a/config.py +++ b/config.py @@ -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: @@ -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 diff --git a/jira_client.py b/jira_client.py index bef0898..65e0a1d 100644 --- a/jira_client.py +++ b/jira_client.py @@ -38,10 +38,9 @@ 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) -> 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}"') diff --git a/main.py b/main.py index 0f124b7..f8bd04b 100644 --- a/main.py +++ b/main.py @@ -129,9 +129,11 @@ def update_db(db_manager: DatabaseManager, jira_client: JiraClient, config: dict """Update database with tickets from JIRA.""" completed_status = parse_status_set(config, 'COMPLETED_STATUS') + jql_query = config.get("JIRA_JQL_QUERY", "project = DEMO AND status != Done ORDER BY created DESC") + # Fetch all matching issues from JIRA logging.info("Fetching issues from JIRA...") - issues = jira_client.get_issues() + issues = jira_client.get_issues(jql_query) logging.debug(f"Retrieved {len(issues)} issues from JIRA") if not issues: From 73e457631307a885ea4cf6e76b3a7e1b6ec23f5a Mon Sep 17 00:00:00 2001 From: Reid Beels Date: Thu, 3 Jul 2025 11:05:27 -0700 Subject: [PATCH 2/2] Add support for multiple JQL queries mapped to separate Things projects --- README.md | 30 +++++++ config.example | 32 ++++++- database.py | 73 +++++++++------- jira_client.py | 26 +++--- main.py | 222 ++++++++++++++++++++++++++----------------------- 5 files changed, 237 insertions(+), 146 deletions(-) diff --git a/README.md b/README.md index 5ad07ed..d21077e 100644 --- a/README.md +++ b/README.md @@ -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__` and `JIRA_JQL_QUERY__` 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: diff --git a/config.example b/config.example index 80e5d4a..df71c78 100644 --- a/config.example +++ b/config.example @@ -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"] \ No newline at end of file +COMPLETED_STATUS=["Done", "Closed", "Resolved", "Completed"] + +# Optional: Configure additional queries to map to different Things projects +# +# Additional projects are defined by adding THINGS_PROJECT__ and JIRA_JQL_QUERY__ +# +# 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() diff --git a/database.py b/database.py index d20186a..fead7cd 100644 --- a/database.py +++ b/database.py @@ -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 @@ -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") @@ -92,23 +107,23 @@ 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]: @@ -116,7 +131,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, things_id, things_project, last_updated FROM jira_tickets') return [JiraTicket(*row) for row in cursor.fetchall()] def get_unsynced_tickets(self) -> List[JiraTicket]: @@ -124,9 +139,9 @@ 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, things_id, things_project, last_updated + FROM jira_tickets WHERE synced_to_things = 'not synced' ''') return [JiraTicket(*row) for row in cursor.fetchall()] @@ -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 \ No newline at end of file + return None diff --git a/jira_client.py b/jira_client.py index 65e0a1d..41f724a 100644 --- a/jira_client.py +++ b/jira_client.py @@ -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 @@ -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() @@ -38,34 +38,34 @@ def _verify_connection(self): logging.error(f"Failed to connect to JIRA: {str(e)}") raise - def get_issues(self, jql_query) -> List[JiraTicket]: + 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 '' @@ -82,9 +82,9 @@ def get_issues(self, jql_query) -> 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 \ No newline at end of file + raise diff --git a/main.py b/main.py index f8bd04b..f405bc3 100644 --- a/main.py +++ b/main.py @@ -8,7 +8,7 @@ def check_virtual_environment(): """Check if script is running in the expected virtual environment.""" venv_path = Path(__file__).parent / ".venv" - + # Check if we're in any virtual environment if not hasattr(sys, 'real_prefix') and not (hasattr(sys, 'base_prefix') and sys.base_prefix != sys.prefix): if venv_path.exists(): @@ -73,51 +73,51 @@ def parse_status_set(config, key): def main(): args = parse_args() setup_logging(args.verbose) - + try: # Load configuration files logging.info("Loading configuration...") jira_config = JiraConfig.from_file(args.config) db_config = DatabaseConfig() - + # Load extra config vars for Things integration from config import load_config_vars extra_config = load_config_vars(args.config) - + # Parse status sets for Things scheduling logic completed_status = parse_status_set(extra_config, 'COMPLETED_STATUS') today_status = parse_status_set(extra_config, 'TODAY_STATUS') anytime_status = parse_status_set(extra_config, 'ANYTIME_STATUS') someday_status = parse_status_set(extra_config, 'SOMEDAY_STATUS') - + logging.info(f"Using database path: {db_config.db_path}") - + # Initialize JIRA and database connections logging.info("Initializing JIRA and database clients...") jira_client = JiraClient(jira_config) db_manager = DatabaseManager(db_config.db_path, jira_config.base_url) - + # Execute requested operation if args.update_db: logging.info("Updating database with tickets from JIRA...") update_db(db_manager, jira_client, extra_config) return - + if args.sync_to_things: logging.info("Syncing unsynced tickets from database to Things...") sync_to_things(db_manager, extra_config, today_status, anytime_status, someday_status, completed_status) return - + if args.resync_to_things: logging.info("Resyncing all tickets in database to Things...") resync_to_things(db_manager, extra_config, today_status, anytime_status, someday_status, completed_status) return - + # Default behavior: full sync workflow logging.info("Running full sync: updating database and syncing to Things...") update_db(db_manager, jira_client, extra_config) sync_to_things(db_manager, extra_config, today_status, anytime_status, someday_status, completed_status) - + except ValueError as e: logging.error(f"Configuration error: {str(e)}") sys.exit(1) @@ -128,78 +128,100 @@ def main(): def update_db(db_manager: DatabaseManager, jira_client: JiraClient, config: dict): """Update database with tickets from JIRA.""" completed_status = parse_status_set(config, 'COMPLETED_STATUS') - - jql_query = config.get("JIRA_JQL_QUERY", "project = DEMO AND status != Done ORDER BY created DESC") # Fetch all matching issues from JIRA logging.info("Fetching issues from JIRA...") - issues = jira_client.get_issues(jql_query) - logging.debug(f"Retrieved {len(issues)} issues from JIRA") - - if not issues: + issues_by_query = [ + (project_name, jira_client.get_issues(jql_query)) + for project_name, jql_query in _project_queries(config) + ] + + total_issues = sum(len(issues) for _, issues in issues_by_query) + logging.debug(f"Retrieved {total_issues} issues from JIRA") + + if total_issues == 0: logging.info("No issues found to process") return - + # Initialize counters for summary reporting added_count = 0 updated_count = 0 unchanged_count = 0 - + # Process each issue from JIRA logging.info("Processing issues...") - for issue in issues: - existing = db_manager.get_ticket_by_id(issue.ticket_id) - - 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) - - if has_changes: - logging.info(f"Updating ticket {issue.ticket_id}: {issue.summary}") - updated_count += 1 - - # Handle status change from non-completed to completed - status_became_complete = (existing.status != issue.status and - existing.things_id and - existing.status not in completed_status and - issue.status in completed_status) - - if status_became_complete: - try: - auth_token = config.get('THINGS_AUTH_TOKEN') - if auth_token: - UpdateTask(auth_token=auth_token, task_id=existing.things_id, completed=True) - logging.info(f"Marked Things task complete for ticket {issue.ticket_id}") - else: - logging.warning(f"No auth token available to mark ticket {issue.ticket_id} complete in Things") - except Exception as e: - logging.error(f"Failed to mark Things task complete for ticket {issue.ticket_id}: {e}") + for project_name, issues in issues_by_query: + for issue in issues: + issue.things_project = project_name + + existing = db_manager.get_ticket_by_id(issue.ticket_id) + + 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 or + existing.things_project != issue.things_project) + + if has_changes: + logging.info(f"Updating ticket {issue.ticket_id}: {issue.summary}") + updated_count += 1 + + # Handle status change from non-completed to completed + status_became_complete = (existing.status != issue.status and + existing.things_id and + existing.status not in completed_status and + issue.status in completed_status) + + if status_became_complete: + try: + auth_token = config.get('THINGS_AUTH_TOKEN') + if auth_token: + UpdateTask(auth_token=auth_token, task_id=existing.things_id, completed=True) + logging.info(f"Marked Things task complete for ticket {issue.ticket_id}") + else: + logging.warning(f"No auth token available to mark ticket {issue.ticket_id} complete in Things") + except Exception as e: + logging.error(f"Failed to mark Things task complete for ticket {issue.ticket_id}: {e}") + else: + logging.debug(f"Found ticket {issue.ticket_id} but no update needed") + unchanged_count += 1 else: - logging.debug(f"Found ticket {issue.ticket_id} but no update needed") - unchanged_count += 1 - else: - logging.info(f"Adding new ticket {issue.ticket_id}: {issue.summary}") - added_count += 1 - - # Save ticket (will only update DB if changes detected) - db_manager.save_ticket(issue) - + logging.info(f"Adding new ticket {issue.ticket_id}: {issue.summary}") + added_count += 1 + + # Save ticket (will only update DB if changes detected) + db_manager.save_ticket(issue) + # Report processing summary total_processed = added_count + updated_count + unchanged_count logging.info(f"Database update complete: {added_count} tickets added, {updated_count} tickets updated, {unchanged_count} unchanged (Total processed: {total_processed})") +def _project_queries(config: dict): + queries = [ + # Build the main JQL query + (config.get('THINGS_PROJECT'), + config.get("JIRA_JQL_QUERY", "project = DEMO AND status != Done ORDER BY created DESC")) + ] + + for key, value in config.items(): + if key.startswith("JIRA_JQL_QUERY__"): + project_suffix = key[len("JIRA_JQL_QUERY__"):] + project_name = config.get(f"THINGS_PROJECT__{project_suffix}", project_suffix) + queries.append((project_name, value)) + + return queries + def _build_things_task_data(ticket: JiraTicket, config: dict, today_status: set, someday_status: set, completed_status: set, jira_base_url: str): """Build task data dictionary for Things integration.""" import ast - + # Basic task information title = f"[{ticket.ticket_id}] {ticket.summary}" jira_url = f"{jira_base_url}/browse/{ticket.ticket_id}" notes = f"{jira_url}\n\n{ticket.description}" - + # Handle tags configuration tags = [] if 'THINGS_TAGS' in config: @@ -207,24 +229,20 @@ def _build_things_task_data(ticket: JiraTicket, config: dict, today_status: set, tags = ast.literal_eval(config['THINGS_TAGS']) except Exception: tags = [config['THINGS_TAGS']] - + # Add issue type as tag if enabled 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()) - + # Build kwargs for Things API kwargs = { 'title': title, 'notes': notes, - 'tags': tags if tags else None + 'tags': tags if tags else None, + 'list_str': ticket.things_project } - - # Set project if specified - project = config.get('THINGS_PROJECT') - if project: - kwargs['list_str'] = project - + # Set scheduling based on ticket status if ticket.status in today_status: kwargs['when'] = 'today' @@ -232,45 +250,45 @@ def _build_things_task_data(ticket: JiraTicket, config: dict, today_status: set, kwargs['when'] = 'someday' else: kwargs['when'] = 'anytime' - + # Mark as completed if needed if ticket.status in completed_status: kwargs['completed'] = True - + return kwargs def sync_to_things(db_manager: DatabaseManager, config: dict, today_status: set, anytime_status: set, someday_status: set, completed_status: set): """Sync unsynced tickets from database to Things.""" unsynced = db_manager.get_unsynced_tickets() - + if not unsynced: logging.info("No unsynced tickets found") return - + logging.info(f"Found {len(unsynced)} unsynced tickets to process") - + # Separate tickets by whether they already exist in Things new_tickets = [t for t in unsynced if not t.things_id] update_tickets = [t for t in unsynced if t.things_id] - + logging.info(f"New tickets to add: {len(new_tickets)}, Existing tickets to update: {len(update_tickets)}") - + auth_token = config.get('THINGS_AUTH_TOKEN') - + # Initialize counters added_count = 0 updated_count = 0 failed_count = 0 - + # Process new tickets (AddTask) for ticket in new_tickets: kwargs = _build_things_task_data(ticket, config, today_status, someday_status, completed_status, db_manager.jira_base_url) - + try: # Add to Things using pyThings task = AddTask(**kwargs) things_id = getattr(task, 'x_things_id', None) - + # Mark as synced and store things_id with db_manager.get_connection() as conn: cursor = conn.cursor() @@ -286,27 +304,27 @@ def sync_to_things(db_manager: DatabaseManager, config: dict, today_status: set, conn.commit() logging.error(f"Failed to add {ticket.ticket_id} to Things: {e}") failed_count += 1 - + # Process existing tickets that need updates (UpdateTask) for ticket in update_tickets: kwargs = _build_things_task_data(ticket, config, today_status, someday_status, completed_status, db_manager.jira_base_url) - + # Add required parameters for UpdateTask kwargs.update({ 'task_id': ticket.things_id, 'auth_token': auth_token, 'reveal': False }) - + if not auth_token: logging.warning(f"No auth token available to update ticket {ticket.ticket_id} in Things") failed_count += 1 continue - + try: # Update in Things using pyThings UpdateTask(**kwargs) - + # Mark as synced with db_manager.get_connection() as conn: cursor = conn.cursor() @@ -322,7 +340,7 @@ def sync_to_things(db_manager: DatabaseManager, config: dict, today_status: set, conn.commit() logging.error(f"Failed to update {ticket.ticket_id} in Things: {e}") failed_count += 1 - + # Report processing summary total_processed = added_count + updated_count + failed_count logging.info(f"Things sync complete: {added_count} tickets added, {updated_count} tickets updated, {failed_count} failed (Total processed: {total_processed})") @@ -330,35 +348,35 @@ def sync_to_things(db_manager: DatabaseManager, config: dict, today_status: set, def resync_to_things(db_manager: DatabaseManager, config: dict, today_status: set, anytime_status: set, someday_status: set, completed_status: set): """Resync all tickets from database to Things regardless of sync status.""" all_tickets = db_manager.get_all_tickets() - + if not all_tickets: logging.info("No tickets found in database") return - + logging.info(f"Found {len(all_tickets)} tickets to resync") - + # Separate tickets by whether they already exist in Things new_tickets = [t for t in all_tickets if not t.things_id] update_tickets = [t for t in all_tickets if t.things_id] - + logging.info(f"New tickets to add: {len(new_tickets)}, Existing tickets to update: {len(update_tickets)}") - + auth_token = config.get('THINGS_AUTH_TOKEN') - + # Initialize counters added_count = 0 updated_count = 0 failed_count = 0 - + # Process new tickets (AddTask) for ticket in new_tickets: kwargs = _build_things_task_data(ticket, config, today_status, someday_status, completed_status, db_manager.jira_base_url) - + try: # Add to Things using pyThings task = AddTask(**kwargs) things_id = getattr(task, 'x_things_id', None) - + # Mark as synced and store things_id with db_manager.get_connection() as conn: cursor = conn.cursor() @@ -374,27 +392,27 @@ def resync_to_things(db_manager: DatabaseManager, config: dict, today_status: se conn.commit() logging.error(f"Failed to add {ticket.ticket_id} to Things: {e}") failed_count += 1 - + # Process existing tickets that need updates (UpdateTask) for ticket in update_tickets: kwargs = _build_things_task_data(ticket, config, today_status, someday_status, completed_status, db_manager.jira_base_url) - + # Add required parameters for UpdateTask kwargs.update({ 'task_id': ticket.things_id, 'auth_token': auth_token, 'reveal': False }) - + if not auth_token: logging.warning(f"No auth token available to update ticket {ticket.ticket_id} in Things") failed_count += 1 continue - + try: # Update in Things using pyThings UpdateTask(**kwargs) - + # Mark as synced with db_manager.get_connection() as conn: cursor = conn.cursor() @@ -410,10 +428,10 @@ def resync_to_things(db_manager: DatabaseManager, config: dict, today_status: se conn.commit() logging.error(f"Failed to update {ticket.ticket_id} in Things: {e}") failed_count += 1 - + # Report processing summary total_processed = added_count + updated_count + failed_count logging.info(f"Things resync complete: {added_count} tickets added, {updated_count} tickets updated, {failed_count} failed (Total processed: {total_processed})") if __name__ == "__main__": - main() \ No newline at end of file + main()