diff --git a/docs/maniphest-cli.md b/docs/maniphest-cli.md index ec480ab..819cf1d 100644 --- a/docs/maniphest-cli.md +++ b/docs/maniphest-cli.md @@ -413,6 +413,132 @@ phabfive maniphest search "My Project" --priority="in:Normal+not:lowered" **Note**: `not:been:PRIORITY` is functionally equivalent to `never:PRIORITY`. +## Status Filtering + +Filter tasks based on their status changes over time. This helps identify tasks that progressed through workflows, track status regressions, and analyze how task completion status evolved. + +### Why Use Status Filtering? + +Common use cases include: + +- **Track completions**: Find tasks that changed to "Resolved" +- **Identify regressions**: Tasks that moved backward from Resolved to Open +- **Find blocked work**: Tasks that are currently Blocked +- **Audit status history**: See complete status change history for tasks +- **Monitor workflow progression**: Find tasks that reached specific milestones + +### Status Pattern Types + +| Pattern | Description | Example | +|---------|-------------|---------| +| `from:STATUS` | Task changed from STATUS | `from:Open` | +| `from:STATUS:raised` | Task progressed from STATUS | `from:Open:raised` | +| `from:STATUS:lowered` | Task regressed from STATUS | `from:Resolved:lowered` | +| `to:STATUS` | Task changed to STATUS | `to:Resolved` | +| `in:STATUS` | Task is currently at STATUS | `in:Resolved` | +| `been:STATUS` | Task was at STATUS at any point | `been:Resolved` | +| `never:STATUS` | Task was never at STATUS | `never:Blocked` | +| `raised` | Task had any status progression | `raised` | +| `lowered` | Task had any status regression | `lowered` | +| `not:PATTERN` | Negates any pattern above | `not:in:Open`, `not:raised` | + +**Negation Prefix `not:`**: Any pattern can be prefixed with `not:` to negate its meaning. This is a general negation operator that works with all pattern types. For example: +- `not:in:Open` - Tasks NOT currently Open +- `not:raised` - Tasks whose status hasn't progressed +- `not:been:Resolved` - Tasks never been Resolved (equivalent to `never:Resolved`) + +### Status Values + +The tool dynamically fetches status information from your Phabricator/Phorge instance using the `maniphest.querystatuses` API. Standard Phabricator statuses include (in progression order): + +- **Open** (0) - Initial state for new tasks +- **Blocked** (1) - Task is blocked/waiting on something +- **Wontfix** (2) - Terminal: Won't be fixed +- **Invalid** (3) - Terminal: Invalid task +- **Duplicate** (4) - Terminal: Duplicate of another task +- **Resolved** (5) - Terminal: Task completed successfully + +**Open vs Closed**: Only Open and Blocked are "open" statuses. All others (Wontfix, Invalid, Duplicate, Resolved) are terminal/closed states. + +**Status Progression**: +- Moving from a lower number to a higher number is considered **"raised"** (forward progression) +- Moving from a higher number to a lower number is considered **"lowered"** (regression/reopening) +- For example: Open (0) → Resolved (5) is "raised" (task progressed forward) +- For example: Resolved (5) → Open (0) is "lowered" (task was reopened) + +**Note**: If your Phabricator/Phorge instance uses custom statuses, the tool will automatically adapt to your configuration. + +### Basic Status Examples + +```bash +# Find tasks currently Open +phabfive maniphest search "My Project" --status="in:Open" + +# Find tasks that were ever Resolved +phabfive maniphest search "My Project" --status="been:Resolved" + +# Find tasks that progressed from Open +phabfive maniphest search "My Project" --status="from:Open:raised" + +# Find tasks that had any status progression +phabfive maniphest search "My Project" --status=raised +``` + +### Combining Column, Priority, and Status Filters + +You can combine all three filter types for powerful queries: + +```bash +# Tasks moved to Done AND were raised from Open AND are currently Resolved +phabfive maniphest search '*' \ + --column='to:Done' \ + --priority='from:Normal:raised' \ + --status='in:Resolved' + +# Tasks in progress that have been blocked +phabfive maniphest search "My Project" \ + --column="in:In Progress" \ + --status="been:Blocked" + +# Recently completed tasks that were never blocked +phabfive maniphest search "My Project" \ + --status="to:Resolved" \ + --updated-after=7 \ + --status="never:Blocked" +``` + +### Status OR/AND Logic + +Same as column and priority patterns, status patterns support OR (comma) and AND (plus): + +```bash +# Tasks currently Open OR Blocked +phabfive maniphest search "My Project" --status="in:Open,in:Blocked" + +# Tasks raised from Open AND currently Resolved +phabfive maniphest search "My Project" --status="from:Open:raised+in:Resolved" +``` + +### Status Negation Patterns + +Use the `not:` prefix to negate status patterns: + +```bash +# Tasks NOT currently Open +phabfive maniphest search "My Project" --status="not:in:Open" + +# Tasks whose status has NOT progressed +phabfive maniphest search "My Project" --status="not:raised" + +# Tasks NOT Resolved AND have been Blocked at some point +phabfive maniphest search "My Project" --status="not:in:Resolved+been:Blocked" + +# Tasks that progressed but did NOT reach Resolved +phabfive maniphest search "My Project" --status="raised+not:in:Resolved" +``` + +**Note**: `not:been:STATUS` is functionally equivalent to `never:STATUS`. + ## Viewing Metadata Use `--show-metadata` to see why tasks matched your filters. This is especially useful when debugging complex filter combinations. @@ -421,6 +547,7 @@ Use `--show-metadata` to see why tasks matched your filters. This is especially phabfive maniphest search '*' \ --column='from:Up Next:forward' \ --priority='been:Normal' \ + --status='in:Resolved' \ --show-metadata ``` @@ -429,11 +556,13 @@ Output includes: Metadata: MatchedBoards: ['Development', 'GUNNAR-Core'] MatchedPriority: true + MatchedStatus: true ``` The metadata section shows: - **MatchedBoards**: Which boards satisfied the `--column` filter (in alphabetical order) - **MatchedPriority**: Whether the task matched the `--priority` filter +- **MatchedStatus**: Whether the task matched the `--status` filter This helps you understand exactly why a task appeared in your search results. diff --git a/phabfive/cli.py b/phabfive/cli.py index 32ec05b..5bd5369 100644 --- a/phabfive/cli.py +++ b/phabfive/cli.py @@ -155,8 +155,23 @@ from:Normal:raised not:in:High+raised in:High,been:Unbreak Now! - --show-history Display column and priority transition history for each task - --show-metadata Display filter match metadata (which boards/priority matched) + --status=PATTERNS Filter tasks by status transitions (comma=OR, plus=AND). + Automatically displays status history. + from:STATUS[:direction] - Changed from STATUS + to:STATUS - Changed to STATUS + in:STATUS - Currently at STATUS + been:STATUS - Was at STATUS at any point + never:STATUS - Never was at STATUS + raised - Status progressed forward + lowered - Status moved backward + not:PATTERN - Negates any pattern above + Examples: + been:Open + from:Open:raised + not:in:Resolved+raised + in:Open,been:Resolved + --show-history Display column, priority, and status transition history + --show-metadata Display filter match metadata (which boards/priority/status matched) Options: --all Show all fields for a ticket @@ -251,6 +266,7 @@ def run(cli_args, sub_args): from phabfive import passphrase, diffusion, paste, user, repl, maniphest from phabfive.maniphest_transitions import parse_transition_patterns from phabfive.priority_transitions import parse_priority_patterns + from phabfive.status_transitions import parse_status_patterns from phabfive.constants import REPO_STATUS_CHOICES from phabfive.exceptions import PhabfiveException @@ -400,6 +416,17 @@ def run(cli_args, sub_args): retcode = 1 return retcode + status_patterns = None + if sub_args.get("--status"): + try: + status_patterns = parse_status_patterns( + sub_args["--status"] + ) + except Exception as e: + print(f"ERROR: Invalid status filter pattern: {e}", file=sys.stderr) + retcode = 1 + return retcode + # Only show history if explicitly requested show_history = sub_args.get("--show-history", False) @@ -411,6 +438,7 @@ def run(cli_args, sub_args): updated_after=sub_args["--updated-after"], transition_patterns=transition_patterns, priority_patterns=priority_patterns, + status_patterns=status_patterns, show_history=show_history, show_metadata=show_metadata, ) diff --git a/phabfive/maniphest.py b/phabfive/maniphest.py index a7318e7..473e2d3 100644 --- a/phabfive/maniphest.py +++ b/phabfive/maniphest.py @@ -1,32 +1,34 @@ # -*- coding: utf-8 -*- # python std lib -from collections.abc import Mapping +import datetime import difflib import fnmatch -from functools import lru_cache import json import logging -from pathlib import Path import sys import time -import datetime +from collections.abc import Mapping +from functools import lru_cache +from pathlib import Path from typing import Optional +from jinja2 import Environment, Template, meta + +# 3rd party imports +from ruamel.yaml import YAML +from ruamel.yaml.scalarstring import PreservedScalarString + +from phabfive.constants import TICKET_PRIORITY_NORMAL + # phabfive imports from phabfive.core import Phabfive -from phabfive.constants import TICKET_PRIORITY_NORMAL from phabfive.exceptions import ( + PhabfiveDataException, PhabfiveException, PhabfiveRemoteException, - PhabfiveDataException, ) -# 3rd party imports -from ruamel.yaml import YAML -from ruamel.yaml.scalarstring import PreservedScalarString -from jinja2 import Template, Environment, meta - # phabfive transition imports - imported in cli.py where patterns are parsed log = logging.getLogger(__name__) @@ -159,6 +161,158 @@ def _fetch_project_names_for_boards(self, tasks_data): proj["phid"]: proj["fields"]["name"] for proj in projects_lookup["data"] } + def _fetch_all_transactions( + self, task_phid, need_columns=False, need_priority=False, need_status=False + ): + """ + Fetch all transaction types for a task in a single API call. + + This consolidates the transaction fetching to avoid redundant API calls + when multiple transaction types are needed (e.g., when using --columns, + --priority, and --status together). + + Uses the deprecated but functional maniphest.gettasktransactions API. + + Parameters + ---------- + task_phid : str + Task PHID (e.g., "PHID-TASK-...") + need_columns : bool + Whether to fetch and parse column transitions + need_priority : bool + Whether to fetch and parse priority transitions + need_status : bool + Whether to fetch and parse status transitions + + Returns + ------- + dict + Dictionary with keys 'columns', 'priority', 'status', each containing + a list of transaction dicts with keys: + - oldValue: previous value (format depends on transaction type) + - newValue: new value (format depends on transaction type) + - dateCreated: timestamp (int) + """ + result_dict = {"columns": [], "priority": [], "status": []} + + # Early return if nothing requested + if not (need_columns or need_priority or need_status): + return result_dict + + try: + # Extract task ID from PHID + search_result = self.phab.maniphest.search( + constraints={"phids": [task_phid]} + ) + + if not search_result.get("data"): + log.warning(f"No task found for PHID {task_phid}") + return result_dict + + task_id = search_result["data"][0]["id"] + + # Single API call for all transaction types + result = self.phab.maniphest.gettasktransactions(ids=[task_id]) + transactions = result.get(str(task_id), []) + + # Get priority map once if needed + priority_map = None + if need_priority: + priority_map = self._get_api_priority_map() + + # Process all transactions in a single pass + for trans in transactions: + trans_type = trans.get("transactionType") + + # Process column transitions + if need_columns and trans_type == "core:columns": + new_value_data = trans.get("newValue") + if ( + not new_value_data + or not isinstance(new_value_data, list) + or len(new_value_data) == 0 + ): + continue + + move_data = new_value_data[0] + board_phid = move_data.get("boardPHID") + new_column_phid = move_data.get("columnPHID") + from_columns = move_data.get("fromColumnPHIDs", {}) + + old_column_phid = None + if from_columns: + old_column_phid = next(iter(from_columns.keys()), None) + + transformed = { + "oldValue": [board_phid, old_column_phid] + if old_column_phid + else None, + "newValue": [board_phid, new_column_phid], + "dateCreated": int(trans.get("dateCreated", 0)), + } + result_dict["columns"].append(transformed) + + # Process priority transitions + elif need_priority and trans_type in ["priority", "core:priority"]: + old_value = trans.get("oldValue") + new_value = trans.get("newValue") + + # Resolve numeric priority values to names + old_value_resolved = None + if old_value is not None: + old_value_resolved = priority_map.get(old_value) + if old_value_resolved is None: + old_value_resolved = old_value + + new_value_resolved = None + if new_value is not None: + new_value_resolved = priority_map.get(new_value) + if new_value_resolved is None: + new_value_resolved = new_value + + transformed = { + "oldValue": old_value_resolved, + "newValue": new_value_resolved, + "dateCreated": int(trans.get("dateCreated", 0)), + } + result_dict["priority"].append(transformed) + + # Process status transitions + elif need_status and trans_type in ["status", "core:status"]: + old_value = trans.get("oldValue") + new_value = trans.get("newValue") + + transformed = { + "oldValue": old_value, + "newValue": new_value, + "dateCreated": int(trans.get("dateCreated", 0)), + } + result_dict["status"].append(transformed) + + log.debug( + f"Fetched transactions for {task_phid} (T{task_id}): " + f"{len(result_dict['columns'])} column, " + f"{len(result_dict['priority'])} priority, " + f"{len(result_dict['status'])} status" + ) + + return result_dict + + except AttributeError as e: + log.warning( + f"Unexpected API response structure for {task_phid}: {e}. " + "The Phabricator API format may have changed." + ) + return result_dict + except KeyError as e: + log.warning(f"Missing expected data in API response for {task_phid}: {e}") + return result_dict + except Exception as e: + log.warning( + f"Failed to fetch transactions for {task_phid}: {type(e).__name__}: {e}" + ) + return result_dict + def _fetch_task_transactions(self, task_phid): """ Fetch transaction history for a task, filtered to column changes. @@ -492,7 +646,9 @@ def _get_current_column(self, task, board_phid): return col_data["name"] if col_data else None - def _task_matches_priority_patterns(self, task, task_phid, priority_patterns): + def _task_matches_priority_patterns( + self, task, task_phid, priority_patterns, transactions=None + ): """ Check if a task matches any of the given priority patterns. @@ -504,6 +660,9 @@ def _task_matches_priority_patterns(self, task, task_phid, priority_patterns): Task PHID priority_patterns : list List of PriorityPattern objects + transactions : dict, optional + Pre-fetched transactions dict with keys 'columns', 'priority', 'status'. + If None, will fetch priority transactions using the old method. Returns ------- @@ -514,8 +673,11 @@ def _task_matches_priority_patterns(self, task, task_phid, priority_patterns): if not priority_patterns: return (True, []) # No filtering needed - # Fetch priority transaction history - priority_transactions = self._fetch_priority_transactions(task_phid) + # Use pre-fetched transactions if provided, otherwise fetch + if transactions is not None and "priority" in transactions: + priority_transactions = transactions["priority"] + else: + priority_transactions = self._fetch_priority_transactions(task_phid) # Get current priority current_priority = task.get("fields", {}).get("priority", {}).get("name") @@ -527,7 +689,52 @@ def _task_matches_priority_patterns(self, task, task_phid, priority_patterns): return (False, []) - def _task_matches_any_pattern(self, task, task_phid, patterns, board_phids): + def _task_matches_status_patterns( + self, task, task_phid, status_patterns, transactions=None + ): + """ + Check if a task matches any of the given status patterns. + + Parameters + ---------- + task : dict + Task data from maniphest.search + task_phid : str + Task PHID + status_patterns : list + List of StatusPattern objects + transactions : dict, optional + Pre-fetched transactions dict with 'status' key. + If not provided, will fetch status transactions. + + Returns + ------- + tuple + (matches: bool, status_transactions: list) + status_transactions contains all status change transactions + """ + if not status_patterns: + return (True, []) # No filtering needed + + # Use pre-fetched transactions if provided, otherwise fetch + if transactions is not None and "status" in transactions: + status_transactions = transactions["status"] + else: + status_transactions = self._fetch_status_transactions(task_phid) + + # Get current status + current_status = task.get("fields", {}).get("status", {}).get("name") + + # Check if any pattern matches + for pattern in status_patterns: + if pattern.matches(status_transactions, current_status): + return (True, status_transactions) + + return (False, []) + + def _task_matches_any_pattern( + self, task, task_phid, patterns, board_phids, transactions=None + ): """ Check if a task matches any of the given transition patterns. @@ -541,6 +748,9 @@ def _task_matches_any_pattern(self, task, task_phid, patterns, board_phids): List of TransitionPattern objects board_phids : list List of board PHIDs to check (typically the project being searched) + transactions : dict, optional + Pre-fetched transactions dict with keys 'columns', 'priority', 'status'. + If None, will fetch column transactions using the old method. Returns ------- @@ -552,10 +762,13 @@ def _task_matches_any_pattern(self, task, task_phid, patterns, board_phids): if not patterns: return (True, [], set()) # No filtering needed - # Fetch transaction history - transactions = self._fetch_task_transactions(task_phid) + # Use pre-fetched transactions if provided, otherwise fetch + if transactions is not None and "columns" in transactions: + column_transactions = transactions["columns"] + else: + column_transactions = self._fetch_task_transactions(task_phid) - if not transactions and not any( + if not column_transactions and not any( any(cond.get("type") == "in" for cond in p.conditions) for p in patterns ): # No transactions and no in-only patterns @@ -572,7 +785,7 @@ def _task_matches_any_pattern(self, task, task_phid, patterns, board_phids): # Filter transactions to this board board_transactions = [ t - for t in transactions + for t in column_transactions if t.get("newValue") and len(t["newValue"]) > 0 and t["newValue"][0] == board_phid @@ -586,7 +799,7 @@ def _task_matches_any_pattern(self, task, task_phid, patterns, board_phids): # Return match status, all transitions, and which boards matched if matching_board_phids: - return (True, transactions, matching_board_phids) + return (True, column_transactions, matching_board_phids) return (False, [], set()) @@ -837,6 +1050,7 @@ def _build_metadata_section( task_id, matching_boards_map, matching_priority_map, + matching_status_map, project_phid_to_name, ): """ @@ -850,6 +1064,8 @@ def _build_metadata_section( Mapping of task ID to set of matching board PHIDs matching_priority_map : dict Mapping of task ID to priority match boolean + matching_status_map : dict + Mapping of task ID to status match boolean project_phid_to_name : dict Mapping of board PHID to project name @@ -878,6 +1094,13 @@ def _build_metadata_section( else: metadata["MatchedPriority"] = False + # Build matched status + if task_id in matching_status_map: + matched_status = matching_status_map[task_id] + metadata["MatchedStatus"] = matched_status + else: + metadata["MatchedStatus"] = False + return metadata def task_search( @@ -887,6 +1110,7 @@ def task_search( updated_after=None, transition_patterns=None, priority_patterns=None, + status_patterns=None, show_history=False, show_metadata=False, ): @@ -904,10 +1128,12 @@ def task_search( Filters tasks based on column transitions (from, to, in, been, never, forward, backward). priority_patterns (list, optional): List of PriorityPattern objects to filter by. Filters tasks based on priority transitions (from, to, in, been, never, raised, lowered). - show_history (bool, optional): If True, display column and priority transition history for each task. + status_patterns (list, optional): List of StatusPattern objects to filter by. + Filters tasks based on status transitions (from, to, in, been, never, raised, lowered). + show_history (bool, optional): If True, display column, priority, and status transition history for each task. Must be explicitly requested; not auto-enabled by filters. - show_metadata (bool, optional): If True, display which boards/priorities matched the filters. - Shows MatchedBoards list and MatchedPriority boolean for debugging filter logic. + show_metadata (bool, optional): If True, display which boards/priorities/statuses matched the filters. + Shows MatchedBoards list, MatchedPriority, and MatchedStatus boolean for debugging filter logic. """ # Convert date filters to Unix timestamps if created_after: @@ -1046,18 +1272,30 @@ def task_search( task_transitions_map = {} # Initialize priority_transitions_map for storing priority history priority_transitions_map = {} + # Initialize status_transitions_map for storing status history + status_transitions_map = {} # Initialize matching_boards_map for storing which boards matched the filter matching_boards_map = {} # Initialize matching_priority_map for storing whether priority filter matched matching_priority_map = {} + # Initialize matching_status_map for storing whether status filter matched + matching_status_map = {} + + # Determine which transaction types are needed before the loop + # This allows us to fetch once per task instead of multiple times + need_columns = bool(transition_patterns) or show_history + need_priority = bool(priority_patterns) or show_history + need_status = bool(status_patterns) or show_history # Apply transition filtering if patterns specified - if transition_patterns or priority_patterns: + if transition_patterns or priority_patterns or status_patterns: filter_desc = [] if transition_patterns: filter_desc.append("column transition patterns") if priority_patterns: filter_desc.append("priority patterns") + if status_patterns: + filter_desc.append("status patterns") log.info( f"Filtering {len(result_data)} tasks by {' and '.join(filter_desc)}" ) @@ -1079,6 +1317,16 @@ def task_search( if not task_phid: continue + # Fetch all transaction types in one API call if any are needed + all_fetched_transactions = None + if need_columns or need_priority or need_status: + all_fetched_transactions = self._fetch_all_transactions( + task_phid, + need_columns=need_columns, + need_priority=need_priority, + need_status=need_status, + ) + # Check column transition patterns column_matches = True all_transitions = [] @@ -1102,7 +1350,11 @@ def task_search( column_matches, all_transitions, matching_board_phids = ( self._task_matches_any_pattern( - item, task_phid, transition_patterns, current_task_boards + item, + task_phid, + transition_patterns, + current_task_boards, + transactions=all_fetched_transactions, ) ) @@ -1114,27 +1366,51 @@ def task_search( # Filtering by priority - check if task matches priority_matches, priority_trans = ( self._task_matches_priority_patterns( - item, task_phid, priority_patterns + item, + task_phid, + priority_patterns, + transactions=all_fetched_transactions, ) ) - elif show_history: + elif show_history and all_fetched_transactions: # Not filtering by priority, but need history for display - priority_trans = self._fetch_priority_transactions(task_phid) + priority_trans = all_fetched_transactions.get("priority", []) + + # Check status patterns and fetch status history if needed + status_matches = True + status_trans = [] + + if status_patterns: + # Filtering by status - check if task matches + status_matches, status_trans = self._task_matches_status_patterns( + item, + task_phid, + status_patterns, + transactions=all_fetched_transactions, + ) + elif show_history and all_fetched_transactions: + # Not filtering by status, but need history for display + status_trans = all_fetched_transactions.get("status", []) - # Task must match both column AND priority patterns (if specified) - if column_matches and priority_matches: + # Task must match column AND priority AND status patterns (if specified) + if column_matches and priority_matches and status_matches: filtered_tasks.append(item) if show_history: if all_transitions: task_transitions_map[item["id"]] = all_transitions if priority_trans: priority_transitions_map[item["id"]] = priority_trans + if status_trans: + status_transitions_map[item["id"]] = status_trans # Store which boards matched for this task if matching_board_phids: matching_boards_map[item["id"]] = matching_board_phids # Store whether priority filter matched if priority_patterns: matching_priority_map[item["id"]] = priority_matches + # Store whether status filter matched + if status_patterns: + matching_status_map[item["id"]] = status_matches log.info(f"Filtered down to {len(filtered_tasks)} tasks matching patterns") result_data = filtered_tasks @@ -1144,14 +1420,26 @@ def task_search( for item in result_data: task_phid = item.get("phid") if task_phid: - # Fetch column transitions - transactions = self._fetch_task_transactions(task_phid) - if transactions: - task_transitions_map[item["id"]] = transactions - # Fetch priority transitions - priority_trans = self._fetch_priority_transactions(task_phid) - if priority_trans: - priority_transitions_map[item["id"]] = priority_trans + # Fetch all transaction types in a single API call + all_fetched_transactions = self._fetch_all_transactions( + task_phid, + need_columns=True, + need_priority=True, + need_status=True, + ) + # Store transactions for history display + if all_fetched_transactions.get("columns"): + task_transitions_map[item["id"]] = all_fetched_transactions[ + "columns" + ] + if all_fetched_transactions.get("priority"): + priority_transitions_map[item["id"]] = all_fetched_transactions[ + "priority" + ] + if all_fetched_transactions.get("status"): + status_transitions_map[item["id"]] = all_fetched_transactions[ + "status" + ] # Fetch project names for board display (always needed for nested format) project_phid_to_name = self._fetch_project_names_for_boards(result_data) @@ -1214,6 +1502,7 @@ def task_search( project_phid_to_name, priority_transitions_map, task_transitions_map, + status_transitions_map, ) if history_data: task_dict["History"] = history_data @@ -1224,6 +1513,7 @@ def task_search( item["id"], matching_boards_map, matching_priority_map, + matching_status_map, project_phid_to_name, ) task_dict["Metadata"] = metadata_data @@ -1248,8 +1538,11 @@ def _display_task_transitions(self, task_phid): task_phid : str Task PHID (e.g., "PHID-TASK-...") """ - # Fetch transitions - transactions = self._fetch_task_transactions(task_phid) + # Fetch column transitions using consolidated method + all_transactions = self._fetch_all_transactions( + task_phid, need_columns=True, need_priority=False + ) + transactions = all_transactions.get("columns", []) if not transactions: print("\nNo workboard transitions found.") diff --git a/phabfive/status_transitions.py b/phabfive/status_transitions.py new file mode 100644 index 0000000..7f387da --- /dev/null +++ b/phabfive/status_transitions.py @@ -0,0 +1,442 @@ +# -*- coding: utf-8 -*- + +# python std lib +import logging + +# phabfive imports +from phabfive.exceptions import PhabfiveException + +log = logging.getLogger(__name__) + +# Fallback status ordering for comparison and workflow progression +# Lower number = earlier in the workflow +# Used to determine if status was "raised" (progressed forward) or "lowered" (moved backward) +# This is only used if the API call to maniphest.querystatuses fails +FALLBACK_STATUS_ORDER = { + "open": 0, + "blocked": 1, + "wontfix": 2, + "invalid": 3, + "duplicate": 4, + "resolved": 5, +} + + +def _build_status_order_from_api(api_response): + """ + Build status order mapping from API response. + + The maniphest.querystatuses API returns: + - openStatuses: list of open status keys + - closedStatuses: dict of closed status values + - statusMap: dict mapping status keys to display names + + Parameters + ---------- + api_response : dict + Response from maniphest.querystatuses API + + Returns + ------- + dict + Mapping of status name (lowercase) to order number + """ + if not api_response: + return FALLBACK_STATUS_ORDER + + order_map = {} + + # Extract status information from API response + open_status_keys = api_response.get("openStatuses", []) + closed_status_values = api_response.get("closedStatuses", {}) + status_map = api_response.get("statusMap", {}) + + # Build order: open statuses first (lower numbers), then closed statuses + current_order = 0 + + # Add open statuses + for status_key in open_status_keys: + status_name = status_map.get(status_key, status_key) + order_map[status_name.lower()] = current_order + current_order += 1 + + # Add closed statuses + for status_key in closed_status_values.values(): + status_name = status_map.get(status_key, status_key) + order_map[status_name.lower()] = current_order + current_order += 1 + + return order_map + + +def get_status_order(status_name, api_response=None): + """ + Get the numeric order of a status for comparison. + + Parameters + ---------- + status_name : str + Status name (case-insensitive) + api_response : dict, optional + Full response from maniphest.querystatuses API. + If not provided, uses fallback ordering. + + Returns + ------- + int or None + Status order number, or None if not found + """ + if not status_name: + return None + + # Build order map from API response if provided + if api_response: + order_map = _build_status_order_from_api(api_response) + else: + order_map = FALLBACK_STATUS_ORDER + + return order_map.get(status_name.lower()) + + +class StatusPattern: + """ + Represents a single status pattern with AND conditions. + + A pattern can have multiple conditions that must all match (AND logic). + Multiple patterns can be combined with OR logic. + """ + + def __init__(self, conditions, api_response=None): + """ + Parameters + ---------- + conditions : list + List of condition dicts, each with keys like: + {"type": "from", "status": "Open", "direction": "raised"} + api_response : dict, optional + Full response from maniphest.querystatuses API for ordering + """ + self.conditions = conditions + self.api_response = api_response + + def matches(self, status_transactions, current_status): + """ + Check if all conditions in this pattern match (AND logic). + + Parameters + ---------- + status_transactions : list + List of status change transactions for a task + current_status : str or None + Current status name + + Returns + ------- + bool + True if all conditions match, False otherwise + """ + # All conditions must match for the pattern to match + for condition in self.conditions: + if not self._matches_condition( + condition, status_transactions, current_status + ): + return False + return True + + def _matches_condition(self, condition, status_transactions, current_status): + """Check if a single condition matches.""" + condition_type = condition.get("type") + + # Determine the match result based on condition type + if condition_type == "from": + result = self._matches_from(condition, status_transactions) + elif condition_type == "to": + result = self._matches_to(condition, status_transactions) + elif condition_type == "in": + result = self._matches_current(condition, current_status) + elif condition_type == "been": + result = self._matches_been(condition, status_transactions) + elif condition_type == "never": + result = self._matches_never(condition, status_transactions) + elif condition_type == "raised": + result = self._matches_raised(status_transactions) + elif condition_type == "lowered": + result = self._matches_lowered(status_transactions) + else: + log.warning(f"Unknown condition type: {condition_type}") + result = False + + # Apply negation if the condition has the "not:" prefix + if condition.get("negated"): + result = not result + + return result + + def _matches_from(self, condition, status_transactions): + """Match 'from:STATUS[:direction]' pattern.""" + target_status = condition.get("status") + direction = condition.get("direction") # None, "raised", or "lowered" + + for trans in status_transactions: + old_value = trans.get("oldValue") + new_value = trans.get("newValue") + + if old_value is None or new_value is None: + continue + + # Check if old status matches target (case-insensitive) + if old_value.lower() == target_status.lower(): + # If no direction specified, it's a match + if direction is None: + return True + + # Check direction + old_order = get_status_order(old_value, self.api_response) + new_order = get_status_order(new_value, self.api_response) + + if old_order is not None and new_order is not None: + if direction == "raised" and new_order > old_order: + return True + elif direction == "lowered" and new_order < old_order: + return True + + return False + + def _matches_to(self, condition, status_transactions): + """Match 'to:STATUS' pattern.""" + target_status = condition.get("status") + + for trans in status_transactions: + new_value = trans.get("newValue") + + if new_value is None: + continue + + if new_value.lower() == target_status.lower(): + return True + + return False + + def _matches_current(self, condition, current_status): + """Match 'in:STATUS' pattern.""" + target_status = condition.get("status") + if not current_status: + return False + return current_status.lower() == target_status.lower() + + def _matches_been(self, condition, status_transactions): + """Match 'been:STATUS' pattern - task was at status at any point.""" + target_status = condition.get("status") + + for trans in status_transactions: + old_value = trans.get("oldValue") + new_value = trans.get("newValue") + + # Check both old and new values + for value in [old_value, new_value]: + if value and value.lower() == target_status.lower(): + return True + + return False + + def _matches_never(self, condition, status_transactions): + """Match 'never:STATUS' pattern - task was never at status.""" + target_status = condition.get("status") + + for trans in status_transactions: + old_value = trans.get("oldValue") + new_value = trans.get("newValue") + + # Check both old and new values + for value in [old_value, new_value]: + if value and value.lower() == target_status.lower(): + return False # Found the status, so it's not "never" + + return True # Never found the status + + def _matches_raised(self, status_transactions): + """Match 'raised' pattern - status progressed forward (higher number = further along).""" + for trans in status_transactions: + old_value = trans.get("oldValue") + new_value = trans.get("newValue") + + if old_value is None or new_value is None: + continue + + old_order = get_status_order(old_value, self.api_response) + new_order = get_status_order(new_value, self.api_response) + + if old_order is not None and new_order is not None: + if new_order > old_order: # Higher number = further along + return True + + return False + + def _matches_lowered(self, status_transactions): + """Match 'lowered' pattern - status moved backward (lower number = earlier stage).""" + for trans in status_transactions: + old_value = trans.get("oldValue") + new_value = trans.get("newValue") + + if old_value is None or new_value is None: + continue + + old_order = get_status_order(old_value, self.api_response) + new_order = get_status_order(new_value, self.api_response) + + if old_order is not None and new_order is not None: + if new_order < old_order: # Lower number = earlier stage + return True + + return False + + +def _parse_single_condition(condition_str): + """ + Parse a single condition string. + + Parameters + ---------- + condition_str : str + Condition like "from:Open:raised", "in:Resolved", "raised", "lowered" + Can be prefixed with "not:" to negate: "not:in:Open", "not:raised" + + Returns + ------- + dict + Condition dict with keys like {"type": "from", "status": "Open", "direction": "raised"} + May include {"negated": True} if prefixed with "not:" + + Raises + ------ + PhabfiveException + If condition syntax is invalid + """ + condition_str = condition_str.strip() + + # Check for not: prefix + negated = False + if condition_str.startswith("not:"): + negated = True + condition_str = condition_str[4:].strip() # Strip "not:" prefix + + # Special keywords without parameters + if condition_str == "raised": + result = {"type": "raised"} + if negated: + result["negated"] = True + return result + elif condition_str == "lowered": + result = {"type": "lowered"} + if negated: + result["negated"] = True + return result + + # Patterns with parameters: type:value or type:value:direction + if ":" not in condition_str: + raise PhabfiveException(f"Invalid status condition syntax: '{condition_str}'") + + parts = condition_str.split(":", 2) # Split into max 3 parts + condition_type = parts[0].strip() + + if condition_type not in ["from", "to", "in", "been", "never"]: + raise PhabfiveException( + f"Invalid status condition type: '{condition_type}'. " + f"Valid types: from, to, in, been, never, raised, lowered" + ) + + if len(parts) < 2: + raise PhabfiveException(f"Missing status name for condition: '{condition_str}'") + + status_name = parts[1].strip() + if not status_name: + raise PhabfiveException(f"Empty status name in condition: '{condition_str}'") + + result = {"type": condition_type, "status": status_name} + + # Handle optional direction for 'from' patterns + if len(parts) == 3: + if condition_type != "from": + raise PhabfiveException( + f"Direction modifier only allowed for 'from' patterns, got: '{condition_str}'" + ) + direction = parts[2].strip() + if direction not in ["raised", "lowered"]: + raise PhabfiveException( + f"Invalid direction: '{direction}'. Must be 'raised' or 'lowered'" + ) + result["direction"] = direction + + # Add negated flag if not: prefix was present + if negated: + result["negated"] = True + + return result + + +def parse_status_patterns(patterns_str, api_response=None): + """ + Parse status pattern string into list of StatusPattern objects. + + Supports: + - Comma (,) for OR logic between patterns + - Plus (+) for AND logic within a pattern + + Parameters + ---------- + patterns_str : str + Pattern string like "from:Open:raised+in:Resolved,to:Closed" + api_response : dict, optional + Full response from maniphest.querystatuses API for ordering + + Returns + ------- + list + List of StatusPattern objects + + Raises + ------ + PhabfiveException + If pattern syntax is invalid + + Examples + -------- + >>> parse_status_patterns("from:Open:raised") + [StatusPattern([{"type": "from", "status": "Open", "direction": "raised"}])] + + >>> parse_status_patterns("from:Open+in:Resolved,to:Closed") + [StatusPattern([from:Open, in:Resolved]), StatusPattern([to:Closed])] + """ + if not patterns_str or not patterns_str.strip(): + raise PhabfiveException("Empty status pattern") + + patterns = [] + + # Split by comma for OR groups + or_groups = patterns_str.split(",") + + for or_group in or_groups: + or_group = or_group.strip() + if not or_group: + continue + + conditions = [] + + # Split by plus for AND conditions + and_parts = or_group.split("+") + + for and_part in and_parts: + and_part = and_part.strip() + if not and_part: + continue + + condition = _parse_single_condition(and_part) + conditions.append(condition) + + if conditions: + patterns.append(StatusPattern(conditions, api_response)) + + if not patterns: + raise PhabfiveException("No valid status patterns found") + + return patterns diff --git a/tests/test_status_transitions.py b/tests/test_status_transitions.py new file mode 100644 index 0000000..488586a --- /dev/null +++ b/tests/test_status_transitions.py @@ -0,0 +1,344 @@ +# -*- coding: utf-8 -*- + +# 3rd party imports +import pytest + +# phabfive imports +from phabfive.status_transitions import ( + StatusPattern, + _parse_single_condition, + parse_status_patterns, + get_status_order, +) +from phabfive.exceptions import PhabfiveException + + +class TestGetStatusOrder: + def test_open(self): + assert get_status_order("Open") == 0 + + def test_blocked(self): + assert get_status_order("Blocked") == 1 + + def test_wontfix(self): + assert get_status_order("Wontfix") == 2 + + def test_invalid(self): + assert get_status_order("Invalid") == 3 + + def test_duplicate(self): + assert get_status_order("Duplicate") == 4 + + def test_resolved(self): + assert get_status_order("Resolved") == 5 + + def test_case_insensitive(self): + assert get_status_order("OPEN") == 0 + assert get_status_order("blocked") == 1 + assert get_status_order("ReSOlvEd") == 5 + + def test_unknown_status(self): + assert get_status_order("Unknown") is None + + def test_none_input(self): + assert get_status_order(None) is None + + def test_empty_string(self): + assert get_status_order("") is None + + +class TestParseSingleCondition: + def test_parse_raised(self): + result = _parse_single_condition("raised") + assert result == {"type": "raised"} + + def test_parse_lowered(self): + result = _parse_single_condition("lowered") + assert result == {"type": "lowered"} + + def test_parse_from_simple(self): + result = _parse_single_condition("from:Open") + assert result == {"type": "from", "status": "Open"} + + def test_parse_from_with_raised(self): + result = _parse_single_condition("from:Open:raised") + assert result == {"type": "from", "status": "Open", "direction": "raised"} + + def test_parse_from_with_lowered(self): + result = _parse_single_condition("from:Resolved:lowered") + assert result == {"type": "from", "status": "Resolved", "direction": "lowered"} + + def test_parse_to(self): + result = _parse_single_condition("to:Resolved") + assert result == {"type": "to", "status": "Resolved"} + + def test_parse_in(self): + result = _parse_single_condition("in:Open") + assert result == {"type": "in", "status": "Open"} + + def test_parse_been(self): + result = _parse_single_condition("been:Resolved") + assert result == {"type": "been", "status": "Resolved"} + + def test_parse_never(self): + result = _parse_single_condition("never:Resolved") + assert result == {"type": "never", "status": "Resolved"} + + def test_invalid_type(self): + with pytest.raises(PhabfiveException) as exc: + _parse_single_condition("invalid:Open") + assert "Invalid status condition type" in str(exc.value) + + def test_missing_colon(self): + with pytest.raises(PhabfiveException) as exc: + _parse_single_condition("notavalidpattern") + assert "Invalid status condition syntax" in str(exc.value) + + def test_empty_status_name(self): + with pytest.raises(PhabfiveException) as exc: + _parse_single_condition("from:") + assert "Empty status name" in str(exc.value) + + def test_direction_on_non_from(self): + with pytest.raises(PhabfiveException) as exc: + _parse_single_condition("to:Open:raised") + assert "Direction modifier only allowed for 'from' patterns" in str(exc.value) + + def test_invalid_direction(self): + with pytest.raises(PhabfiveException) as exc: + _parse_single_condition("from:Open:invalid") + assert "Invalid direction" in str(exc.value) + + def test_parse_not_in(self): + result = _parse_single_condition("not:in:Open") + assert result == {"type": "in", "status": "Open", "negated": True} + + def test_parse_not_from(self): + result = _parse_single_condition("not:from:Resolved") + assert result == {"type": "from", "status": "Resolved", "negated": True} + + def test_parse_not_from_with_direction(self): + result = _parse_single_condition("not:from:Open:raised") + assert result == {"type": "from", "status": "Open", "direction": "raised", "negated": True} + + def test_parse_not_been(self): + result = _parse_single_condition("not:been:Resolved") + assert result == {"type": "been", "status": "Resolved", "negated": True} + + def test_parse_not_raised(self): + result = _parse_single_condition("not:raised") + assert result == {"type": "raised", "negated": True} + + def test_parse_not_lowered(self): + result = _parse_single_condition("not:lowered") + assert result == {"type": "lowered", "negated": True} + + +class TestParseStatusPatterns: + def test_single_simple_pattern(self): + patterns = parse_status_patterns("raised") + assert len(patterns) == 1 + assert len(patterns[0].conditions) == 1 + assert patterns[0].conditions[0]["type"] == "raised" + + def test_single_from_pattern(self): + patterns = parse_status_patterns("from:Open:raised") + assert len(patterns) == 1 + assert patterns[0].conditions[0] == { + "type": "from", + "status": "Open", + "direction": "raised", + } + + def test_or_patterns_comma(self): + patterns = parse_status_patterns("in:Open,in:Resolved") + assert len(patterns) == 2 + assert patterns[0].conditions[0] == {"type": "in", "status": "Open"} + assert patterns[1].conditions[0] == {"type": "in", "status": "Resolved"} + + def test_and_conditions_plus(self): + patterns = parse_status_patterns("from:Open+in:Resolved") + assert len(patterns) == 1 + assert len(patterns[0].conditions) == 2 + assert patterns[0].conditions[0] == {"type": "from", "status": "Open"} + assert patterns[0].conditions[1] == {"type": "in", "status": "Resolved"} + + def test_complex_pattern(self): + patterns = parse_status_patterns("from:Open:raised+in:Resolved,to:Wontfix") + assert len(patterns) == 2 + # First pattern: AND conditions + assert len(patterns[0].conditions) == 2 + assert patterns[0].conditions[0] == { + "type": "from", + "status": "Open", + "direction": "raised", + } + assert patterns[0].conditions[1] == {"type": "in", "status": "Resolved"} + # Second pattern + assert len(patterns[1].conditions) == 1 + assert patterns[1].conditions[0] == {"type": "to", "status": "Wontfix"} + + def test_empty_pattern(self): + with pytest.raises(PhabfiveException) as exc: + parse_status_patterns("") + assert "Empty status pattern" in str(exc.value) + + def test_whitespace_only_pattern(self): + with pytest.raises(PhabfiveException) as exc: + parse_status_patterns(" ") + assert "Empty status pattern" in str(exc.value) + + +class TestStatusPatternMatching: + def test_matches_in_current_status(self): + """Test 'in:STATUS' pattern matches current status""" + pattern = StatusPattern([{"type": "in", "status": "Open"}]) + + transactions = [] + current_status = "Open" + + assert pattern.matches(transactions, current_status) is True + + def test_matches_in_different_status(self): + """Test 'in:STATUS' pattern doesn't match different status""" + pattern = StatusPattern([{"type": "in", "status": "Resolved"}]) + + transactions = [] + current_status = "Open" + + assert pattern.matches(transactions, current_status) is False + + def test_matches_from_status(self): + """Test 'from:STATUS' pattern matches transition from that status""" + pattern = StatusPattern([{"type": "from", "status": "Open"}]) + + transactions = [ + {"oldValue": "Open", "newValue": "Resolved", "dateCreated": 1234567890} + ] + current_status = "Resolved" + + assert pattern.matches(transactions, current_status) is True + + def test_matches_to_status(self): + """Test 'to:STATUS' pattern matches transition to that status""" + pattern = StatusPattern([{"type": "to", "status": "Resolved"}]) + + transactions = [ + {"oldValue": "Open", "newValue": "Resolved", "dateCreated": 1234567890} + ] + current_status = "Resolved" + + assert pattern.matches(transactions, current_status) is True + + def test_matches_been_status(self): + """Test 'been:STATUS' pattern matches any occurrence of status""" + pattern = StatusPattern([{"type": "been", "status": "Open"}]) + + transactions = [ + {"oldValue": "Open", "newValue": "Blocked", "dateCreated": 1234567890}, + {"oldValue": "Blocked", "newValue": "Resolved", "dateCreated": 1234567900} + ] + current_status = "Resolved" + + assert pattern.matches(transactions, current_status) is True + + def test_matches_never_status(self): + """Test 'never:STATUS' pattern matches when status never occurred""" + pattern = StatusPattern([{"type": "never", "status": "Wontfix"}]) + + transactions = [ + {"oldValue": "Open", "newValue": "Resolved", "dateCreated": 1234567890} + ] + current_status = "Resolved" + + assert pattern.matches(transactions, current_status) is True + + def test_matches_raised(self): + """Test 'raised' pattern matches status progression""" + pattern = StatusPattern([{"type": "raised"}]) + + transactions = [ + {"oldValue": "Open", "newValue": "Resolved", "dateCreated": 1234567890} + ] + current_status = "Resolved" + + assert pattern.matches(transactions, current_status) is True + + def test_matches_lowered(self): + """Test 'lowered' pattern matches status regression""" + pattern = StatusPattern([{"type": "lowered"}]) + + transactions = [ + {"oldValue": "Resolved", "newValue": "Open", "dateCreated": 1234567890} + ] + current_status = "Open" + + assert pattern.matches(transactions, current_status) is True + + def test_matches_not_in(self): + """Test 'not:in:STATUS' pattern negates the match""" + pattern = StatusPattern([{"type": "in", "status": "Open", "negated": True}]) + + transactions = [] + current_status = "Resolved" + + assert pattern.matches(transactions, current_status) is True + + def test_matches_and_conditions(self): + """Test multiple AND conditions must all match""" + pattern = StatusPattern([ + {"type": "been", "status": "Open"}, + {"type": "in", "status": "Resolved"} + ]) + + transactions = [ + {"oldValue": "Open", "newValue": "Resolved", "dateCreated": 1234567890} + ] + current_status = "Resolved" + + assert pattern.matches(transactions, current_status) is True + + def test_matches_and_conditions_fail(self): + """Test multiple AND conditions fail if any doesn't match""" + pattern = StatusPattern([ + {"type": "been", "status": "Open"}, + {"type": "in", "status": "Wontfix"} + ]) + + transactions = [ + {"oldValue": "Open", "newValue": "Resolved", "dateCreated": 1234567890} + ] + current_status = "Resolved" + + assert pattern.matches(transactions, current_status) is False + + def test_matches_from_with_raised_direction(self): + """Test 'from:STATUS:raised' matches status change with progression""" + pattern = StatusPattern([{"type": "from", "status": "Open", "direction": "raised"}]) + + transactions = [ + {"oldValue": "Open", "newValue": "Resolved", "dateCreated": 1234567890} + ] + current_status = "Resolved" + + assert pattern.matches(transactions, current_status) is True + + def test_matches_from_with_lowered_direction(self): + """Test 'from:STATUS:lowered' matches status change with regression""" + pattern = StatusPattern([{"type": "from", "status": "Resolved", "direction": "lowered"}]) + + transactions = [ + {"oldValue": "Resolved", "newValue": "Open", "dateCreated": 1234567890} + ] + current_status = "Open" + + assert pattern.matches(transactions, current_status) is True + + def test_case_insensitive_matching(self): + """Test status matching is case-insensitive""" + pattern = StatusPattern([{"type": "in", "status": "OPEN"}]) + + transactions = [] + current_status = "open" + + assert pattern.matches(transactions, current_status) is True diff --git a/tox.ini b/tox.ini index 6ffcb59..e83adeb 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] minversion = 4.0 -envlist = py{310,311,312,313},flake8,coverage,mkdocs +envlist = py{310,311,312,313,314},flake8,coverage,mkdocs basepython = py310: python3.10 py311: python3.11