From 82e6b12204371eaefcfe261183f98af979ffb2e6 Mon Sep 17 00:00:00 2001 From: Konstantinos Skarmoutsos Date: Mon, 31 Aug 2026 17:12:47 +0300 Subject: [PATCH 1/4] feat(maniphest): add tool for editing task comments Add pha_task_edit_comment, backed by the new maniphest.comment.edit Conduit API, which rewrites an existing comment identified by its transaction PHID as returned by pha_task_get_transactions. Co-Authored-By: Claude Opus 5 --- conduit/client/maniphest.py | 16 ++++++++++++++++ conduit/client/tests/test_client.py | 21 +++++++++++++++++++++ conduit/main_tools.py | 22 ++++++++++++++++++++++ 3 files changed, 59 insertions(+) diff --git a/conduit/client/maniphest.py b/conduit/client/maniphest.py index a38a317..2a4844f 100644 --- a/conduit/client/maniphest.py +++ b/conduit/client/maniphest.py @@ -157,6 +157,22 @@ def edit_task( return self._make_request("maniphest.edit", params) + def edit_comment(self, transaction_phid: str, content: str) -> Dict[str, Any]: + """ + Edit the content of an existing task comment. + + Args: + transaction_phid: PHID of the comment transaction (PHID-XACT-TASK-xxx) + content: New comment content + + Returns: + The edited comment's transaction PHID, object PHID and content + """ + return self._make_request( + "maniphest.comment.edit", + {"transactionPHID": transaction_phid, "content": content}, + ) + def get_task_transactions(self, task_id: int) -> Dict[str, Any]: """ Get transaction history for a task. diff --git a/conduit/client/tests/test_client.py b/conduit/client/tests/test_client.py index e3e8bdf..af9caa4 100644 --- a/conduit/client/tests/test_client.py +++ b/conduit/client/tests/test_client.py @@ -1,5 +1,7 @@ from unittest import TestCase +from unittest.mock import patch +from conduit.client.maniphest import ManiphestClient from conduit.utils import flatten_params @@ -27,3 +29,22 @@ def test_flatten_params(self): ("test[1][a]", 4), ], ) + + +class TestManiphestEditComment(TestCase): + @patch("conduit.client.base.BasePhabricatorClient._make_request") + def test_edit_comment(self, mock_request): + client = ManiphestClient( + api_url="http://test.example.com/api/", api_token="test_token" + ) + mock_request.return_value = {"transactionPHID": "PHID-XACT-TASK-abc"} + + client.edit_comment("PHID-XACT-TASK-abc", "updated text") + + mock_request.assert_called_once_with( + "maniphest.comment.edit", + { + "transactionPHID": "PHID-XACT-TASK-abc", + "content": "updated text", + }, + ) diff --git a/conduit/main_tools.py b/conduit/main_tools.py index 29ed28a..d930ae4 100644 --- a/conduit/main_tools.py +++ b/conduit/main_tools.py @@ -506,6 +506,28 @@ def pha_task_add_comment(task_id: str, comment: str) -> dict: ) return {"success": True} + @mcp.tool() + @handle_api_errors + def pha_task_edit_comment(transaction_phid: str, comment: str) -> dict: + """ + Edit an existing comment on a Phabricator task. + + Args: + transaction_phid: The PHID of the comment transaction to edit + (e.g. "PHID-XACT-TASK-xxx"), as returned by + pha_task_get_transactions. + comment: The new content of the comment. + + Returns: + Success status. + """ + client = get_client_func() + client.maniphest.edit_comment( + transaction_phid=transaction_phid, + content=comment, + ) + return {"success": True} + @mcp.tool() @handle_api_errors @optimize_token_usage From 402a7ca436d7795641e4f85ae0d29e7e029a3e3f Mon Sep 17 00:00:00 2001 From: Konstantinos Skarmoutsos Date: Mon, 31 Aug 2026 18:10:18 +0300 Subject: [PATCH 2/4] feat(projects): point the column client at project.column.edit The column client called project.column.create, project.column.edit and project.column.delete with transaction lists. None of those endpoints existed, so every method was dead code. Replace them with a single edit_column() taking flat parameters, matching the project.column.edit API added to our fork, which creates a column when no PHID is given and edits one otherwise. delete_column, update_column_name and update_column_limit are removed: Phabricator has no notion of deleting a column, and the remaining two are one edit_column() call each. Co-Authored-By: Claude Opus 5 --- conduit/client/project.py | 115 ++++++++++++-------------------------- 1 file changed, 37 insertions(+), 78 deletions(-) diff --git a/conduit/client/project.py b/conduit/client/project.py index 89466a2..8398231 100644 --- a/conduit/client/project.py +++ b/conduit/client/project.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional from conduit.client.base import BasePhabricatorClient from conduit.utils import build_search_params, build_transaction_params @@ -70,7 +70,11 @@ def create_project( return self.edit_project(transactions) def search_columns( - self, constraints: Dict[str, Any] = None, limit: int = 100 + self, + constraints: Dict[str, Any] = None, + limit: int = 100, + after: Optional[str] = None, + before: Optional[str] = None, ) -> Dict[str, Any]: """ Read information about workboard columns. @@ -78,6 +82,8 @@ def search_columns( Args: constraints: Search constraints limit: Maximum number of results to return + after: Cursor for the next page + before: Cursor for the previous page Returns: Column information @@ -85,6 +91,8 @@ def search_columns( params = build_search_params( constraints=constraints, limit=limit, + after=after, + before=before, ) return self._make_request("project.column.search", params) @@ -101,87 +109,38 @@ def query_projects(self, constraints: Dict[str, Any] = None) -> Dict[str, Any]: params = constraints or {} return self._make_request("project.query", params) - def create_column( - self, project_phid: str, name: str, limit: int = None - ) -> Dict[str, Any]: - """ - Create a new workboard column in a project. - - Args: - project_phid: PHID of the project to create column in - name: Name of the column - limit: Column limit (optional) - - Returns: - Created column data - """ - transactions = [ - {"type": "name", "value": name}, - {"type": "projectPHID", "value": project_phid}, - ] - - if limit is not None: - transactions.append({"type": "limit", "value": str(limit)}) - - params = build_transaction_params(transactions=transactions) - return self._make_request("project.column.create", params) - def edit_column( - self, column_phid: str, transactions: List[Dict[str, Any]] + self, + column_phid: Optional[str] = None, + project_phid: Optional[str] = None, + name: Optional[str] = None, + hidden: Optional[bool] = None, + limit: Optional[int] = None, + sequence: Optional[int] = None, ) -> Dict[str, Any]: """ - Edit an existing workboard column. + Create or edit a workboard column. Args: - column_phid: PHID of the column to edit - transactions: List of transaction objects + column_phid: PHID of the column to edit. Omit to create a column. + project_phid: PHID of the board, required when creating. + name: New column name, required when creating. + hidden: Whether the column is hidden from the board. + limit: Point limit for the column, 0 to remove it. + sequence: Position of the column on the board. Returns: - Updated column data - """ - params = build_transaction_params( - transactions=transactions, object_identifier=column_phid - ) - return self._make_request("project.column.edit", params) - - def delete_column(self, column_phid: str) -> Dict[str, Any]: - """ - Delete a workboard column. + The column's id, phid, name, hidden, sequence, pointLimit, + isDefault and proxyPHID. + """ + params = { + "columnPHID": column_phid, + "projectPHID": project_phid, + "name": name, + "hidden": hidden, + "limit": limit, + "sequence": sequence, + } + params = {k: v for k, v in params.items() if v is not None} - Args: - column_phid: PHID of the column to delete - - Returns: - Deletion result - """ - params = {"objectIdentifier": column_phid} - return self._make_request("project.column.delete", params) - - # Convenience methods for common column operations - def update_column_name(self, column_phid: str, new_name: str) -> Dict[str, Any]: - """ - Update the name of a workboard column. - - Args: - column_phid: PHID of the column to update - new_name: New name for the column - - Returns: - Updated column data - """ - transactions = [{"type": "name", "value": new_name}] - return self.edit_column(column_phid, transactions) - - def update_column_limit(self, column_phid: str, limit: int) -> Dict[str, Any]: - """ - Update the task limit of a workboard column. - - Args: - column_phid: PHID of the column to update - limit: New task limit for the column - - Returns: - Updated column data - """ - transactions = [{"type": "limit", "value": str(limit)}] - return self.edit_column(column_phid, transactions) + return self._make_request("project.column.edit", params) From 17657428629b7b3b7ef186616c49fad305976fe9 Mon Sep 17 00:00:00 2001 From: Konstantinos Skarmoutsos Date: Mon, 31 Aug 2026 18:10:28 +0300 Subject: [PATCH 3/4] feat(tasks): add board management tools Five additions to the task and workboard tool surface, all sharing the same transaction and paging helpers: - pha_task_update takes points, column_phid and comment. maniphest.edit has always accepted them; the tool did not expose them. - pha_task_bulk_update applies one change set to up to 500 tasks. It defaults to dry_run, returning the diff against each task's current values so a job can propose a change without holding write access. - pha_task_aggregate counts tasks by column, priority, status, owner, project, points, staleness or month, paging server-side and returning counts only. - pha_task_search_advanced takes a fields projection, so callers can ask for the handful of fields they need instead of the whole task payload. - pha_workboard_search_columns pages past the first 100 columns and reports is_hidden and proxy_phid; pha_workboard_edit_column creates, renames, limits, reorders and hides columns, also dry_run by default. Co-Authored-By: Claude Opus 5 --- conduit/client/tests/test_workboard.py | 42 ++ conduit/client/types.py | 8 + conduit/main_tools.py | 721 +++++++++++++++++++++--- conduit/tools/tests/test_board_tools.py | 441 +++++++++++++++ 4 files changed, 1143 insertions(+), 69 deletions(-) create mode 100644 conduit/tools/tests/test_board_tools.py diff --git a/conduit/client/tests/test_workboard.py b/conduit/client/tests/test_workboard.py index 3720c15..72c6955 100644 --- a/conduit/client/tests/test_workboard.py +++ b/conduit/client/tests/test_workboard.py @@ -20,6 +20,7 @@ ManiphestSearchConstraints, ManiphestTaskTransactionStatus, ) +from conduit.main_tools import _build_task_transactions pytestmark = pytest.mark.integration @@ -254,6 +255,47 @@ def test_workboard_data_structures(self): if "attachments" in task: self.assertIn("columns", task["attachments"]) + def test_edit_column_and_move_a_task_into_it(self): + """Create, move into, hide and reorder a column through project.column.edit.""" + project_phid = os.getenv("PHABRICATOR_TEST_WORKBOARD_PROJECT_PHID") + if not project_phid: + self.skipTest("PHABRICATOR_TEST_WORKBOARD_PROJECT_PHID is not set") + if "project.column.edit" not in self.project_client._make_request( + "conduit.query", {} + ): + self.skipTest("project.column.edit is not available on this server") + + column = self.project_client.edit_column( + project_phid=project_phid, name=f"{self.test_project_prefix}_column" + ) + + task = self.maniphest_client.create_task( + title=f"Column move task {time.time_ns()}" + ) + self.maniphest_client.edit_task( + object_identifier=task["phid"], + transactions=_build_task_transactions( + projects_add=[project_phid], column_phid=column["phid"] + ), + ) + moved = self.maniphest_client.search_tasks( + constraints={"phids": [task["phid"]]}, attachments={"columns": True} + )["data"][0] + board = moved["attachments"]["columns"]["boards"][project_phid] + self.assertEqual([c["phid"] for c in board["columns"]], [column["phid"]]) + + edited = self.project_client.edit_column( + column_phid=column["phid"], hidden=True, sequence=0 + ) + self.assertTrue(edited["hidden"]) + self.assertEqual(edited["sequence"], 0) + + searched = self.project_client.search_columns( + constraints={"phids": [column["phid"]]} + )["data"][0]["fields"] + self.assertTrue(searched["isHidden"]) + self.assertEqual(searched["sequence"], 0) + def _validate_column_structure(self, column): """Helper method to validate column object structure.""" # Basic column fields diff --git a/conduit/client/types.py b/conduit/client/types.py index 2ab1458..6df8637 100644 --- a/conduit/client/types.py +++ b/conduit/client/types.py @@ -650,6 +650,13 @@ class ManiphestTaskTransactionMFA(ManiphestTaskTransactionBase): value: bool +class ManiphestTaskTransactionPoints(ManiphestTaskTransactionBase): + """Set the story points of a task (null clears the value).""" + + type: Literal["points"] + value: Optional[Union[int, float]] + + class ManiphestTaskTransactionReference(ManiphestTaskTransactionBase): """Update the Reference custom field (custom.skroutz:reference).""" @@ -691,6 +698,7 @@ class ManiphestTaskTransactionDueDate(ManiphestTaskTransactionBase): ManiphestTaskTransactionSubtype, ManiphestTaskTransactionComment, ManiphestTaskTransactionMFA, + ManiphestTaskTransactionPoints, ManiphestTaskTransactionReference, ManiphestTaskTransactionDueDate, ] diff --git a/conduit/main_tools.py b/conduit/main_tools.py index d930ae4..7307f7a 100644 --- a/conduit/main_tools.py +++ b/conduit/main_tools.py @@ -1,15 +1,21 @@ +import statistics +import time +from datetime import datetime, timezone from functools import wraps -from typing import Any, Callable, Dict, List, Literal, Optional +from typing import Any, Callable, Dict, List, Literal, Optional, Tuple from fastmcp import FastMCP from conduit.client.types import ( ManiphestSearchAttachments, ManiphestSearchConstraints, + ManiphestTaskTransaction, + ManiphestTaskTransactionColumn, ManiphestTaskTransactionComment, ManiphestTaskTransactionDescription, ManiphestTaskTransactionDueDate, ManiphestTaskTransactionOwner, + ManiphestTaskTransactionPoints, ManiphestTaskTransactionPriority, ManiphestTaskTransactionProjectsAdd, ManiphestTaskTransactionProjectsRemove, @@ -212,6 +218,291 @@ def _add_task_enumeration_metadata(result: dict, *, reverse: bool = False) -> di return result +def _build_task_transactions( + title: Optional[str] = None, + description: Optional[str] = None, + priority: Optional[str] = None, + status: Optional[str] = None, + owner_phid: Optional[str] = None, + projects_add: Optional[List[str]] = None, + projects_remove: Optional[List[str]] = None, + projects_set: Optional[List[str]] = None, + reference: Optional[str] = None, + points: Optional[float] = None, + column_phid: Optional[str] = None, + comment: Optional[str] = None, + due_date: Optional[int] = None, +) -> List[ManiphestTaskTransaction]: + """Build maniphest.edit transactions for the fields that were provided.""" + transactions: List[ManiphestTaskTransaction] = [] + if title is not None: + transactions.append(ManiphestTaskTransactionTitle(type="title", value=title)) + if description is not None: + transactions.append( + ManiphestTaskTransactionDescription(type="description", value=description) + ) + if priority is not None: + transactions.append( + ManiphestTaskTransactionPriority(type="priority", value=priority) + ) + if status is not None: + transactions.append(ManiphestTaskTransactionStatus(type="status", value=status)) + if owner_phid is not None: + transactions.append( + ManiphestTaskTransactionOwner(type="owner", value=owner_phid) + ) + if projects_add is not None: + transactions.append( + ManiphestTaskTransactionProjectsAdd(type="projects.add", value=projects_add) + ) + if projects_remove is not None: + transactions.append( + ManiphestTaskTransactionProjectsRemove( + type="projects.remove", value=projects_remove + ) + ) + if projects_set is not None: + transactions.append( + ManiphestTaskTransactionProjectsSet(type="projects.set", value=projects_set) + ) + if reference is not None: + transactions.append( + ManiphestTaskTransactionReference( + type="custom.skroutz:reference", value=reference + ) + ) + if due_date is not None: + transactions.append( + ManiphestTaskTransactionDueDate( + type="custom.skroutz:due-date", value=due_date + ) + ) + if points is not None: + transactions.append(ManiphestTaskTransactionPoints(type="points", value=points)) + if column_phid is not None: + transactions.append( + ManiphestTaskTransactionColumn(type="column", value=[column_phid]) + ) + if comment is not None: + transactions.append( + ManiphestTaskTransactionComment(type="comment", value=comment) + ) + return transactions + + +def _normalize_task_id(task_id: str) -> str: + """Strip a leading T from a task monogram ("T1234" -> "1234").""" + if task_id and task_id[0] in ("T", "t") and task_id[1:].isdigit(): + return task_id[1:] + return task_id + + +def _fetch_tasks(client: PhabricatorClient, task_ids: List[str]) -> Dict[str, dict]: + """Load tasks by monogram, numeric ID or PHID, keyed by the given identifier.""" + ids = set() + phids = set() + for task_id in task_ids: + normalized = _normalize_task_id(task_id) + if normalized.isdigit(): + ids.add(int(normalized)) + else: + phids.add(normalized) + + tasks = [] + for key, values in (("ids", sorted(ids)), ("phids", sorted(phids))): + for offset in range(0, len(values), 100): + page = values[offset : offset + 100] + after = None + while True: + result = client.maniphest.search_tasks( + constraints={key: page}, after=after, limit=100 + ) + tasks.extend(result.get("data", [])) + after = (result.get("cursor") or {}).get("after") + if not after: + break + + by_id = {task["id"]: task for task in tasks} + by_phid = {task["phid"]: task for task in tasks} + + found = {} + for task_id in task_ids: + normalized = _normalize_task_id(task_id) + if normalized.isdigit(): + task = by_id.get(int(normalized)) + else: + task = by_phid.get(normalized) + if task is not None: + found[task_id] = task + return found + + +def _task_field_diff(task: dict, changes: Dict[str, Any]) -> List[dict]: + """Compare requested changes against a task's current field values.""" + fields = task.get("fields") or {} + current = { + "title": fields.get("name"), + "description": (fields.get("description") or {}).get("raw"), + "status": (fields.get("status") or {}).get("value"), + "priority": (fields.get("priority") or {}).get("name"), + "owner_phid": fields.get("ownerPHID"), + "points": fields.get("points"), + } + + diff = [] + for field, new_value in changes.items(): + old_value = current.get(field) + # Priority is read back as a display name ("High") but written as a + # keyword ("high"), so compare case-insensitively. + if field == "priority" and isinstance(old_value, str): + if old_value.lower() == str(new_value).lower(): + continue + elif field in current and old_value == new_value: + continue + + diff.append( + { + "task_id": "T{}".format(task["id"]), + "field": field, + "from": old_value, + "to": new_value, + } + ) + return diff + + +def _project_task_fields(result: dict, fields: Optional[List[str]]) -> dict: + """Keep only the named fields on each task, dropping the rest of the payload.""" + if not fields: + return result + + projected = [] + for task in result.get("data") or []: + task_fields = task.get("fields") or {} + kept = {"id": task.get("id"), "phid": task.get("phid")} + for field in fields: + if field in ("id", "phid"): + continue + if field in task_fields: + kept[field] = task_fields[field] + projected.append(kept) + + result["data"] = projected + return result + + +TaskGrouping = Literal[ + "column", + "priority", + "status", + "owner", + "project", + "points", + "staleness", + "created_month", + "closed_month", +] + +DEFAULT_STALENESS_BUCKETS = [30, 90, 180, 365, 730] + +MAX_AGGREGATE_TASKS = 5000 + +MAX_COLUMN_SEARCH_PAGES = 10 + + +def _epoch_month(timestamp: Optional[int]) -> Optional[str]: + if not timestamp: + return None + return datetime.fromtimestamp(timestamp, timezone.utc).strftime("%Y-%m") + + +def _staleness_bucket(days: float, buckets: List[int]) -> str: + for bucket in buckets: + if days < bucket: + return "<{}d".format(bucket) + return ">={}d".format(buckets[-1]) if buckets else "all" + + +def _task_group_keys( + task: dict, + group_by: TaskGrouping, + buckets: List[int], + now: float, +) -> List[Tuple[str, str]]: + """Return the (key, label) pairs a task contributes to, usually just one.""" + fields = task.get("fields") or {} + attachments = task.get("attachments") or {} + + if group_by == "status": + status = fields.get("status") or {} + return [(status.get("value") or "unknown", status.get("name") or "Unknown")] + + if group_by == "priority": + priority = fields.get("priority") or {} + return [ + (str(priority.get("value")), priority.get("name") or "Unknown"), + ] + + if group_by == "owner": + owner = fields.get("ownerPHID") + return [(owner or "unassigned", owner or "Unassigned")] + + if group_by == "points": + points = fields.get("points") + return [("none" if points is None else str(points), str(points))] + + if group_by == "staleness": + modified = fields.get("dateModified") + if not modified: + return [("unknown", "Unknown")] + label = _staleness_bucket((now - modified) / 86400, buckets) + return [(label, label)] + + if group_by == "created_month": + month = _epoch_month(fields.get("dateCreated")) + return [(month or "unknown", month or "Unknown")] + + if group_by == "closed_month": + month = _epoch_month(fields.get("dateClosed")) + return [(month or "open", month or "Still open")] + + if group_by == "project": + phids = ((attachments.get("projects") or {}).get("projectPHIDs")) or [] + return [(phid, phid) for phid in phids] or [("none", "No projects")] + + if group_by == "column": + boards = ((attachments.get("columns") or {}).get("boards")) or {} + keys = [] + for board in boards.values(): + for column in board.get("columns") or []: + keys.append((column.get("phid"), column.get("name") or "Unnamed")) + return keys or [("none", "Not on a board")] + + raise ValueError("unsupported group_by: {}".format(group_by)) + + +def _summarize_group(tasks: List[dict], now: float) -> dict: + ages = [] + unassigned = 0 + with_points = 0 + for task in tasks: + fields = task.get("fields") or {} + if not fields.get("ownerPHID"): + unassigned += 1 + if fields.get("points") is not None: + with_points += 1 + created = fields.get("dateCreated") + if created: + ages.append((now - created) / 86400) + + return { + "count": len(tasks), + "unassigned": unassigned, + "with_points": with_points, + "median_age_days": round(statistics.median(ages), 1) if ages else None, + } + + def register_tools( # noqa: C901 mcp: FastMCP, get_client_func: Callable[[], PhabricatorClient], @@ -399,6 +690,9 @@ def pha_task_update( projects_set: Optional[List[str]] = None, reference: Optional[str] = None, due_date: Optional[int] = None, + points: Optional[float] = None, + column_phid: Optional[str] = None, + comment: Optional[str] = None, ) -> dict: """ Update the metadata of a Phabricator task. @@ -415,65 +709,30 @@ def pha_task_update( projects_set: List of project PHIDs to set (overwrites current projects). reference: The new value for the task's Reference custom field. due_date: The new value for the task's Due Date custom field, as a Unix epoch timestamp. + points: The new story point value for the task. + column_phid: PHID of the workboard column to move the task into. + comment: A comment to post along with the update. Returns: Success status. """ client = get_client_func() - transactions = [] - if title is not None: - transactions.append( - ManiphestTaskTransactionTitle(type="title", value=title) - ) - if description is not None: - transactions.append( - ManiphestTaskTransactionDescription( - type="description", value=description - ) - ) - if priority is not None: - transactions.append( - ManiphestTaskTransactionPriority(type="priority", value=priority) - ) - if status is not None: - transactions.append( - ManiphestTaskTransactionStatus(type="status", value=status) - ) - if owner_phid is not None: - transactions.append( - ManiphestTaskTransactionOwner(type="owner", value=owner_phid) - ) - if projects_add is not None: - transactions.append( - ManiphestTaskTransactionProjectsAdd( - type="projects.add", value=projects_add - ) - ) - if projects_remove is not None: - transactions.append( - ManiphestTaskTransactionProjectsRemove( - type="projects.remove", value=projects_remove - ) - ) - if projects_set is not None: - transactions.append( - ManiphestTaskTransactionProjectsSet( - type="projects.set", value=projects_set - ) - ) - if reference is not None: - transactions.append( - ManiphestTaskTransactionReference( - type="custom.skroutz:reference", value=reference - ) - ) - if due_date is not None: - transactions.append( - ManiphestTaskTransactionDueDate( - type="custom.skroutz:due-date", value=due_date - ) - ) + transactions = _build_task_transactions( + title=title, + description=description, + priority=priority, + status=status, + owner_phid=owner_phid, + projects_add=projects_add, + projects_remove=projects_remove, + projects_set=projects_set, + reference=reference, + points=points, + column_phid=column_phid, + comment=comment, + due_date=due_date, + ) client.maniphest.edit_task( object_identifier=task_id, @@ -481,6 +740,234 @@ def pha_task_update( ) return {"success": True} + @mcp.tool() + @handle_api_errors + def pha_task_aggregate( + projects: Optional[List[str]] = None, + statuses: Optional[List[str]] = None, + group_by: TaskGrouping = "status", + staleness_buckets: Optional[List[int]] = None, + created_after: Optional[int] = None, + created_before: Optional[int] = None, + modified_after: Optional[int] = None, + modified_before: Optional[int] = None, + max_tasks: int = MAX_AGGREGATE_TASKS, + ) -> dict: + """ + Count tasks by group without returning the tasks themselves. + + Args: + projects: Project slugs or PHIDs. Descendant projects match too, + as they do in Conduit. + statuses: Task statuses to include, such as ["open"]. + group_by: What to count by. "column" and "project" read the + board and project attachments, so a task on several boards is + counted once per column. + staleness_buckets: Day boundaries for group_by="staleness", + defaulting to [30, 90, 180, 365, 730]. + created_after: Unix timestamp; tasks created at or after it. + created_before: Unix timestamp; tasks created at or before it. + modified_after: Unix timestamp; tasks modified at or after it. + modified_before: Unix timestamp; tasks modified at or before it. + max_tasks: Stop after reading this many tasks, at most 5000, so an + unbounded query cannot page forever. + + Returns: + The total task count and per-group counts, each with how many are + unassigned, how many carry points, and the median task age in days. + Owner and project groups are keyed by PHID, not by name. + """ + if ( + isinstance(max_tasks, bool) + or not isinstance(max_tasks, int) + or not 1 <= max_tasks <= MAX_AGGREGATE_TASKS + ): + raise ValueError( + "max_tasks must be an integer between 1 and {}".format( + MAX_AGGREGATE_TASKS + ) + ) + + buckets = sorted(staleness_buckets or DEFAULT_STALENESS_BUCKETS) + + constraints: ManiphestSearchConstraints = {} + if projects: + constraints["projects"] = projects + if statuses: + constraints["statuses"] = statuses + if created_after is not None: + constraints["createdStart"] = created_after + if created_before is not None: + constraints["createdEnd"] = created_before + if modified_after is not None: + constraints["modifiedStart"] = modified_after + if modified_before is not None: + constraints["modifiedEnd"] = modified_before + + attachments: ManiphestSearchAttachments = {} + if group_by == "column": + attachments["columns"] = True + if group_by == "project": + attachments["projects"] = True + + client = get_client_func() + + tasks = [] + after = None + while len(tasks) < max_tasks: + result = client.maniphest.search_tasks( + constraints=constraints or None, + attachments=attachments or None, + after=after, + limit=min(100, max_tasks - len(tasks)), + ) + tasks.extend(result.get("data") or []) + + after = (result.get("cursor") or {}).get("after") + if not after: + break + + now = time.time() + grouped: Dict[str, List[dict]] = {} + labels: Dict[str, str] = {} + for task in tasks: + for key, label in _task_group_keys(task, group_by, buckets, now): + grouped.setdefault(key, []).append(task) + labels[key] = label + + groups = [ + dict(key=key, label=labels[key], **_summarize_group(members, now)) + for key, members in grouped.items() + ] + groups.sort(key=lambda group: group["count"], reverse=True) + + return { + "success": True, + "total": len(tasks), + "truncated": after is not None, + "group_by": group_by, + "groups": groups, + } + + @mcp.tool() + @handle_api_errors + def pha_task_bulk_update( + task_ids: List[str], + dry_run: bool = True, + priority: Optional[str] = None, + status: Optional[str] = None, + owner_phid: Optional[str] = None, + projects_add: Optional[List[str]] = None, + projects_remove: Optional[List[str]] = None, + projects_set: Optional[List[str]] = None, + points: Optional[float] = None, + column_phid: Optional[str] = None, + comment: Optional[str] = None, + ) -> dict: + """ + Apply the same update to many Phabricator tasks, previewing by default. + + Args: + task_ids: Task monograms, numeric IDs or PHIDs, at most 500. + dry_run: When true (the default) nothing is written: the change set + is computed and returned for review. + priority: The new priority for every task. + status: The new status for every task. + owner_phid: The PHID of the new owner for every task. + projects_add: List of project PHIDs to add to every task. + projects_remove: List of project PHIDs to remove from every task. + projects_set: List of project PHIDs to set on every task. + points: The new story point value for every task. + column_phid: PHID of the workboard column to move every task into. + comment: A comment to post on every task. + + Returns: + The change set as would_change, whether it was applied, and per-task + errors. Tasks that could not be loaded are reported in errors, and a + task named more than once is edited once. Projects, column and + comment changes are always listed, with "from" set to None, because + their current values are not read. + """ + if not task_ids: + raise ValueError("task_ids must not be empty") + if len(task_ids) > 500: + raise ValueError("task_ids accepts at most 500 tasks per call") + + transactions = _build_task_transactions( + priority=priority, + status=status, + owner_phid=owner_phid, + projects_add=projects_add, + projects_remove=projects_remove, + projects_set=projects_set, + points=points, + column_phid=column_phid, + comment=comment, + ) + if not transactions: + raise ValueError("no fields to update were provided") + + client = get_client_func() + tasks = _fetch_tasks(client, task_ids) + + # _task_field_diff compares the fields maniphest.search exposes and + # lists the rest (projects, column, comment) with "from" set to None. + changes = { + field: value + for field, value in ( + ("priority", priority), + ("status", status), + ("owner_phid", owner_phid), + ("points", points), + ("projects_add", projects_add), + ("projects_remove", projects_remove), + ("projects_set", projects_set), + ("column_phid", column_phid), + ("comment", comment), + ) + if value is not None + } + + targets: Dict[str, str] = {} + would_change = [] + errors = [] + for task_id in dict.fromkeys(task_ids): + task = tasks.get(task_id) + if task is None: + errors.append({"task_id": task_id, "error": "task not found"}) + continue + if task["phid"] in targets: + continue + targets[task["phid"]] = task_id + would_change.extend(_task_field_diff(task, changes)) + + if dry_run: + return { + "success": True, + "applied": False, + "would_change": would_change, + "errors": errors, + } + + applied_to = [] + for task_phid, task_id in targets.items(): + try: + client.maniphest.edit_task( + object_identifier=task_phid, + transactions=transactions, + ) + applied_to.append(task_id) + except Exception as exc: # noqa: BLE001 - reported per task + errors.append({"task_id": task_id, "error": str(exc)}) + + return { + "success": not errors, + "applied": True, + "applied_to": applied_to, + "would_change": would_change, + "errors": errors, + } + @mcp.tool() @handle_api_errors def pha_task_add_comment(task_id: str, comment: str) -> dict: @@ -693,6 +1180,7 @@ def pha_task_search_advanced( closed_before: Optional[int] = None, before: Optional[str] = None, after: Optional[str] = None, + fields: Optional[List[str]] = None, ) -> dict: """ Advanced task search with filtering and preset options. For a cursor page, @@ -729,6 +1217,9 @@ def pha_task_search_advanced( all query-defining arguments unchanged from the prior request. after: Opaque cursor from cursor.after for the next page. Repeat all query-defining arguments unchanged from the prior request. + fields: Task fields to keep, such as ["ownerPHID", "points", + "dateModified"]. Omit to return the full task payload. Each + task always keeps its id and phid. Returns: Search results with task data and pagination metadata @@ -836,6 +1327,7 @@ def pha_task_search_advanced( ) result = _add_task_enumeration_metadata(result, reverse=before is not None) + result = _project_task_fields(result, fields) return {"success": True, "results": result} @@ -1755,6 +2247,8 @@ def pha_workboard_search_columns( project_phids: Optional[List[str]] = None, phids: Optional[List[str]] = None, limit: int = 100, + include_hidden: bool = True, + after: Optional[str] = None, ) -> dict: """ Search for workboard columns with filtering capabilities. @@ -1762,36 +2256,125 @@ def pha_workboard_search_columns( Args: project_phids: List of project PHIDs to search columns in phids: List of specific column PHIDs to search for - limit: Maximum number of results to return (default: 100, max: 1000) + limit: Maximum number of columns to return, at most 1000. Values + above 100 are served by reading successive pages, as one board + can hold more columns than a single page returns. + include_hidden: Include columns hidden from the board. Hidden + columns are returned by default. + after: Opaque cursor from cursor.after for the next page. Repeat + all other arguments unchanged from the prior request. Returns: - Search results with column data and pagination metadata + Search results with column data and pagination metadata. At most + ten pages are read per call, so with include_hidden=False a result + can hold fewer than limit columns while cursor.after is still set. """ - # Initialize None parameters to empty lists - if project_phids is None: - project_phids = [] - if phids is None: - phids = [] + if ( + isinstance(limit, bool) + or not isinstance(limit, int) + or not 1 <= limit <= 1000 + ): + raise ValueError("limit must be an integer between 1 and 1000") client = get_client_func() - # Build constraints - only use supported parameters constraints = {} - if project_phids: constraints["projects"] = project_phids if phids: constraints["phids"] = phids - result = client.project.search_columns( - constraints=constraints if constraints else None, + columns = [] + for _ in range(MAX_COLUMN_SEARCH_PAGES): + result = client.project.search_columns( + constraints=constraints if constraints else None, + limit=min(100, limit - len(columns)), + after=after, + ) + page = result.get("data") or [] + if not include_hidden: + page = [ + column + for column in page + if not (column.get("fields") or {}).get("isHidden") + ] + columns.extend(page) + + after = (result.get("cursor") or {}).get("after") + if not after or len(columns) >= limit: + break + + result["data"] = columns + result["cursor"] = {**(result.get("cursor") or {}), "limit": limit} + result = _add_pagination_metadata(result, result["cursor"]) + + return {"success": True, "columns": result} + + @mcp.tool() + @handle_api_errors + def pha_workboard_edit_column( + column_phid: Optional[str] = None, + project_phid: Optional[str] = None, + name: Optional[str] = None, + hidden: Optional[bool] = None, + sequence: Optional[int] = None, + limit: Optional[int] = None, + dry_run: bool = True, + ) -> dict: + """ + Create or edit a workboard column, previewing the change by default. + + Args: + column_phid: PHID of the column to edit. Omit to create a column. + project_phid: PHID of the board, required when creating a column. + name: New name for the column, required when creating one. + hidden: True to hide the column from the board, False to show it. + sequence: Position of the column on the board. + limit: Point limit for the column, 0 to remove the limit. + dry_run: When true (the default) nothing is written: the requested + change is returned for review. + + Returns: + The requested change and, once applied, the column's new state. + The default "Backlog" column cannot be hidden, and columns that + proxy a subproject are renamed or hidden by editing the subproject. + """ + if column_phid is None and not (project_phid and name): + raise ValueError("creating a column requires both project_phid and name") + + change = { + field: value + for field, value in ( + ("name", name), + ("hidden", hidden), + ("sequence", sequence), + ("limit", limit), + ) + if value is not None + } + if not change: + raise ValueError("no column fields to change were provided") + + if dry_run: + return { + "success": True, + "applied": False, + "creates_column": column_phid is None, + "column_phid": column_phid, + "would_change": change, + } + + client = get_client_func() + column = client.project.edit_column( + column_phid=column_phid, + project_phid=project_phid, + name=name, + hidden=hidden, limit=limit, + sequence=sequence, ) - # Add pagination metadata - result = _add_pagination_metadata(result, result.get("cursor")) - - return {"success": True, "columns": result} + return {"success": True, "applied": True, "column": column} @mcp.tool() @handle_api_errors diff --git a/conduit/tools/tests/test_board_tools.py b/conduit/tools/tests/test_board_tools.py new file mode 100644 index 0000000..343c1e7 --- /dev/null +++ b/conduit/tools/tests/test_board_tools.py @@ -0,0 +1,441 @@ +"""Unit tests for the task and workboard tools added for board management.""" + +from dataclasses import dataclass, field +from typing import Any, Dict, List + +from conduit.main_tools import ( + _build_task_transactions, + _project_task_fields, + _staleness_bucket, + _task_field_diff, + _task_group_keys, + register_tools, +) + + +class StubMCP: + def __init__(self) -> None: + self.tools: Dict[str, Any] = {} + + def tool(self, *args: Any, **kwargs: Any): + def decorator(function: Any) -> Any: + self.tools[function.__name__] = function + return function + + return decorator + + +@dataclass +class RecordingManiphest: + pages: List[Dict[str, Any]] + edits: List[Dict[str, Any]] = field(default_factory=list) + searches: List[Dict[str, Any]] = field(default_factory=list) + + def search_tasks(self, **kwargs: Any) -> Dict[str, Any]: + self.searches.append(kwargs) + return self.pages[min(len(self.searches) - 1, len(self.pages) - 1)] + + def edit_task(self, **kwargs: Any) -> Dict[str, Any]: + self.edits.append(kwargs) + return {"object": {"phid": kwargs["object_identifier"]}} + + +@dataclass +class RecordingProject: + pages: List[Dict[str, Any]] + edits: List[Dict[str, Any]] = field(default_factory=list) + searches: List[Dict[str, Any]] = field(default_factory=list) + + def search_columns(self, **kwargs: Any) -> Dict[str, Any]: + self.searches.append(kwargs) + return self.pages[min(len(self.searches) - 1, len(self.pages) - 1)] + + def edit_column(self, **kwargs: Any) -> Dict[str, Any]: + self.edits.append(kwargs) + return {"phid": "PHID-PCOL-one", "hidden": kwargs.get("hidden")} + + +@dataclass +class RecordingClient: + maniphest: RecordingManiphest + project: RecordingProject + + +def task(task_id, **fields): + return { + "id": task_id, + "phid": "PHID-TASK-{}".format(task_id), + "fields": fields, + } + + +def build_tools(task_pages=None, column_pages=None): + maniphest = RecordingManiphest(task_pages or [{"data": [], "cursor": {}}]) + project = RecordingProject(column_pages or [{"data": [], "cursor": {}}]) + mcp = StubMCP() + register_tools(mcp, lambda: RecordingClient(maniphest, project)) + return mcp.tools, maniphest, project + + +class TestTaskTransactions: + def test_builds_only_provided_fields(self) -> None: + transactions = _build_task_transactions( + points=3, column_phid="PHID-PCOL-one", comment="done" + ) + + assert transactions == [ + {"type": "points", "value": 3}, + {"type": "column", "value": ["PHID-PCOL-one"]}, + {"type": "comment", "value": "done"}, + ] + + def test_builds_nothing_when_no_fields_given(self) -> None: + assert _build_task_transactions() == [] + + +class TestTaskFieldDiff: + def test_reports_only_fields_that_change(self) -> None: + current = task( + 1, + status={"value": "open", "name": "Open"}, + ownerPHID="PHID-USER-one", + points=3, + ) + + diff = _task_field_diff( + current, {"status": "resolved", "owner_phid": "PHID-USER-one"} + ) + + assert diff == [ + {"task_id": "T1", "field": "status", "from": "open", "to": "resolved"} + ] + + def test_compares_priority_by_name_case_insensitively(self) -> None: + current = task(2, priority={"value": 80, "name": "High"}) + + assert _task_field_diff(current, {"priority": "high"}) == [] + assert len(_task_field_diff(current, {"priority": "low"})) == 1 + + +class TestBulkUpdate: + def test_dry_run_writes_nothing(self) -> None: + tools, maniphest, _ = build_tools( + [ + { + "data": [task(1, status={"value": "open", "name": "Open"})], + "cursor": {}, + } + ] + ) + + result = tools["pha_task_bulk_update"](task_ids=["T1"], status="resolved") + + assert result["applied"] is False + assert result["would_change"] == [ + {"task_id": "T1", "field": "status", "from": "open", "to": "resolved"} + ] + assert maniphest.edits == [] + + def test_applies_edits_when_dry_run_is_off(self) -> None: + tools, maniphest, _ = build_tools( + [ + { + "data": [task(1, status={"value": "open", "name": "Open"})], + "cursor": {}, + } + ] + ) + + result = tools["pha_task_bulk_update"]( + task_ids=["T1"], status="resolved", comment="stale", dry_run=False + ) + + assert result["applied"] is True + assert result["applied_to"] == ["T1"] + assert maniphest.edits[0]["object_identifier"] == "PHID-TASK-1" + assert maniphest.edits[0]["transactions"] == [ + {"type": "status", "value": "resolved"}, + {"type": "comment", "value": "stale"}, + ] + + def test_dry_run_lists_writes_it_cannot_diff(self) -> None: + tools, maniphest, _, _ = build_tools([{"data": [task(1)], "cursor": {}}]) + + result = tools["pha_task_bulk_update"]( + task_ids=["T1"], projects_add=["PHID-PROJ-a"], comment="stale" + ) + + assert result["would_change"] == [ + { + "task_id": "T1", + "field": "projects_add", + "from": None, + "to": ["PHID-PROJ-a"], + }, + {"task_id": "T1", "field": "comment", "from": None, "to": "stale"}, + ] + assert maniphest.edits == [] + + def test_edits_a_task_once_whatever_it_is_called(self) -> None: + tools, maniphest, _, _ = build_tools([{"data": [task(1)], "cursor": {}}]) + + result = tools["pha_task_bulk_update"]( + task_ids=["T1", "1", "PHID-TASK-1", "T1"], + comment="stale", + dry_run=False, + ) + + assert result["errors"] == [] + assert result["applied_to"] == ["T1"] + assert len(result["would_change"]) == 1 + assert [edit["object_identifier"] for edit in maniphest.edits] == [ + "PHID-TASK-1" + ] + + def test_reports_tasks_that_do_not_exist(self) -> None: + tools, _, _ = build_tools([{"data": [], "cursor": {}}]) + + result = tools["pha_task_bulk_update"](task_ids=["T404"], status="resolved") + + assert result["errors"] == [{"task_id": "T404", "error": "task not found"}] + + def test_rejects_an_empty_change_set(self) -> None: + tools, _, _ = build_tools() + + result = tools["pha_task_bulk_update"](task_ids=["T1"]) + + assert result["success"] is False + assert "no fields to update" in result["error"] + + def test_rejects_more_than_five_hundred_tasks(self) -> None: + tools, maniphest, _ = build_tools() + + result = tools["pha_task_bulk_update"]( + task_ids=[str(i) for i in range(501)], status="resolved" + ) + + assert result["success"] is False + assert "at most 500" in result["error"] + assert maniphest.searches == [] + + +class TestGrouping: + def test_buckets_staleness_by_day_boundaries(self) -> None: + buckets = [30, 90] + + assert _staleness_bucket(10, buckets) == "<30d" + assert _staleness_bucket(30, buckets) == "<90d" + assert _staleness_bucket(365, buckets) == ">=90d" + + def test_counts_a_task_once_per_column(self) -> None: + board_task = { + "id": 1, + "phid": "PHID-TASK-1", + "fields": {}, + "attachments": { + "columns": { + "boards": { + "PHID-PROJ-a": { + "columns": [ + {"phid": "PHID-PCOL-a", "name": "Backlog"}, + {"phid": "PHID-PCOL-b", "name": "Doing"}, + ] + } + } + } + }, + } + + keys = _task_group_keys(board_task, "column", [30], now=0) + + assert keys == [("PHID-PCOL-a", "Backlog"), ("PHID-PCOL-b", "Doing")] + + def test_groups_tasks_without_an_owner_as_unassigned(self) -> None: + assert _task_group_keys(task(1), "owner", [30], now=0) == [ + ("unassigned", "Unassigned") + ] + + +class TestAggregate: + def test_counts_and_summarises_each_group(self) -> None: + day = 86400 + now = 100 * day + tools, _, _ = build_tools( + [ + { + "data": [ + task( + 1, + status={"value": "open", "name": "Open"}, + dateCreated=now - 10 * day, + points=3, + ownerPHID="PHID-USER-one", + ), + task( + 2, + status={"value": "open", "name": "Open"}, + dateCreated=now - 30 * day, + ), + task( + 3, + status={"value": "resolved", "name": "Resolved"}, + dateCreated=now - 50 * day, + ), + ], + "cursor": {}, + } + ] + ) + + result = tools["pha_task_aggregate"](group_by="status") + + assert result["total"] == 3 + assert result["truncated"] is False + open_group = result["groups"][0] + assert open_group["key"] == "open" + assert open_group["count"] == 2 + assert open_group["unassigned"] == 1 + assert open_group["with_points"] == 1 + + def test_stops_at_max_tasks(self) -> None: + page = { + "data": [task(i) for i in range(100)], + "cursor": {"after": "more"}, + } + tools, maniphest, _ = build_tools([page]) + + result = tools["pha_task_aggregate"](group_by="status", max_tasks=200) + + assert result["total"] == 200 + assert result["truncated"] is True + assert len(maniphest.searches) == 2 + + def test_rejects_max_tasks_above_the_ceiling(self) -> None: + tools, maniphest, _, _ = build_tools() + + result = tools["pha_task_aggregate"](group_by="status", max_tasks=10000) + + assert result["success"] is False + assert "between 1 and 5000" in result["error"] + assert maniphest.searches == [] + + +class TestFieldProjection: + def test_keeps_only_requested_fields(self) -> None: + result = {"data": [task(1, name="Fix it", points=3, ownerPHID="PHID-USER-one")]} + + projected = _project_task_fields(result, ["points"]) + + assert projected["data"] == [{"id": 1, "phid": "PHID-TASK-1", "points": 3}] + + def test_returns_the_full_payload_when_no_fields_given(self) -> None: + result = {"data": [task(1, name="Fix it")]} + + assert _project_task_fields(result, None) == result + + +class TestColumnTools: + def test_pages_past_the_first_hundred_columns(self) -> None: + first = { + "data": [ + {"phid": "PHID-PCOL-{}".format(i), "fields": {}} for i in range(100) + ], + "cursor": {"after": "page-2"}, + } + second = { + "data": [{"phid": "PHID-PCOL-x", "fields": {}}], + "cursor": {"after": None}, + } + tools, _, project = build_tools(column_pages=[first, second]) + + result = tools["pha_workboard_search_columns"](limit=150) + + assert len(result["columns"]["data"]) == 101 + assert result["columns"]["pagination"]["has_more"] is False + assert project.searches[1]["after"] == "page-2" + + def test_continues_from_a_cursor(self) -> None: + page = { + "data": [{"phid": "PHID-PCOL-a", "fields": {}}], + "cursor": {"after": "page-3"}, + } + tools, _, project, _ = build_tools(column_pages=[page]) + + result = tools["pha_workboard_search_columns"](limit=1, after="page-2") + + assert project.searches[0]["after"] == "page-2" + assert result["columns"]["cursor"]["after"] == "page-3" + assert result["columns"]["pagination"]["has_more"] is True + + def test_bounds_the_scan_for_visible_columns(self) -> None: + page = { + "data": [{"phid": "PHID-PCOL-h", "fields": {"isHidden": True}}], + "cursor": {"after": "more"}, + } + tools, _, project, _ = build_tools(column_pages=[page]) + + result = tools["pha_workboard_search_columns"](limit=1, include_hidden=False) + + assert len(project.searches) == 10 + assert result["columns"]["data"] == [] + assert result["columns"]["cursor"]["after"] == "more" + + def test_drops_hidden_columns_when_asked(self) -> None: + page = { + "data": [ + {"phid": "PHID-PCOL-a", "fields": {"isHidden": False}}, + {"phid": "PHID-PCOL-b", "fields": {"isHidden": True}}, + ], + "cursor": {}, + } + tools, _, _ = build_tools(column_pages=[page]) + + result = tools["pha_workboard_search_columns"](include_hidden=False) + + assert [column["phid"] for column in result["columns"]["data"]] == [ + "PHID-PCOL-a" + ] + + def test_edit_column_dry_run_writes_nothing(self) -> None: + tools, _, project = build_tools() + + result = tools["pha_workboard_edit_column"]( + column_phid="PHID-PCOL-one", hidden=True + ) + + assert result == { + "success": True, + "applied": False, + "creates_column": False, + "column_phid": "PHID-PCOL-one", + "would_change": {"hidden": True}, + } + assert project.edits == [] + + def test_edit_column_applies_the_change(self) -> None: + tools, _, project = build_tools() + + result = tools["pha_workboard_edit_column"]( + column_phid="PHID-PCOL-one", hidden=True, dry_run=False + ) + + assert result["applied"] is True + assert project.edits == [ + { + "column_phid": "PHID-PCOL-one", + "project_phid": None, + "name": None, + "hidden": True, + "limit": None, + "sequence": None, + } + ] + + def test_creating_a_column_requires_a_project_and_name(self) -> None: + tools, _, project = build_tools() + + result = tools["pha_workboard_edit_column"](name="Done") + + assert result["success"] is False + assert "project_phid and name" in result["error"] + assert project.edits == [] From 16edaacd73400a71032910bbbcf29763e4826a27 Mon Sep 17 00:00:00 2001 From: Konstantinos Skarmoutsos Date: Thu, 3 Sep 2026 14:11:20 +0300 Subject: [PATCH 4/4] feat(dashboards): add pha_dashboard_edit_panel Wrap dashboard.panel.edit, the only dashboard endpoint upstream exposes, so an agent can keep a hand-built dashboard's panels current: rename one, repoint a query panel at another saved query, or refresh the text of a panel holding the task standard. Like the other write tools it defaults to dry_run. The tool edits only; it takes no panel_type and cannot create. PhabricatorDash- boardPanelEditEngine sets the panel type from setPanelType(), which only the web controllers call, so a panel created over Conduit would have no type and no properties. There is also no dashboard.panel.search and PhabricatorDashboardPanel does not implement PhabricatorConduitResultInterface, so panels cannot be read back: the caller needs the panel PHID from its UI page, and a dry run reports the transactions it would send rather than a diff. Co-Authored-By: Claude Opus 5 --- conduit/client/misc.py | 27 ++++++++ conduit/client/unified.py | 5 ++ conduit/main_tools.py | 69 +++++++++++++++++++++ conduit/tools/tests/test_board_tools.py | 82 ++++++++++++++++++++----- 4 files changed, 169 insertions(+), 14 deletions(-) diff --git a/conduit/client/misc.py b/conduit/client/misc.py index 7fa9ca1..dc84024 100644 --- a/conduit/client/misc.py +++ b/conduit/client/misc.py @@ -131,6 +131,33 @@ def send_message( return self._make_request("harbormaster.sendmessage", params) +class DashboardClient(BasePhabricatorClient): + """ + Client for Dashboard API operations. + + Only "dashboard.panel.edit" exists upstream: dashboards themselves and the + panel layout are UI-only, and panels cannot be read back over Conduit. + """ + + def edit_panel( + self, panel_phid: str, transactions: List[Dict[str, Any]] + ) -> Dict[str, Any]: + """ + Apply transactions to an existing dashboard panel. + + Args: + panel_phid: PHID of the panel to edit + transactions: List of transaction objects + + Returns: + The edited panel's object and applied transactions + """ + params = build_transaction_params( + transactions=transactions, object_identifier=panel_phid + ) + return self._make_request("dashboard.panel.edit", params) + + class PasteClient(BasePhabricatorClient): """ Client for Paste (Code Snippets) API operations. diff --git a/conduit/client/unified.py b/conduit/client/unified.py index 499e349..6672958 100644 --- a/conduit/client/unified.py +++ b/conduit/client/unified.py @@ -14,6 +14,7 @@ from conduit.client.passphrase import PassphraseClient from conduit.client.misc import ( ConduitClient, + DashboardClient, FlagClient, HarbormasterClient, MacroClient, @@ -307,6 +308,7 @@ def __init__( self.macro = MacroClient(api_url, api_token, self.http_client) self.flag = FlagClient(api_url, api_token, self.http_client) self.phid = PhidClient(api_url, api_token, self.http_client) + self.dashboard = DashboardClient(api_url, api_token, self.http_client) @retry_request(max_retries=3, retry_delay=1.0, retry_backoff=2.0) @cached_request(ttl=300) @@ -455,6 +457,9 @@ def __init__( self.phid = PhidClient( api_url, api_token, self.http_client, oauth_token=oauth_token ) + self.dashboard = DashboardClient( + api_url, api_token, self.http_client, oauth_token=oauth_token + ) def get_stats(self) -> Dict[str, Any]: """Get client statistics if enhanced features are enabled.""" diff --git a/conduit/main_tools.py b/conduit/main_tools.py index 7307f7a..c49d194 100644 --- a/conduit/main_tools.py +++ b/conduit/main_tools.py @@ -2464,6 +2464,75 @@ def pha_workboard_search_tasks_by_column( result = _add_task_enumeration_metadata(result, reverse=before is not None) return {"success": True, "tasks": result} + @mcp.tool() + @handle_api_errors + def pha_dashboard_edit_panel( + panel_phid: str, + name: Optional[str] = None, + text: Optional[str] = None, + query_key: Optional[str] = None, + query_engine: Optional[str] = None, + item_limit: Optional[int] = None, + dry_run: bool = True, + ) -> dict: + """ + Edit an existing dashboard panel, previewing the change by default. + + Panels are created and placed on a dashboard from the web UI: Conduit + exposes no way to set a panel's type, and no way to read panels back, + so this tool only updates a panel whose PHID you already have. + + Args: + panel_phid: PHID of the panel to edit, from its UI page. + name: New panel name. + text: New body for a text panel, in remarkup. + query_key: Saved query key a query panel reads, such as "assigned". + query_engine: Search engine class a query panel searches with, such + as "ManiphestTaskSearchEngine". + item_limit: How many items a query panel lists. + dry_run: When true (the default) nothing is written: the + transactions that would be sent are returned for review. + + Returns: + The transactions that would be, or were, applied. A field that does + not belong to the panel's type is rejected by the server, since the + panel type cannot be checked from here first. + """ + transactions = [ + {"type": transaction_type, "value": value} + for transaction_type, value in ( + ("name", name), + ("custom.text", text), + ("custom.key", query_key), + ("custom.class", query_engine), + ("custom.limit", item_limit), + ) + if value is not None + ] + if not transactions: + raise ValueError("no panel fields to change were provided") + + if dry_run: + return { + "success": True, + "applied": False, + "panel_phid": panel_phid, + "would_apply": transactions, + } + + client = get_client_func() + result = client.dashboard.edit_panel( + panel_phid=panel_phid, + transactions=transactions, + ) + + return { + "success": True, + "applied": True, + "panel_phid": panel_phid, + "result": result, + } + from conduit.tools.phriction_tools import register_phriction_tools register_phriction_tools(mcp, get_client_func) diff --git a/conduit/tools/tests/test_board_tools.py b/conduit/tools/tests/test_board_tools.py index 343c1e7..0de8afc 100644 --- a/conduit/tools/tests/test_board_tools.py +++ b/conduit/tools/tests/test_board_tools.py @@ -55,10 +55,20 @@ def edit_column(self, **kwargs: Any) -> Dict[str, Any]: return {"phid": "PHID-PCOL-one", "hidden": kwargs.get("hidden")} +@dataclass +class RecordingDashboard: + edits: List[Dict[str, Any]] = field(default_factory=list) + + def edit_panel(self, **kwargs: Any) -> Dict[str, Any]: + self.edits.append(kwargs) + return {"object": {"phid": kwargs["panel_phid"]}} + + @dataclass class RecordingClient: maniphest: RecordingManiphest project: RecordingProject + dashboard: RecordingDashboard def task(task_id, **fields): @@ -72,9 +82,10 @@ def task(task_id, **fields): def build_tools(task_pages=None, column_pages=None): maniphest = RecordingManiphest(task_pages or [{"data": [], "cursor": {}}]) project = RecordingProject(column_pages or [{"data": [], "cursor": {}}]) + dashboard = RecordingDashboard() mcp = StubMCP() - register_tools(mcp, lambda: RecordingClient(maniphest, project)) - return mcp.tools, maniphest, project + register_tools(mcp, lambda: RecordingClient(maniphest, project, dashboard)) + return mcp.tools, maniphest, project, dashboard class TestTaskTransactions: @@ -119,7 +130,7 @@ def test_compares_priority_by_name_case_insensitively(self) -> None: class TestBulkUpdate: def test_dry_run_writes_nothing(self) -> None: - tools, maniphest, _ = build_tools( + tools, maniphest, _, _ = build_tools( [ { "data": [task(1, status={"value": "open", "name": "Open"})], @@ -137,7 +148,7 @@ def test_dry_run_writes_nothing(self) -> None: assert maniphest.edits == [] def test_applies_edits_when_dry_run_is_off(self) -> None: - tools, maniphest, _ = build_tools( + tools, maniphest, _, _ = build_tools( [ { "data": [task(1, status={"value": "open", "name": "Open"})], @@ -193,14 +204,14 @@ def test_edits_a_task_once_whatever_it_is_called(self) -> None: ] def test_reports_tasks_that_do_not_exist(self) -> None: - tools, _, _ = build_tools([{"data": [], "cursor": {}}]) + tools, _, _, _ = build_tools([{"data": [], "cursor": {}}]) result = tools["pha_task_bulk_update"](task_ids=["T404"], status="resolved") assert result["errors"] == [{"task_id": "T404", "error": "task not found"}] def test_rejects_an_empty_change_set(self) -> None: - tools, _, _ = build_tools() + tools, _, _, _ = build_tools() result = tools["pha_task_bulk_update"](task_ids=["T1"]) @@ -208,7 +219,7 @@ def test_rejects_an_empty_change_set(self) -> None: assert "no fields to update" in result["error"] def test_rejects_more_than_five_hundred_tasks(self) -> None: - tools, maniphest, _ = build_tools() + tools, maniphest, _, _ = build_tools() result = tools["pha_task_bulk_update"]( task_ids=[str(i) for i in range(501)], status="resolved" @@ -260,7 +271,7 @@ class TestAggregate: def test_counts_and_summarises_each_group(self) -> None: day = 86400 now = 100 * day - tools, _, _ = build_tools( + tools, _, _, _ = build_tools( [ { "data": [ @@ -302,7 +313,7 @@ def test_stops_at_max_tasks(self) -> None: "data": [task(i) for i in range(100)], "cursor": {"after": "more"}, } - tools, maniphest, _ = build_tools([page]) + tools, maniphest, _, _ = build_tools([page]) result = tools["pha_task_aggregate"](group_by="status", max_tasks=200) @@ -346,7 +357,7 @@ def test_pages_past_the_first_hundred_columns(self) -> None: "data": [{"phid": "PHID-PCOL-x", "fields": {}}], "cursor": {"after": None}, } - tools, _, project = build_tools(column_pages=[first, second]) + tools, _, project, _ = build_tools(column_pages=[first, second]) result = tools["pha_workboard_search_columns"](limit=150) @@ -388,7 +399,7 @@ def test_drops_hidden_columns_when_asked(self) -> None: ], "cursor": {}, } - tools, _, _ = build_tools(column_pages=[page]) + tools, _, _, _ = build_tools(column_pages=[page]) result = tools["pha_workboard_search_columns"](include_hidden=False) @@ -397,7 +408,7 @@ def test_drops_hidden_columns_when_asked(self) -> None: ] def test_edit_column_dry_run_writes_nothing(self) -> None: - tools, _, project = build_tools() + tools, _, project, _ = build_tools() result = tools["pha_workboard_edit_column"]( column_phid="PHID-PCOL-one", hidden=True @@ -413,7 +424,7 @@ def test_edit_column_dry_run_writes_nothing(self) -> None: assert project.edits == [] def test_edit_column_applies_the_change(self) -> None: - tools, _, project = build_tools() + tools, _, project, _ = build_tools() result = tools["pha_workboard_edit_column"]( column_phid="PHID-PCOL-one", hidden=True, dry_run=False @@ -432,10 +443,53 @@ def test_edit_column_applies_the_change(self) -> None: ] def test_creating_a_column_requires_a_project_and_name(self) -> None: - tools, _, project = build_tools() + tools, _, project, _ = build_tools() result = tools["pha_workboard_edit_column"](name="Done") assert result["success"] is False assert "project_phid and name" in result["error"] assert project.edits == [] + + +class TestDashboardPanelTool: + def test_dry_run_writes_nothing(self) -> None: + tools, _, _, dashboard = build_tools() + + result = tools["pha_dashboard_edit_panel"]( + panel_phid="PHID-DSHP-one", name="Board health" + ) + + assert result["applied"] is False + assert result["would_apply"] == [{"type": "name", "value": "Board health"}] + assert dashboard.edits == [] + + def test_maps_panel_fields_to_transactions(self) -> None: + tools, _, _, dashboard = build_tools() + + tools["pha_dashboard_edit_panel"]( + panel_phid="PHID-DSHP-one", + text="The task standard", + query_key="assigned", + item_limit=10, + dry_run=False, + ) + + assert dashboard.edits == [ + { + "panel_phid": "PHID-DSHP-one", + "transactions": [ + {"type": "custom.text", "value": "The task standard"}, + {"type": "custom.key", "value": "assigned"}, + {"type": "custom.limit", "value": 10}, + ], + } + ] + + def test_rejects_an_empty_change_set(self) -> None: + tools, _, _, _ = build_tools() + + result = tools["pha_dashboard_edit_panel"](panel_phid="PHID-DSHP-one") + + assert result["success"] is False + assert "no panel fields to change" in result["error"]