diff --git a/phabfive/cli.py b/phabfive/cli.py index 0bc9a58..0ff5dc5 100644 --- a/phabfive/cli.py +++ b/phabfive/cli.py @@ -108,21 +108,66 @@ -h, --help Show this help message and exit """ -sub_maniphest_args = """ +sub_maniphest_base_args = """ Usage: phabfive maniphest comment add [options] + phabfive maniphest show [options] + phabfive maniphest create [options] + phabfive maniphest search [options] + +Options: + -h, --help Show this help message and exit +""" + +sub_maniphest_show_args = """ +Usage: phabfive maniphest show ([--all] | [--pp]) [options] + +Arguments: + Task ID (e.g., T123) + +Options: + --all Show all fields for a ticket + --pp Show all fields rendering with pretty print + -h, --help Show this help message and exit +""" + +sub_maniphest_create_args = """ +Usage: phabfive maniphest create [--dry-run] [options] + +Arguments: + Path to YAML configuration file + +Options: + --dry-run Does everything except commiting the tickets + -h, --help Show this help message and exit +""" + +sub_maniphest_comment_args = """ +Usage: + phabfive maniphest comment add [options] + +Arguments: + Task ID (e.g., T123) + Comment text to add + +Options: + -h, --help Show this help message and exit +""" + +sub_maniphest_search_args = """ +Usage: phabfive maniphest search [options] -Search Arguments: +Arguments: Project name or filter pattern (supports OR/AND logic and wildcards). Supports: "*" (all projects), "prefix*" (starts with), "*suffix" (ends with), "*contains*" (contains text). Filter syntax: "ProjectA,ProjectB" (OR), "ProjectA+ProjectB" (AND). Empty string "" returns no results. -Search Options: +Options: --created-after=N Tasks created within the last N days --updated-after=N Tasks updated within the last N days --column=PATTERNS Filter tasks by column transitions (comma=OR, plus=AND). @@ -172,12 +217,7 @@ 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 - --dry-run Does everything except commiting the tickets - --pp Show all fields rendering with pretty print - -h, --help Show this help message and exit + -h, --help Show this help message and exit """ @@ -230,7 +270,12 @@ def parse_cli(): cli_args[""] = [monogram] cli_args[""] = app - sub_args = docopt(eval("sub_{app}_args".format(app=app)), argv=argv) # nosec-B307 + + # For maniphest shortcuts, use the show command + if app == "maniphest": + sub_args = docopt(sub_maniphest_show_args, argv=argv) + else: + sub_args = docopt(eval("sub_{app}_args".format(app=app)), argv=argv) # nosec-B307 elif cli_args[""] == "passphrase": sub_args = docopt(sub_passphrase_args, argv=argv) elif cli_args[""] == "diffusion": @@ -242,7 +287,30 @@ def parse_cli(): elif cli_args[""] == "repl": sub_args = docopt(sub_repl_args, argv=argv) elif cli_args[""] == "maniphest": - sub_args = docopt(sub_maniphest_args, argv=argv) + # Determine which maniphest subcommand is being called + maniphest_subcmd = None + if len(argv) > 1: + if argv[1] == "show": + maniphest_subcmd = "show" + elif argv[1] == "create": + maniphest_subcmd = "create" + elif argv[1] == "search": + maniphest_subcmd = "search" + elif argv[1] == "comment": + maniphest_subcmd = "comment" + + # Use the appropriate help string based on subcommand + if maniphest_subcmd == "show": + sub_args = docopt(sub_maniphest_show_args, argv=argv) + elif maniphest_subcmd == "create": + sub_args = docopt(sub_maniphest_create_args, argv=argv) + elif maniphest_subcmd == "search": + sub_args = docopt(sub_maniphest_search_args, argv=argv) + elif maniphest_subcmd == "comment": + sub_args = docopt(sub_maniphest_comment_args, argv=argv) + else: + # No subcommand or unrecognized subcommand - show base help + sub_args = docopt(sub_maniphest_base_args, argv=argv) else: extras( True, @@ -452,14 +520,14 @@ def run(cli_args, sub_args): show_metadata=show_metadata, ) - if sub_args["create"]: + if sub_args.get("create"): # This part is responsible for bulk creating several tickets at once maniphest_app.create_from_config( sub_args[""], dry_run=sub_args["--dry-run"], ) - if sub_args["comment"] and sub_args["add"]: + if sub_args.get("comment") and sub_args.get("add"): result = maniphest_app.add_comment( sub_args[""], sub_args[""], @@ -472,7 +540,7 @@ def run(cli_args, sub_args): print("Comment successfully added") print("Ticket URI: {0}".format(ticket["uri"])) - if sub_args["show"]: + if sub_args.get("show"): _, result = maniphest_app.info(int(sub_args[""][1:])) if sub_args["--pp"]: diff --git a/phabfive/maniphest.py b/phabfive/maniphest.py index 0e65ca4..94b6f36 100644 --- a/phabfive/maniphest.py +++ b/phabfive/maniphest.py @@ -42,16 +42,21 @@ def __init__(self): def _resolve_project_phids(self, project: str) -> list[str]: """ - Resolve project name or wildcard pattern to list of project PHIDs. + Resolve project name, hashtag, or wildcard pattern to list of project PHIDs. + + Matches against all project slugs/hashtags, not just the primary name. + This allows users to search using any hashtag associated with a project. Parameters ---------- - project (str): Project name or wildcard pattern. + project (str): Project name, hashtag, or wildcard pattern. Supports: "*" (all), "prefix*", "*suffix", "*contains*" + Matches against any project slug/hashtag (case-insensitive). Returns ------- list: List of project PHIDs matching the pattern. Empty list if no matches. + Duplicates are automatically removed when multiple slugs match the same project. """ # Validate project parameter if not project or project == "": @@ -61,80 +66,115 @@ def _resolve_project_phids(self, project: str) -> list[str]: # Fetch all projects from Phabricator regardless of exact match or not to be able to suggest project names log.debug("Fetching all projects from Phabricator") - # Use pagination to fetch all projects (API returns max 100 per page) - all_projects = {} - after = None + # Use project.query to get slugs (project.search doesn't return all hashtags) + # Note: project.query doesn't support pagination, so we fetch all at once + slug_to_phid = {} # Maps each slug/hashtag to its project PHID + phid_to_primary_name = {} # Maps PHID to primary project name - while True: - # Use queryKey="all" to get all projects, with cursor-based pagination - if after: - projects_query = self.phab.project.search(queryKey="all", after=after) - else: - projects_query = self.phab.project.search(queryKey="all") + try: + # project.query returns a Result object with 'data' key containing projects + projects_result = self.phab.project.query() + projects_data = projects_result.get("data", {}) - # Merge results from this page - for p in projects_query["data"]: - all_projects[p["fields"]["name"]] = p["phid"] + # Process all projects (projects_data is a dict keyed by PHID) + for phid, project_data in projects_data.items(): + primary_name = project_data["name"] + phid_to_primary_name[phid] = primary_name - # Check if there are more pages - cursor = projects_query.get("cursor", {}) - after = cursor.get("after") + # Always add the primary name as a searchable slug + slug_to_phid[primary_name] = phid - if after is None: - # No more pages - break + # Get all slugs (hashtags) for this project and add them too + slugs = project_data.get("slugs", []) + if slugs: + for slug in slugs: + if slug: + slug_to_phid[slug] = phid - log.debug(f"Fetched {len(all_projects)} total projects from Phabricator") - # Create case-insensitive lookup mappings - lower_to_phid = {name.lower(): phid for name, phid in all_projects.items()} - lower_to_original = {name.lower(): name for name in all_projects.keys()} + except Exception as e: + log.error(f"Failed to fetch projects: {e}") + return [] + + log.debug( + f"Fetched {len(phid_to_primary_name)} total projects with {len(slug_to_phid)} slugs/hashtags from Phabricator" + ) + # Create case-insensitive lookup mappings for slugs + lower_slug_to_phid = {slug.lower(): phid for slug, phid in slug_to_phid.items()} + lower_slug_to_original = {slug.lower(): slug for slug in slug_to_phid.keys()} # Check if wildcard search is needed has_wildcard = "*" in project if has_wildcard: if project == "*": - # Search all projects - log.info(f"Wildcard '*' matched all {len(all_projects)} projects") - return list(all_projects.values()) + # Search all projects - return unique PHIDs + unique_phids = list(set(slug_to_phid.values())) + log.info(f"Wildcard '*' matched all {len(unique_phids)} projects") + return unique_phids else: - # Filter projects by wildcard pattern (case-insensitive) - matching_projects: list[str] = [ - lower_to_original[name_lower] - for name_lower in lower_to_phid.keys() - if fnmatch.fnmatch(name_lower, project.lower()) - ] - - if not matching_projects: + # Filter slugs by wildcard pattern (case-insensitive) + # Use set to avoid duplicate PHIDs when multiple slugs of same project match + matching_phids = set() + matching_display_names = [] + + for slug_lower in lower_slug_to_phid.keys(): + if fnmatch.fnmatch(slug_lower, project.lower()): + phid = lower_slug_to_phid[slug_lower] + if phid not in matching_phids: + matching_phids.add(phid) + # Use primary name for display + matching_display_names.append(phid_to_primary_name[phid]) + + if not matching_phids: log.warning(f"Wildcard pattern '{project}' matched no projects") return [] log.info( - f"Wildcard pattern '{project}' matched {len(matching_projects)} " - + f"project(s): {', '.join(matching_projects)}" + f"Wildcard pattern '{project}' matched {len(matching_phids)} " + + f"project(s): {', '.join(sorted(matching_display_names))}" ) - return [all_projects[name] for name in matching_projects] + return list(matching_phids) # Exact match - validate project exists (case-insensitive) + # Match against any slug/hashtag log.debug(f"Exact match mode, validating project '{project}'") project_lower = project.lower() - if project_lower in lower_to_phid: - original_name = lower_to_original[project_lower] + if project_lower in lower_slug_to_phid: + phid = lower_slug_to_phid[project_lower] + matched_slug = lower_slug_to_original[project_lower] + primary_name = phid_to_primary_name[phid] log.debug( - f"Found case-insensitive match for project '{project}' -> '{original_name}'" + f"Found case-insensitive match for project '{project}' -> slug '{matched_slug}' (primary: '{primary_name}')" ) - return [lower_to_phid[project_lower]] + return [phid] else: - # Project not found - suggest similar names (case-insensitive) + # Project not found - suggest similar slugs (case-insensitive) + # Deduplicate suggestions by PHID to avoid showing same project multiple times cutoff = 0.6 if len(project_lower) > 3 else 0.4 - similar = difflib.get_close_matches( - project_lower, lower_to_phid.keys(), n=3, cutoff=cutoff + similar_slugs = difflib.get_close_matches( + project_lower, lower_slug_to_phid.keys(), n=10, cutoff=cutoff ) - if similar: - # Map back to original names for display - original_similar = [lower_to_original[s] for s in similar] + + if similar_slugs: + # Deduplicate by PHID - show primary names with matched slugs + seen_phids = set() + unique_suggestions = [] + + for slug in similar_slugs: + phid = lower_slug_to_phid[slug] + if phid not in seen_phids: + seen_phids.add(phid) + primary_name = phid_to_primary_name[phid] + original_slug = lower_slug_to_original[slug] + + # Format: "Primary Name (matched-slug)" + unique_suggestions.append(f"{primary_name} ({original_slug})") + + # Limit to 3 unique projects + unique_suggestions = unique_suggestions[:3] + log.error( - f"Project '{project}' not found. Did you mean: {', '.join(original_similar)}?" + f"Project '{project}' not found. Did you mean: {', '.join(unique_suggestions)}?" ) else: log.error(f"Project '{project}' not found") diff --git a/tests/test_maniphest.py b/tests/test_maniphest.py index bec288b..e6565f1 100644 --- a/tests/test_maniphest.py +++ b/tests/test_maniphest.py @@ -954,11 +954,15 @@ def test_task_search_yaml_output_is_parsable(self, mock_init, capsys): # Mock the phab API maniphest.phab = MagicMock() - # Mock project search to return a project - maniphest.phab.project.search.return_value = { - "data": [{"phid": "PHID-PROJ-123", "fields": {"name": "Test Project"}}], - "cursor": {"after": None}, + # Mock project.query to return a project with slugs + mock_project_result = MagicMock() + mock_project_result.get.return_value = { + "PHID-PROJ-123": { + "name": "Test Project", + "slugs": ["test-project", "test_project"], + } } + maniphest.phab.project.query.return_value = mock_project_result # Mock maniphest search with tasks containing special YAML characters mock_response = MagicMock()