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/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/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) 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/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/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 29ed28a..c49d194 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: @@ -506,6 +993,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 @@ -671,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, @@ -707,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 @@ -814,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} @@ -1733,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. @@ -1740,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 @@ -1859,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 new file mode 100644 index 0000000..0de8afc --- /dev/null +++ b/conduit/tools/tests/test_board_tools.py @@ -0,0 +1,495 @@ +"""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 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): + 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": {}}]) + dashboard = RecordingDashboard() + mcp = StubMCP() + register_tools(mcp, lambda: RecordingClient(maniphest, project, dashboard)) + return mcp.tools, maniphest, project, dashboard + + +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 == [] + + +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"]