Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 82 additions & 14 deletions phabfive/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,21 +108,66 @@
-h, --help Show this help message and exit
"""

sub_maniphest_args = """
sub_maniphest_base_args = """
Usage:
phabfive maniphest comment add <ticket_id> <comment> [options]
phabfive maniphest show <ticket_id> [options]
phabfive maniphest create <config-file> [options]
phabfive maniphest search <project_name> [options]

Options:
-h, --help Show this help message and exit
"""

sub_maniphest_show_args = """
Usage:
phabfive maniphest show <ticket_id> ([--all] | [--pp]) [options]

Arguments:
<ticket_id> 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 <config-file> [--dry-run] [options]

Arguments:
<config-file> 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 <ticket_id> <comment> [options]

Arguments:
<ticket_id> Task ID (e.g., T123)
<comment> Comment text to add

Options:
-h, --help Show this help message and exit
"""

sub_maniphest_search_args = """
Usage:
phabfive maniphest search <project_name> [options]

Search Arguments:
Arguments:
<project_name> 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).
Expand Down Expand Up @@ -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
"""


Expand Down Expand Up @@ -230,7 +270,12 @@ def parse_cli():

cli_args["<args>"] = [monogram]
cli_args["<command>"] = 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["<command>"] == "passphrase":
sub_args = docopt(sub_passphrase_args, argv=argv)
elif cli_args["<command>"] == "diffusion":
Expand All @@ -242,7 +287,30 @@ def parse_cli():
elif cli_args["<command>"] == "repl":
sub_args = docopt(sub_repl_args, argv=argv)
elif cli_args["<command>"] == "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,
Expand Down Expand Up @@ -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["<config-file>"],
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["<ticket_id>"],
sub_args["<comment>"],
Expand All @@ -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["<ticket_id>"][1:]))

if sub_args["--pp"]:
Expand Down
138 changes: 89 additions & 49 deletions phabfive/maniphest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "":
Expand All @@ -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")
Expand Down
12 changes: 8 additions & 4 deletions tests/test_maniphest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down