diff --git a/apps/pi/pi/agents/sql_agent/base.py b/apps/pi/pi/agents/sql_agent/base.py index 97e569ff73..eeeb15d793 100644 --- a/apps/pi/pi/agents/sql_agent/base.py +++ b/apps/pi/pi/agents/sql_agent/base.py @@ -43,17 +43,17 @@ from pi.services.llm.error_handling import llm_error_handler from pi.services.llm.llms import get_sql_agent_llm from pi.services.schemas.chat import QueryFlowStore +from .helpers import _fix_group_by_order_by_mismatch_parsed +from .helpers import _get_available_tables +from .helpers import execute_sql_query +from .helpers import fix_group_by_order_by_mismatch +from .helpers import format_column_context +from .helpers import generate_cte_query +from .helpers import get_column_details +from .helpers import get_table_schemas from .prompts import TABLE_SELECTION from .prompts import get_sql_generator from .schemas import TableSelectionResponse -from .tools import _fix_group_by_order_by_mismatch_parsed -from .tools import _get_available_tables -from .tools import execute_sql_query -from .tools import fix_group_by_order_by_mismatch -from .tools import format_column_context -from .tools import generate_cte_query -from .tools import get_column_details -from .tools import get_table_schemas log = logger.getChild(__name__) console = Console() diff --git a/apps/pi/pi/agents/sql_agent/tools.py b/apps/pi/pi/agents/sql_agent/helpers.py similarity index 96% rename from apps/pi/pi/agents/sql_agent/tools.py rename to apps/pi/pi/agents/sql_agent/helpers.py index eb907bdac9..f709dd238d 100644 --- a/apps/pi/pi/agents/sql_agent/tools.py +++ b/apps/pi/pi/agents/sql_agent/helpers.py @@ -161,6 +161,14 @@ def format_column_context(column_context: Dict[str, Any]) -> str: distinct_values = column["distinct_values_in_column"] distinct_formatted = ", ".join(f"`{value}`" for value in distinct_values) formatted_lines.append(f" - **Distinct Values:** {distinct_formatted}") + elif "computed_status_conditions" in column: + conditions = column["computed_status_conditions"] + formatted_lines.append(f" - **Computed Status Conditions:**") + for cond in conditions: + status = cond.get("status", "N/A") + cond_desc = cond.get("description", "No description") + sql_condition = cond.get("sql_condition", "N/A") + formatted_lines.append(f" - **`{status}`** ({cond_desc}): `{sql_condition}`") formatted_lines.append("") return "\n".join(formatted_lines) @@ -1097,6 +1105,31 @@ async def construct_action_entity_url( else: return None + elif entity_type == "workspace": + # For workspaces: /workspace_slug/ + url = f"{api_base_url}/{workspace_slug}/" + return {"entity_url": url, "entity_name": entity_name, "entity_type": entity_type, "entity_id": entity_id} + + elif entity_type == "sticky": + # For stickies: /workspace_slug/stickies/ + url = f"{api_base_url}/{workspace_slug}/stickies/" + return {"entity_url": url, "entity_name": entity_name, "entity_type": entity_type, "entity_id": entity_id} + + elif entity_type == "initiative": + # For initiatives: /workspace_slug/initiatives/ + url = f"{api_base_url}/{workspace_slug}/initiatives/" + return {"entity_url": url, "entity_name": entity_name, "entity_type": entity_type, "entity_id": entity_id} + + elif entity_type == "teamspace": + # For teamspaces: /workspace_slug/teamspaces/ + url = f"{api_base_url}/{workspace_slug}/teamspaces/" + return {"entity_url": url, "entity_name": entity_name, "entity_type": entity_type, "entity_id": entity_id} + + elif entity_type == "customer": + # For customers: /workspace_slug/customers/ + url = f"{api_base_url}/{workspace_slug}/customers/" + return {"entity_url": url, "entity_name": entity_name, "entity_type": entity_type, "entity_id": entity_id} + else: # Unknown entity type return None diff --git a/apps/pi/pi/agents/sql_agent/prompts.py b/apps/pi/pi/agents/sql_agent/prompts.py index 5884ef534b..228c9c87f6 100644 --- a/apps/pi/pi/agents/sql_agent/prompts.py +++ b/apps/pi/pi/agents/sql_agent/prompts.py @@ -16,7 +16,7 @@ from typing import Any # Lazy import to avoid circular dependency # from pi.services.chat.prompts import plane_context -from pi.agents.sql_agent.tools import format_table_details +from pi.agents.sql_agent.helpers import format_table_details table_description_content: dict[str, dict[str, Any]] = json.loads(read_text("pi.agents.sql_agent.store", "table-descriptions.json")) table_descriptions = format_table_details(table_description_content) diff --git a/apps/pi/pi/agents/sql_agent/store/table-columns-context.json b/apps/pi/pi/agents/sql_agent/store/table-columns-context.json index 269dfb85e9..88d187b5db 100644 --- a/apps/pi/pi/agents/sql_agent/store/table-columns-context.json +++ b/apps/pi/pi/agents/sql_agent/store/table-columns-context.json @@ -1,245 +1,273 @@ [ - { - "table": "intake_issues", - "columns": [ - { - "name": "status", - "description": "Enumerated states representing the status of an issue in intake", - "enumerations": [ - { - "identifier": -2, - "description": "Pending" - }, - { - "identifier": -1, - "description": "Rejected" - }, - { - "identifier": 0, - "description": "Snoozed" - }, - { - "identifier": 1, - "description": "Accepted" - }, - { - "identifier": 2, - "description": "Duplicate" - } - ] - } + { + "table": "intake_issues", + "columns": [ + { + "name": "status", + "description": "Enumerated states representing the status of an issue in intake", + "enumerations": [ + { + "identifier": -2, + "description": "Pending" + }, + { + "identifier": -1, + "description": "Rejected" + }, + { + "identifier": 0, + "description": "Snoozed" + }, + { + "identifier": 1, + "description": "Accepted" + }, + { + "identifier": 2, + "description": "Duplicate" + } ] - }, - { - "table": "project_members", - "columns": [ - { - "name": "role", - "description": "Enumerated role representing the role of a memebr in project", - "enumerations": [ - { - "identifier": 5, - "description": "Guest" - }, - { - "identifier": 15, - "description": "Member" - }, - { - "identifier": 20, - "description": "Admin" - } - ] - } + } + ] + }, + { + "table": "project_members", + "columns": [ + { + "name": "role", + "description": "Enumerated role representing the role of a memebr in project", + "enumerations": [ + { + "identifier": 5, + "description": "Guest" + }, + { + "identifier": 15, + "description": "Member" + }, + { + "identifier": 20, + "description": "Admin" + } ] - }, - { - "table": "workspace_members", - "columns": [ - { - "name": "role", - "description": "Enumerated role representing the role of a memebr in workspace", - "enumerations": [ - { - "identifier": 5, - "description": "Guest" - }, - { - "identifier": 15, - "description": "Member" - }, - { - "identifier": 20, - "description": "Admin" - } - ] - } + } + ] + }, + { + "table": "workspace_members", + "columns": [ + { + "name": "role", + "description": "Enumerated role representing the role of a memebr in workspace", + "enumerations": [ + { + "identifier": 5, + "description": "Guest" + }, + { + "identifier": 15, + "description": "Member" + }, + { + "identifier": 20, + "description": "Admin" + } ] - }, - { - "table": "issue_relations", - "columns": [ - { - "name": "relation_type", - "description": "The type of relationship between two issues.", - "distinct_values_in_column": [ - "duplicate", - "finish_before", - "blocked_by", - "start_before", - "relates_to" - ] - } + } + ] + }, + { + "table": "issue_relations", + "columns": [ + { + "name": "relation_type", + "description": "The type of relationship between two issues.", + "distinct_values_in_column": ["duplicate", "finish_before", "blocked_by", "start_before", "relates_to"] + } + ] + }, + { + "table": "issue_activities", + "columns": [ + { + "name": "verb", + "description": "The action that was performed on field present in 'field' column for the given issue.", + "distinct_values_in_column": ["created", "deleted", "removed", "updated"] + }, + { + "name": "field", + "description": "The name of the field on which activity is performed.", + "distinct_values_in_column": [ + "archived_at", + "assignees", + "attachment", + "blocked_by", + "blocking", + "comment", + "cycles", + "description", + "draft", + "duplicate", + "estimate_point", + "finish_before", + "finish_after", + "intake", + "issue", + "labels", + "link", + "modules", + "name", + "parent", + "priority", + "project", + "reaction", + "relates_to", + "sequence_id", + "start_before", + "start_after", + "start_date", + "state", + "target_date", + "type", + "vote" ] - }, - { - "table": "issue_activities", - "columns": [ - { - "name": "verb", - "description": "The action that was performed on field present in 'field' column for the given issue.", - "distinct_values_in_column": [ - "created", - "deleted", - "removed", - "updated" - ] - }, - { - "name": "field", - "description": "The name of the field on which activity is performed.", - "distinct_values_in_column": [ - "archived_at", - "assignees", - "attachment", - "blocked_by", - "blocking", - "comment", - "cycles", - "description", - "draft", - "duplicate", - "estimate_point", - "finish_before", - "finish_after", - "intake", - "issue", - "labels", - "link", - "modules", - "name", - "parent", - "priority", - "project", - "reaction", - "relates_to", - "sequence_id", - "start_before", - "start_after", - "start_date", - "state", - "target_date", - "type", - "vote" - ] - }, - { - "name": "new_value", - "description": "The new value (string) of the field after the update." - }, - { - "name": "old_value", - "description": "The old value (string) of the field before the update." - }, - { - "name": "new_identifier", - "description": "The identifier (UUID) of the new value of the field after the update." - }, - { - "name": "old_identifier", - "description": "The identifier (UUID) of the old value of the field before the update." - } + }, + { + "name": "new_value", + "description": "The new value (string) of the field after the update." + }, + { + "name": "old_value", + "description": "The old value (string) of the field before the update." + }, + { + "name": "new_identifier", + "description": "The identifier (UUID) of the new value of the field after the update." + }, + { + "name": "old_identifier", + "description": "The identifier (UUID) of the old value of the field before the update." + } + ] + }, + { + "table": "states", + "columns": [ + { + "name": "group", + "description": "The name of the group to which the issue state belongs.", + "distinct_values_in_column": ["started", "backlog", "completed", "cancelled", "unstarted"] + } + ] + }, + { + "table": "issue_attachments", + "columns": [ + { + "name": "attributes", + "description": "Attributes of the attachment (size, name).", + "fields_in_jsonb_object": ["name", "size"] + } + ] + }, + { + "table": "project_states", + "columns": [ + { + "name": "group", + "description": "The name of the group to which the project state belongs.", + "distinct_values_in_column": ["monitoring", "draft", "completed", "cancelled", "planning", "execution"] + } + ] + }, + { + "table": "projects", + "columns": [ + { + "name": "network", + "description": "Enumerated numbers representing the visibility of a project (public/ private).", + "enumerations": [ + { + "identifier": 0, + "description": "Private" + }, + { + "identifier": 2, + "description": "Public" + } ] - }, - { - "table": "states", - "columns": [ - { - "name": "group", - "description": "The name of the group to which the issue state belongs.", - "distinct_values_in_column": [ - "started", - "backlog", - "completed", - "cancelled", - "unstarted" - ] - } + } + ] + }, + { + "table": "issues", + "columns": [ + { + "name": "priority", + "description": "The priority level assigned to the issue, stored as lowercase text", + "distinct_values_in_column": ["urgent", "high", "medium", "low", "none"] + } + ] + }, + { + "table": "cycles", + "columns": [ + { + "name": "status", + "description": "IMPORTANT: The 'cycles' table does NOT have a 'status' column. Status is computed dynamically based on 'start_date' and 'end_date'. Use the following conditions to filter by status", + "computed_status_conditions": [ + { + "status": "current", + "description": "Currently active cycle", + "sql_condition": "start_date <= NOW() AND end_date >= NOW()" + }, + { + "status": "active", + "description": "Alias for 'current' - currently active cycle", + "sql_condition": "start_date <= NOW() AND end_date >= NOW()" + }, + { + "status": "ongoing", + "description": "Alias for 'current' - currently active cycle", + "sql_condition": "start_date <= NOW() AND end_date >= NOW()" + }, + { + "status": "in-progress", + "description": "Alias for 'current' - currently active cycle", + "sql_condition": "start_date <= NOW() AND end_date >= NOW()" + }, + { + "status": "upcoming", + "description": "Cycle that hasn't started yet", + "sql_condition": "start_date > NOW()" + }, + { + "status": "completed", + "description": "Cycle that has ended", + "sql_condition": "end_date < NOW()" + }, + { + "status": "draft", + "description": "Cycle without start or end dates", + "sql_condition": "start_date IS NULL AND end_date IS NULL" + }, + { + "status": "incomplete", + "description": "Cycle that is not yet completed (current, upcoming, or draft)", + "sql_condition": "end_date >= NOW() OR end_date IS NULL" + } ] - }, - { - "table": "issue_attachments", - "columns": [ - { - "name": "attributes", - "description": "Attributes of the attachment (size, name).", - "fields_in_jsonb_object": [ - "name", - "size" - ] - } - ] - }, - { - "table": "project_states", - "columns": [ - { - "name": "group", - "description": "The name of the group to which the project state belongs.", - "distinct_values_in_column": [ - "monitoring", - "draft", - "completed", - "cancelled", - "planning", - "execution" - ] - } - ] - }, - { - "table": "projects", - "columns": [ - { - "name": "network", - "description": "Enumerated numbers representing the visibility of a project (public/ private).", - "enumerations": [ - { - "identifier": 0, - "description": "Private" - }, - { - "identifier": 2, - "description": "Public" - } - ] - } - ] - }, - { - "table": "issues", - "columns": [ - { - "name": "priority", - "description": "The priority level assigned to the issue, stored as lowercase text", - "distinct_values_in_column": [ - "urgent", - "high", - "medium", - "low", - "none" - ] - } - ] - } -] \ No newline at end of file + } + ] + }, + { + "table": "modules", + "columns": [ + { + "name": "status", + "description": "The status of a module, stored as a string", + "distinct_values_in_column": ["backlog", "planned", "in-progress", "paused", "completed", "cancelled"] + } + ] + } +] diff --git a/apps/pi/pi/agents/sql_agent/store/table-descriptions.json b/apps/pi/pi/agents/sql_agent/store/table-descriptions.json index fa8ad21a3e..7c0afc9d52 100644 --- a/apps/pi/pi/agents/sql_agent/store/table-descriptions.json +++ b/apps/pi/pi/agents/sql_agent/store/table-descriptions.json @@ -1,1229 +1,1072 @@ { - "intake_issues": { - "about": "The 'intake_issues' table stores details about issues created in the Intake process.", - "contains": [ - "Unique identifier (`id`).", - "Current status of the intake issue (`status`).", - "Snooze timing (`snoozed_till`).", - "Reference to the associated intake process (`intake_id`).", - "Reference to the related issue (`issue_id`).", - "Reference to the project (`project_id`)." - ], - "does_not_contain": [ - "Detailed issue content (refer to the 'issues' table).", - "Intake process configurations (refer to the 'intakes' table)." - ], - "relationships": { - "foreign_keys": { - "created_by_id": "users(id)", - "duplicate_to_id": "issues(id)", - "intake_id": "intakes(id)", - "issue_id": "issues(id)", - "project_id": "projects(id)", - "updated_by_id": "users(id)", - "workspace_id": "workspaces(id)" - } - } - }, - "intakes": { - "about": "The 'intakes' table manages guest-created issues that can be reviewed by Admins and Members. It facilitates the intake process, ensuring proper handling of user-submitted tasks.", - "contains": [ - "Unique identifier (`id`).", - "Name of the intake process (`name`).", - "Default intake flag (`is_default`).", - "Reference to the project (`project_id`).", - "Configuration settings and default statuses." - ], - "does_not_contain": [ - "Individual issue details (refer to the 'issues' table).", - "Current intake issue records (refer to the 'intake_issues' table)." - ], - "relationships": { - "foreign_keys": { - "created_by_id": "users(id)", - "project_id": "projects(id)", - "updated_by_id": "users(id)", - "workspace_id": "workspaces(id)" - } - } - }, - "issue_comments": { - "about": "The 'issue_comments' table stores user comments related to issues. Comments are part of the activity section and can include discussions, progress updates, file attachments, and mentions of other users.", - "contains": [ - "Unique identifier (`id`).", - "Plain text content (`comment_stripped`).", - "HTML-formatted content (`comment_html`).", - "Access level (e.g., private, public) (`access`).", - "Reference to the related issue (`issue_id`).", - "Reference to the project (`project_id`)." - ], - "does_not_contain": [ - "Detailed issue information (refer to the 'issues' table).", - "Attachments linked to comments (refer to the 'issue_attachments' table).", - "User mentions within comment text (refer to the 'issue_mentions' table)." - ], - "relationships": { - "foreign_keys": { - "actor_id": "users(id)", - "created_by_id": "users(id)", - "issue_id": "issues(id)", - "project_id": "projects(id)", - "updated_by_id": "users(id)", - "workspace_id": "workspaces(id)" - } - } - }, - "issue_attachments": { - "about": "The 'issue_attachments' table stores attachments related to issues. Attachments can include files, images, or other media associated with an issue.", - "contains": [ - "Unique identifier (`id`).", - "Metadata and attributes in JSON format (`attributes`).", - "Reference to the associated issue (`issue_id`).", - "Reference to the project (`project_id`)." - ], - "does_not_contain": [ - "Detailed issue descriptions (refer to the 'issues' table).", - "Discussions related to attachments (refer to the 'issue_comments' table)." - ], - "relationships": { - "foreign_keys": { - "created_by_id": "users(id)", - "issue_id": "issues(id)", - "project_id": "projects(id)", - "updated_by_id": "users(id)", - "workspace_id": "workspaces(id)" - } - } - }, - "importers": { - "about": "The 'importers' table handles the import of issue data from external services such as GitHub and Jira into Plane projects.", - "contains": [ - "Unique identifier (`id`).", - "External service type (e.g., GitHub, Jira) (`service`).", - "Import status (e.g., Pending, In Progress, Completed, Failed) (`status`).", - "Import process data in JSON format (`data`).", - "Reference to the project (`project_id`).", - "Reference to the API token used (`token_id`).", - "Imported data in JSON format (`imported_data`)." - ], - "does_not_contain": [ - "Actual issue records imported (refer to the 'issues' table).", - "Media attachments related to imported issues (refer to the 'issue_attachments' table)." - ], - "relationships": { - "foreign_keys": { - "created_by_id": "users(id)", - "initiated_by_id": "users(id)", - "project_id": "projects(id)", - "token_id": "api_tokens(id)", - "updated_by_id": "users(id)", - "workspace_id": "workspaces(id)" - } - } - }, - "cycle_issues": { - "about": "The 'cycle_issues' table links issues to specific cycles within Plane. Cycles are time-bound periods, similar to sprints, where teams focus on completing assigned tasks.", - "contains": [ - "Reference to the related cycle (`cycle_id`).", - "Reference to the associated issue (`issue_id`).", - "Reference to the project (`project_id`)." - ], - "does_not_contain": [ - "Issue details (refer to the 'issues' table).", - "Cycle details (refer to the 'cycles' table)." - ], - "relationships": { - "foreign_keys": { - "created_by_id": "users(id)", - "cycle_id": "cycles(id)", - "issue_id": "issues(id)", - "project_id": "projects(id)", - "updated_by_id": "users(id)", - "workspace_id": "workspaces(id)" - } - } - }, - "estimate_points": { - "about": "The 'estimate_points' table defines point values used in the estimation process. These points quantify the effort needed for an issue, using predefined systems such as Linear, Fibonacci, Squares, or custom progressions.", - "contains": [ - "Point key (`key`).", - "Description of the point (`description`).", - "Point value (`value`).", - "Reference to the parent estimate (`estimate_id`).", - "Reference to the project (`project_id`)." - ], - "does_not_contain": [ - "Actual estimates assigned to issues (refer to the 'estimates' table)." - ], - "relationships": { - "foreign_keys": { - "created_by_id": "users(id)", - "estimate_id": "estimates(id)", - "project_id": "projects(id)", - "updated_by_id": "users(id)", - "workspace_id": "workspaces(id)" - } - } - }, - "estimates": { - "about": "The 'estimates' table stores configuration settings for estimating the effort required for issues in a project. It includes systems like Points and Categories for quantifying work complexity.", - "contains": [ - "Name of the estimation method (`name`).", - "Description of the estimation method (`description`).", - "Type of estimation system (`type`).", - "Default usage flag (`last_used`).", - "Reference to the project (`project_id`)." - ], - "does_not_contain": [ - "Point values (refer to the 'estimate_points' table)." - ], - "relationships": { - "foreign_keys": { - "created_by_id": "users(id)", - "project_id": "projects(id)", - "updated_by_id": "users(id)", - "workspace_id": "workspaces(id)" - } - } - }, - "issue_mentions": { - "about": "The 'issue_mentions' table tracks references to users within issue comments. Mentions allow users to notify or involve specific team members in discussions related to an issue.", - "contains": [ - "Reference to the mentioned user (`mention_id`).", - "Reference to the related issue (`issue_id`).", - "Reference to the project (`project_id`)." - ], - "does_not_contain": [ - "Content where the mention occurred (refer to 'issue_comments')." - ], - "relationships": { - "foreign_keys": { - "created_by_id": "users(id)", - "issue_id": "issues(id)", - "mention_id": "users(id)", - "project_id": "projects(id)", - "updated_by_id": "users(id)", - "workspace_id": "workspaces(id)" - } - } - }, - "issue_relations": { - "about": "The 'issue_relations' table defines connections between issues within a project. Relations include 'Blocking/Blocked by' to represent dependencies, 'Relates to' for contextual or similar issues, and 'Duplicate of' to identify redundant issues.", - "contains": [ - "Type of relationship (`relation_type`).", - "Reference to the primary issue (`issue_id`).", - "Reference to the related issue (`related_issue_id`).", - "Reference to the project (`project_id`)." - ], - "does_not_contain": [ - "Detailed issue content (refer to the 'issues' table)." - ], - "relationships": { - "foreign_keys": { - "created_by_id": "users(id)", - "issue_id": "issues(id)", - "related_issue_id": "issues(id)", - "project_id": "projects(id)", - "updated_by_id": "users(id)", - "workspace_id": "workspaces(id)" - } - } - }, - "issue_sequences": { - "about": "The 'issue_sequences' table organizes issues into a specific order or sequence.", - "contains": [ - "Sequence identifiers for issues to maintain order.", - "References to the issue and project." - ], - "does_not_contain": [ - "Issue details or content.", - "Sequence history." - ], - "relationships": { - "foreign_keys": { - "created_by_id": "users(id)", - "issue_id": "issues(id)", - "project_id": "projects(id)", - "updated_by_id": "users(id)", - "workspace_id": "workspaces(id)" - } - } - }, - "issue_subscribers": { - "about": "The 'issue_subscribers' table tracks users who are subscribed to specific issues. Subscribers receive notifications about updates, comments, and changes to the issues.", - "contains": [ - "Information about users following issues for updates.", - "References to the issue, subscriber, and project." - ], - "does_not_contain": [ - "Notification settings or preferences.", - "Issue content." - ], - "relationships": { - "foreign_keys": { - "assignee_id": "users(id)", - "created_by_id": "users(id)", - "issue_id": "issues(id)", - "project_id": "projects(id)", - "updated_by_id": "users(id)", - "workspace_id": "workspaces(id)" - } - } - }, - "issue_activities": { - "about": "The 'issue_activities' table records a history of actions performed on issues within a project. It tracks details such as the action (verb), changes to fields (old and new values), comments, attachments, and the users involved in the activity.", - "contains": [ - "Records of actions taken on issues (e.g., status changes, comments).", - "Details about the change, old and new values.", - "References to the issue, related comments, and users involved." - ], - "does_not_contain": [ - "Current issue state (see 'issues' table).", - "Activity on other entities." - ], - "relationships": { - "foreign_keys": { - "actor_id": "users(id)", - "created_by_id": "users(id)", - "issue_comment_id": "issue_comments(id)", - "issue_id": "issues(id)", - "project_id": "projects(id)", - "updated_by_id": "users(id)", - "workspace_id": "workspaces(id)" - } - } - }, - "issue_assignees": { - "about": "The 'issue_assignees' table manages the assignment of users to specific issues. It links issues to their assignees.", - "contains": [ - "Information about users assigned to work on issues.", - "References to the issue, assignee, and project." - ], - "does_not_contain": [ - "Assignment history.", - "Issue details.", - "User details like username, first_name (see 'users' table)." - ], - "relationships": { - "foreign_keys": { - "assignee_id": "users(id)", - "created_by_id": "users(id)", - "issue_id": "issues(id)", - "project_id": "projects(id)", - "updated_by_id": "users(id)", - "workspace_id": "workspaces(id)" - } - } - }, - "issue_labels": { - "about": "The 'issue_labels' table links issues to labels, enabling categorization and organization of tasks within projects.", - "contains": [ - "Links between issues and labels for categorization.", - "References to the issue, label, and project." - ], - "does_not_contain": [ - "Label definitions (see 'labels' table).", - "Issue content." - ], - "relationships": { - "foreign_keys": { - "created_by_id": "users(id)", - "issue_id": "issues(id)", - "label_id": "labels(id)", - "project_id": "projects(id)", - "updated_by_id": "users(id)", - "workspace_id": "workspaces(id)" - } - } - }, - "issue_links": { - "about": "The 'issue_links' table stores user-added external URLs (e.g., documentation, references, third-party tools) that are attached to issues.", - "contains": [ - "External URLs and optional titles that users manually attach to issues (e.g., documentation links, reference materials, external tools).", - "References to the issue and project that the external link is attached to." - ], - "does_not_contain": [ - "Issue content.", - "Link previews or metadata.", - "URLs to view the issues themselves in Plane (these are generated programmatically, not stored).", - "Navigation links between issues or to other Plane entities." - ], - "relationships": { - "foreign_keys": { - "created_by_id": "users(id)", - "issue_id": "issues(id)", - "project_id": "projects(id)", - "updated_by_id": "users(id)", - "workspace_id": "workspaces(id)" - } - } - }, - "cycles": { - "about": "The 'cycles' table represents time-bound periods in Plane, similar to sprints in Agile methodologies. It tracks details such as start and due dates.", - "contains": [ - "Cycle details including name, description, start and end dates.", - "References to the project and owner (user)." - ], - "does_not_contain": [ - "Issues within the cycle (see 'cycle_issues' table).", - "Cycle statistics." - ], - "relationships": { - "foreign_keys": { - "created_by_id": "users(id)", - "owned_by_id": "users(id)", - "project_id": "projects(id)", - "updated_by_id": "users(id)", - "workspace_id": "workspaces(id)" - } - } - }, - "modules": { - "about": "The 'modules' stores information about modules in a project. Modules act as containers to group related tasks.", - "contains": [ - "Module details including name, description, status, and dates.", - "References to the project and lead (user)." - ], - "does_not_contain": [ - "Issues within the module (see 'module_issues' table).", - "Module members (see 'module_members' table)." - ], - "relationships": { - "foreign_keys": { - "created_by_id": "users(id)", - "lead_id": "users(id)", - "project_id": "projects(id)", - "updated_by_id": "users(id)", - "workspace_id": "workspaces(id)" - } - } - }, - "issues": { - "about": "The 'issues' table stores the fundamental units of work within a project in Plane. Issues represent tasks or objectives, with attributes such as priority, description, estimated effort, state, and type.", - "contains": [ - "Issue details including name, description, priority, dates, and status.", - "References to the project, creator, assignees, and other attributes." - ], - "does_not_contain": [ - "Comments on issues (see 'issue_comments' table).", - "Attachments (see 'issue_attachments' table).", - "Links (see 'issue_links' table).", - "Assignees (see 'issue_assignees' table)." - ], - "relationships": { - "foreign_keys": { - "created_by_id": "users(id)", - "parent_id": "issues(id)", - "project_id": "projects(id)", - "state_id": "states(id)", - "type_id": "issue_types(id)", - "estimate_point_id": "estimate_points(id)", - "updated_by_id": "users(id)", - "workspace_id": "workspaces(id)" - } - } - }, - "module_issues": { - "about": "The 'module_issues' table links issues to specific modules within Plane. Modules are structural components used to group related tasks.", - "contains": [ - "Links between issues and modules.", - "References to the issue, module, and project." - ], - "does_not_contain": [ - "Issue or module details.", - "Assignment history." - ], - "relationships": { - "foreign_keys": { - "created_by_id": "users(id)", - "issue_id": "issues(id)", - "module_id": "modules(id)", - "project_id": "projects(id)", - "updated_by_id": "users(id)", - "workspace_id": "workspaces(id)" - } - } - }, - "module_links": { - "about": "The 'module_links' table stores external links attached to specific modules in Plane.", - "contains": [ - "URLs and titles of links associated with modules.", - "References to the module and project." - ], - "does_not_contain": [ - "Module content.", - "Link previews or metadata." - ], - "relationships": { - "foreign_keys": { - "created_by_id": "users(id)", - "module_id": "modules(id)", - "project_id": "projects(id)", - "updated_by_id": "users(id)", - "workspace_id": "workspaces(id)" - } - } - }, - "labels": { - "about": "The 'labels' table stores details about labels, categorization tags used to organize and filter issues in Plane.", - "contains": [ - "Label details including name, description, and hierarchy.", - "References to parent labels for nested labeling." - ], - "does_not_contain": [ - "Associations with issues or pages (see 'issue_labels' and 'page_labels').", - "Usage statistics." - ], - "relationships": { - "foreign_keys": { - "created_by_id": "users(id)", - "parent_id": "labels(id)", - "project_id": "projects(id)", - "updated_by_id": "users(id)", - "workspace_id": "workspaces(id)" - } - } - }, - "issue_views": { - "about": "The 'issue_views' table stores customizable saved views or filters for issues in Plane.", - "contains": [ - "View configurations including name, description, and access level.", - "References to the owner (user) and project." - ], - "does_not_contain": [ - "Actual issues (it defines how to filter or view them).", - "View usage data." - ], - "relationships": { - "foreign_keys": { - "created_by_id": "users(id)", - "owned_by_id": "users(id)", - "project_id": "projects(id)", - "updated_by_id": "users(id)", - "workspace_id": "workspaces(id)" - } - } - }, - "issue_worklogs": { - "about": "The 'issue_worklogs' table logs time entries for work done on specific issues in Plane. It tracks details such as duration, descriptions, and the user who logged the time.", - "contains": [ - "Worklog entries with duration, description, and timestamps.", - "References to the issue, user who logged the time, and project." - ], - "does_not_contain": [ - "Issue content.", - "Billing or invoicing information." - ], - "relationships": { - "foreign_keys": { - "created_by_id": "users(id)", - "issue_id": "issues(id)", - "logged_by_id": "users(id)", - "project_id": "projects(id)", - "updated_by_id": "users(id)", - "workspace_id": "workspaces(id)" - } - } - }, - "module_members": { - "about": "The 'module_members' table associates users with specific modules in Plane.", - "contains": [ - "Information about users who are members of modules.", - "References to the module, member (user), and project.", - "Members include real users but not bots like intake bots." - ], - "does_not_contain": [ - "Module content.", - "User roles or permissions." - ], - "relationships": { - "foreign_keys": { - "created_by_id": "users(id)", - "member_id": "users(id)", - "module_id": "modules(id)", - "project_id": "projects(id)", - "updated_by_id": "users(id)", - "workspace_id": "workspaces(id)" - } - } - }, - "project_identifiers": { - "about": "The 'project_identifiers' table stores unique identifiers and names for projects in Plane. These identifiers help distinguish projects within a workspace and are used for efficient project management and API interactions.", - "contains": [ - "Project identifiers used for referencing or display.", - "References to the project and users involved." - ], - "does_not_contain": [ - "Project details (see 'projects' table).", - "Configuration settings." - ], - "relationships": { - "foreign_keys": { - "created_by_id": "users(id)", - "project_id": "projects(id)", - "updated_by_id": "users(id)", - "workspace_id": "workspaces(id)" - } - } - }, - "pages": { - "about": "The 'pages' table stores structured content or documents within Plane.", - "contains": [ - "Page details including name, content (in various formats), and access levels.", - "References to the owner, creator, and hierarchical parent pages." - ], - "does_not_contain": [ - "Direct associations with projects (see 'project_pages').", - "Page version history (see 'page_versions')." - ], - "relationships": { - "foreign_keys": { - "created_by_id": "users(id)", - "owned_by_id": "users(id)", - "parent_id": "pages(id)", - "updated_by_id": "users(id)", - "workspace_id": "workspaces(id)" - } - } - }, - "page_labels": { - "about": "The 'page_labels' table links labels to pages in Plane. These labels help categorize and organize pages.", - "contains": [ - "Links between pages and labels for categorization.", - "References to the page, label, and workspace." - ], - "does_not_contain": [ - "Label definitions (see 'labels' table).", - "Page content." - ], - "relationships": { - "foreign_keys": { - "created_by_id": "users(id)", - "label_id": "labels(id)", - "page_id": "pages(id)", - "updated_by_id": "users(id)", - "workspace_id": "workspaces(id)" - } - } - }, - "project_attributes": { - "about": "The 'project_attributes' table stores additional metadata for projects in Plane, such as priority, start and target dates, and state.", - "contains": [ - "Attributes like priority, start date, target date, and state.", - "References to the project, state, and users." - ], - "does_not_contain": [ - "Basic project information (see 'projects').", - "Issue data." - ], - "relationships": { - "foreign_keys": { - "created_by_id": "users(id)", - "project_id": "projects(id)", - "state_id": "project_states(id)", - "updated_by_id": "users(id)", - "workspace_id": "workspaces(id)" - } - } - }, - "project_states": { - "about": "The 'project_states' table stores the state of the project. Examples include states like 'Draft' 'Planning' or 'Monitoring'.", - "contains": [ - "Project State details including name, description, and its group.", - "References to the workspace and users." - ], - "does_not_contain": [ - "Associations with specific projects (see 'project_attributes').", - "Issue states (see 'states' table)." - ], - "relationships": { - "foreign_keys": { - "created_by_id": "users(id)", - "updated_by_id": "users(id)", - "workspace_id": "workspaces(id)" - } - } - }, - "project_issue_types": { - "about": "The 'project_issue_types' table associates customizable issue types with specific projects in Plane.", - "contains": [ - "Information about which issue types are enabled in projects.", - "References to the project, issue type, and users." - ], - "does_not_contain": [ - "Issue details.", - "Issue type definitions (see 'issue_types')." - ], - "relationships": { - "foreign_keys": { - "created_by_id": "users(id)", - "issue_type_id": "issue_types(id)", - "project_id": "projects(id)", - "updated_by_id": "users(id)", - "workspace_id": "workspaces(id)" - } - } - }, - "project_members": { - "about": "The 'project_members' table tracks users who are members of projects in Plane. It includes roles, membership status, and other details to manage collaboration and access within projects.", - "contains": [ - "Information about project membership, roles, and activity status (If they are active)", - "References to the project and member (user).", - "Members include real users but not bots like intake bots." - ], - "does_not_contain": [ - "Workspace membership (see 'workspace_members').", - "User permissions." - ], - "relationships": { - "foreign_keys": { - "created_by_id": "users(id)", - "member_id": "users(id)", - "project_id": "projects(id)", - "updated_by_id": "users(id)", - "workspace_id": "workspaces(id)" - } - } - }, - "project_pages": { - "about": "The 'project_pages' table associates pages with projects in Plane.", - "contains": [ - "Links between projects and pages.", - "References to the project, page, and workspace." - ], - "does_not_contain": [ - "Page content (see 'pages' table).", - "Project details." - ], - "relationships": { - "foreign_keys": { - "created_by_id": "users(id)", - "page_id": "pages(id)", - "project_id": "projects(id)", - "updated_by_id": "users(id)", - "workspace_id": "workspaces(id)" - } - } - }, - "users": { - "about": "The 'users' table stores information about user accounts in Plane. It includes details such as username, email, names, activity timestamps, and user-specific attributes like timezone and bot status.", - "contains": [ - "User details including username, names, and status flags.", - "Timestamps for login and activity tracking.", - "Include both real users and bots." - ], - "does_not_contain": [ - "User roles in projects or workspaces.", - "project_id", - "workspace_id" - ], - "relationships": { - "referenced_by": [ - "Many tables via user-related foreign keys (e.g., 'created_by_id' or 'updated_by_id').", - "Membership related tables also reference the user via the 'member__id' field." - ] - } - }, - "projects": { - "about": "The 'projects' table stores core information about projects in Plane. Projects act as containers for tasks, timelines, pages, and resources.", - "contains": [ - "Project details including name, description, settings, public/private, and configurations.", - "References to the project lead, default assignee, and workspace." - ], - "does_not_contain": [ - "Issues within the project (see 'issues' table).", - "Project membership (see 'project_members').", - "Project state name (see 'project_states').", - "Project start date, end date, state and priority (see 'project_attributes')." - ], - "relationships": { - "foreign_keys": { - "created_by_id": "users(id)", - "default_assignee_id": "users(id)", - "project_lead_id": "users(id)", - "updated_by_id": "users(id)", - "workspace_id": "workspaces(id)", - "default_state_id": "states(id)", - "estimate_id": "estimates(id)" - } - } - }, - "states": { - "about": "The 'states' table defines possible stages or phases that tasks or issues can go through within a project in Plane. These states, such as 'To Do,' 'In Progress,' and 'Completed,' help track progress and organize workflows.", - "contains": [ - "State details including name, description, sequence", - "References to the project and workspace.", - "The name of state group which state belongs to." - ], - "does_not_contain": [ - "Associations with specific issues (see 'issues' table).", - "Project states (see 'project_states')." - ], - "relationships": { - "foreign_keys": { - "created_by_id": "users(id)", - "project_id": "projects(id)", - "updated_by_id": "users(id)", - "workspace_id": "workspaces(id)" - } - } - }, - "issue_properties": { - "about": "The 'issue_properties' table defines custom fields for issues in Plane. These properties allow teams to capture specific information, such as text, numbers, dates, or dropdown options, tailored to project needs.", - "contains": [ - "Property definitions including name, type, default value, and requirements.", - "References to the issue type and workspace." - ], - "does_not_contain": [ - "Property values for specific issues (see 'issue_property_values').", - "Issue details." - ], - "relationships": { - "foreign_keys": { - "created_by_id": "users(id)", - "issue_type_id": "issue_types(id)", - "project_id": "projects(id)", - "updated_by_id": "users(id)", - "workspace_id": "workspaces(id)" - } - } - }, - "issue_property_options": { - "about": "The 'issue_property_options' table stores selectable options for dropdown or multi-select custom properties in Plane. These options allow users to define and choose values for custom fields tailored to project needs.", - "contains": [ - "Options for properties that require selection (e.g., dropdowns).", - "References to the property and potentially parent options." - ], - "does_not_contain": [ - "Property definitions (see 'issue_properties').", - "Issue property values." - ], - "relationships": { - "foreign_keys": { - "parent_id": "issue_property_options(id)", - "property_id": "issue_properties(id)", - "created_by_id": "users(id)", - "project_id": "projects(id)", - "updated_by_id": "users(id)", - "workspace_id": "workspaces(id)" - } - } - }, - "issue_property_values": { - "about": "The 'issue_property_values' table stores the assigned values of custom properties for issues in Plane. These values capture specific information, such as text, numbers, dates, or selected options, based on the custom properties defined for an issue.", - "contains": [ - "Actual values of properties for issues across various data types.", - "References to the issue, property, and any selected options." - ], - "does_not_contain": [ - "Property definitions.", - "Issue details." - ], - "relationships": { - "foreign_keys": { - "created_by_id": "users(id)", - "issue_id": "issues(id)", - "property_id": "issue_properties(id)", - "value_option_id": "issue_property_options(id)", - "project_id": "projects(id)", - "updated_by_id": "users(id)", - "workspace_id": "workspaces(id)" - } - } - }, - "issue_types": { - "about": "The 'issue_types' table defines various categories of issues in Plane. Issue types allow teams to classify and manage tasks based on specific needs tailored to the project.", - "contains": [ - "Issue type details including name, description, and flags (e.g., is_epic).", - "References to the workspace and users." - ], - "does_not_contain": [ - "Actual issues (see 'issues' table).", - "Issue type configurations for projects (see 'project_issue_types')." - ], - "relationships": { - "foreign_keys": { - "created_by_id": "users(id)", - "updated_by_id": "users(id)", - "workspace_id": "workspaces(id)" - } - } - }, - "workspaces": { - "about": "The 'workspaces' table stores information about workspaces in Plane, which serve as containers for organizing projects and teams.", - "contains": [ - "Workspace details including name, slug, owner, and settings.", - "References to the owner and creator." - ], - "does_not_contain": [ - "Projects within the workspace (see 'projects' table).", - "Workspace members (see 'workspace_members')." - ], - "relationships": { - "foreign_keys": { - "created_by_id": "users(id)", - "owner_id": "users(id)", - "updated_by_id": "users(id)" - } - } - }, - "workspace_members": { - "about": "The 'workspace_members' table tracks users who are members of workspaces in Plane. It includes roles, membership status, and other details at workspace level.", - "contains": [ - "Information about workspace membership, roles, and activity status (If they are active).", - "References to the member (user) and workspace.", - "Members include real users but not bots like intake bots." - ], - "does_not_contain": [ - "Project membership.", - "User permissions." - ], - "relationships": { - "foreign_keys": { - "created_by_id": "users(id)", - "member_id": "users(id)", - "updated_by_id": "users(id)", - "workspace_id": "workspaces(id)" - } - } - }, - "page_logs": { - "about": "The 'page_logs' table records changes and transactions made to pages in Plane. It tracks updates, edits, and actions.", - "contains": [ - "Records of actions performed on pages.", - "References to the page, user who made the change, and workspace." - ], - "does_not_contain": [ - "Page content.", - "Version history (see 'page_versions')." - ], - "relationships": { - "foreign_keys": { - "created_by_id": "users(id)", - "page_id": "pages(id)", - "updated_by_id": "users(id)", - "workspace_id": "workspaces(id)" - } - } - }, - "page_versions": { - "about": "The 'page_versions' table stores historical versions of pages in Plane.", - "contains": [ - "Archived versions of page content with timestamps.", - "References to the page, owner, and workspace." - ], - "does_not_contain": [ - "Current page content (see 'pages' table).", - "Change logs (see 'page_logs')." - ], - "relationships": { - "foreign_keys": { - "created_by_id": "users(id)", - "owned_by_id": "users(id)", - "page_id": "pages(id)", - "updated_by_id": "users(id)", - "workspace_id": "workspaces(id)" - } - } - }, - "team_spaces": { - "about": "The 'team_spaces' table stores information about team spaces in Plane, which serve as dedicated areas for team collaboration and work tracking.", - "contains": [ - "Team space details including name, description, and logo properties.", - "References to the lead, creator, and workspace." - ], - "does_not_contain": [ - "Team members (see 'team_space_members').", - "Team projects (see 'team_space_projects')." - ], - "relationships": { - "foreign_keys": { - "created_by_id": "users(id)", - "lead_id": "users(id)", - "updated_by_id": "users(id)", - "workspace_id": "workspaces(id)" - } - } - }, - "team_space_members": { - "about": "The 'team_space_members' table tracks users who are members of team spaces in Plane.", - "contains": [ - "Information about team space membership.", - "References to the team space and member (user)." - ], - "does_not_contain": [ - "Team space details.", - "User permissions." - ], - "relationships": { - "foreign_keys": { - "created_by_id": "users(id)", - "member_id": "users(id)", - "team_space_id": "team_spaces(id)", - "updated_by_id": "users(id)", - "workspace_id": "workspaces(id)" - } - } - }, - "team_space_projects": { - "about": "The 'team_space_projects' table links projects to team spaces in Plane.", - "contains": [ - "Links between projects and team spaces.", - "References to the project and team space." - ], - "does_not_contain": [ - "Project details.", - "Team space details." - ], - "relationships": { - "foreign_keys": { - "created_by_id": "users(id)", - "project_id": "projects(id)", - "team_space_id": "team_spaces(id)", - "updated_by_id": "users(id)", - "workspace_id": "workspaces(id)" - } - } - }, - "team_space_pages": { - "about": "The 'team_space_pages' table links pages to team spaces in Plane.", - "contains": [ - "Links between pages and team spaces.", - "References to the page and team space." - ], - "does_not_contain": [ - "Page content.", - "Team space details." - ], - "relationships": { - "foreign_keys": { - "created_by_id": "users(id)", - "page_id": "pages(id)", - "team_space_id": "team_spaces(id)", - "updated_by_id": "users(id)", - "workspace_id": "workspaces(id)" - } - } - }, - "team_space_labels": { - "about": "The 'team_space_labels' table links labels to team spaces in Plane.", - "contains": [ - "Links between labels and team spaces.", - "References to the label and team space." - ], - "does_not_contain": [ - "Label definitions.", - "Team space details." - ], - "relationships": { - "foreign_keys": { - "created_by_id": "users(id)", - "label_id": "labels(id)", - "team_space_id": "team_spaces(id)", - "updated_by_id": "users(id)", - "workspace_id": "workspaces(id)" - } - } - }, - "team_space_comments": { - "about": "The 'team_space_comments' table stores comments related to team spaces in Plane.", - "contains": [ - "Comment content in various formats (plain text, HTML, JSON).", - "References to the team space and comment author." - ], - "does_not_contain": [ - "Team space details.", - "Comment attachments." - ], - "relationships": { - "foreign_keys": { - "actor_id": "users(id)", - "created_by_id": "users(id)", - "parent_id": "team_space_comments(id)", - "team_space_id": "team_spaces(id)", - "updated_by_id": "users(id)", - "workspace_id": "workspaces(id)" - } - } - }, - "stickies": { - "about": "The 'stickies' table stores personal notes and reminders in Plane.", - "contains": [ - "Sticky note content and formatting.", - "References to the owner and workspace." - ], - "does_not_contain": [ - "Attachments.", - "Comments." - ], - "relationships": { - "foreign_keys": { - "created_by_id": "users(id)", - "owner_id": "users(id)", - "updated_by_id": "users(id)", - "workspace_id": "workspaces(id)" - } - } - }, - "issue_votes": { - "about": "The 'issue_votes' table tracks user votes on issues in Plane.", - "contains": [ - "Vote value and metadata.", - "References to the issue and voter." - ], - "does_not_contain": [ - "Issue details.", - "Vote history." - ], - "relationships": { - "foreign_keys": { - "actor_id": "users(id)", - "created_by_id": "users(id)", - "issue_id": "issues(id)", - "project_id": "projects(id)", - "updated_by_id": "users(id)", - "workspace_id": "workspaces(id)" - } - } - }, - "initiatives": { - "about": "The 'initiatives' table stores high-level strategic objectives and metadata for initiatives in Plane.", - "contains": [ - "Initiative details including name, descriptions (plain text, HTML, stripped, and binary).", - "Timeline information such as start and end dates.", - "State of the initiative and logo properties stored as JSON.", - "References to the lead, creator, updater, and workspace." - ], - "does_not_contain": [ - "Associated projects (see 'initiative_projects').", - "Initiative epics (see 'initiative_epics')." - ], - "relationships": { - "foreign_keys": { - "created_by_id": "users(id)", - "lead_id": "users(id)", - "updated_by_id": "users(id)", - "workspace_id": "workspaces(id)" - } - } - }, - "initiative_projects": { - "about": "The 'initiative_projects' table links projects to initiatives in Plane.", - "contains": [ - "Links between projects and initiatives.", - "References to the project and initiative." - ], - "does_not_contain": [ - "Project details.", - "Initiative details." - ], - "relationships": { - "foreign_keys": { - "created_by_id": "users(id)", - "initiative_id": "initiatives(id)", - "project_id": "projects(id)", - "updated_by_id": "users(id)", - "workspace_id": "workspaces(id)" - } - } - }, - "initiative_epics": { - "about": "The 'initiative_epics' table links epics to initiatives in Plane.", - "contains": [ - "Links between epics and initiatives.", - "References to the epic and initiative." - ], - "does_not_contain": [ - "Epic details.", - "Initiative details." - ], - "relationships": { - "foreign_keys": { - "created_by_id": "users(id)", - "epic_id": "issues(id)", - "initiative_id": "initiatives(id)", - "updated_by_id": "users(id)", - "workspace_id": "workspaces(id)" - } - } - }, - "initiative_labels": { - "about": "The 'initiative_labels' table stores label definitions used for initiatives in Plane.", - "contains": [ - "Label details including name, color, and description.", - "References to the workspace and users who created or updated the label." - ], - "does_not_contain": [ - "Links between initiatives and labels (this relationship no longer exists here).", - "Label usage mapping across initiatives." - ], - "relationships": { - "foreign_keys": { - "created_by_id": "users(id)", - "updated_by_id": "users(id)", - "workspace_id": "workspaces(id)" - } - } - }, - "initiative_links": { - "about": "The 'initiative_links' table stores external links attached to initiatives in Plane.", - "contains": [ - "URLs and metadata for links associated with initiatives.", - "References to the initiative and workspace." - ], - "does_not_contain": [ - "Initiative details.", - "Link previews." - ], - "relationships": { - "foreign_keys": { - "created_by_id": "users(id)", - "initiative_id": "initiatives(id)", - "updated_by_id": "users(id)", - "workspace_id": "workspaces(id)" - } - } - }, - "initiative_comments": { - "about": "The 'initiative_comments' table stores comments related to initiatives in Plane.", - "contains": [ - "Comment content in multiple formats (plain text, HTML, JSON).", - "Metadata such as access level, external source, and edit timestamps.", - "References to the initiative and comment author." - ], - "does_not_contain": [ - "Initiative details.", - "Comment attachments." - ], - "relationships": { - "foreign_keys": { - "actor_id": "users(id)", - "created_by_id": "users(id)", - "initiative_id": "initiatives(id)", - "updated_by_id": "users(id)", - "workspace_id": "workspaces(id)" - } - } - }, - "initiative_activities": { - "about": "The 'initiative_activities' table records changes and actions performed on initiatives in Plane.", - "contains": [ - "Activity details such as verb, field changes, old/new values, and comments.", - "References to the initiative, actor, and related comment (if any)." - ], - "does_not_contain": [ - "Initiative details.", - "Activity attachments." - ], - "relationships": { - "foreign_keys": { - "actor_id": "users(id)", - "created_by_id": "users(id)", - "initiative_id": "initiatives(id)", - "initiative_comment_id": "initiative_comments(id)", - "updated_by_id": "users(id)", - "workspace_id": "workspaces(id)" - } - } + "intake_issues": { + "about": "The 'intake_issues' table stores details about issues created in the Intake process.", + "contains": [ + "Unique identifier (`id`).", + "Current status of the intake issue (`status`).", + "Snooze timing (`snoozed_till`).", + "Reference to the associated intake process (`intake_id`).", + "Reference to the related issue (`issue_id`).", + "Reference to the project (`project_id`)." + ], + "does_not_contain": [ + "Detailed issue content (refer to the 'issues' table).", + "Intake process configurations (refer to the 'intakes' table)." + ], + "relationships": { + "foreign_keys": { + "created_by_id": "users(id)", + "duplicate_to_id": "issues(id)", + "intake_id": "intakes(id)", + "issue_id": "issues(id)", + "project_id": "projects(id)", + "updated_by_id": "users(id)", + "workspace_id": "workspaces(id)" + } } -} \ No newline at end of file + }, + "intakes": { + "about": "The 'intakes' table manages guest-created issues that can be reviewed by Admins and Members. It facilitates the intake process, ensuring proper handling of user-submitted tasks.", + "contains": [ + "Unique identifier (`id`).", + "Name of the intake process (`name`).", + "Default intake flag (`is_default`).", + "Reference to the project (`project_id`).", + "Configuration settings and default statuses." + ], + "does_not_contain": [ + "Individual issue details (refer to the 'issues' table).", + "Current intake issue records (refer to the 'intake_issues' table)." + ], + "relationships": { + "foreign_keys": { + "created_by_id": "users(id)", + "project_id": "projects(id)", + "updated_by_id": "users(id)", + "workspace_id": "workspaces(id)" + } + } + }, + "issue_comments": { + "about": "The 'issue_comments' table stores user comments related to issues. Comments are part of the activity section and can include discussions, progress updates, file attachments, and mentions of other users.", + "contains": [ + "Unique identifier (`id`).", + "Plain text content (`comment_stripped`).", + "HTML-formatted content (`comment_html`).", + "Access level (e.g., private, public) (`access`).", + "Reference to the related issue (`issue_id`).", + "Reference to the project (`project_id`)." + ], + "does_not_contain": [ + "Detailed issue information (refer to the 'issues' table).", + "Attachments linked to comments (refer to the 'issue_attachments' table).", + "User mentions within comment text (refer to the 'issue_mentions' table)." + ], + "relationships": { + "foreign_keys": { + "actor_id": "users(id)", + "created_by_id": "users(id)", + "issue_id": "issues(id)", + "project_id": "projects(id)", + "updated_by_id": "users(id)", + "workspace_id": "workspaces(id)" + } + } + }, + "issue_attachments": { + "about": "The 'issue_attachments' table stores attachments related to issues. Attachments can include files, images, or other media associated with an issue.", + "contains": [ + "Unique identifier (`id`).", + "Metadata and attributes in JSON format (`attributes`).", + "Reference to the associated issue (`issue_id`).", + "Reference to the project (`project_id`)." + ], + "does_not_contain": [ + "Detailed issue descriptions (refer to the 'issues' table).", + "Discussions related to attachments (refer to the 'issue_comments' table)." + ], + "relationships": { + "foreign_keys": { + "created_by_id": "users(id)", + "issue_id": "issues(id)", + "project_id": "projects(id)", + "updated_by_id": "users(id)", + "workspace_id": "workspaces(id)" + } + } + }, + "importers": { + "about": "The 'importers' table handles the import of issue data from external services such as GitHub and Jira into Plane projects.", + "contains": [ + "Unique identifier (`id`).", + "External service type (e.g., GitHub, Jira) (`service`).", + "Import status (e.g., Pending, In Progress, Completed, Failed) (`status`).", + "Import process data in JSON format (`data`).", + "Reference to the project (`project_id`).", + "Reference to the API token used (`token_id`).", + "Imported data in JSON format (`imported_data`)." + ], + "does_not_contain": [ + "Actual issue records imported (refer to the 'issues' table).", + "Media attachments related to imported issues (refer to the 'issue_attachments' table)." + ], + "relationships": { + "foreign_keys": { + "created_by_id": "users(id)", + "initiated_by_id": "users(id)", + "project_id": "projects(id)", + "token_id": "api_tokens(id)", + "updated_by_id": "users(id)", + "workspace_id": "workspaces(id)" + } + } + }, + "cycle_issues": { + "about": "The 'cycle_issues' table links issues to specific cycles within Plane. Cycles are time-bound periods, similar to sprints, where teams focus on completing assigned tasks.", + "contains": [ + "Reference to the related cycle (`cycle_id`).", + "Reference to the associated issue (`issue_id`).", + "Reference to the project (`project_id`)." + ], + "does_not_contain": [ + "Issue details (refer to the 'issues' table).", + "Cycle details (refer to the 'cycles' table)." + ], + "relationships": { + "foreign_keys": { + "created_by_id": "users(id)", + "cycle_id": "cycles(id)", + "issue_id": "issues(id)", + "project_id": "projects(id)", + "updated_by_id": "users(id)", + "workspace_id": "workspaces(id)" + } + } + }, + "estimate_points": { + "about": "The 'estimate_points' table defines point values used in the estimation process. These points quantify the effort needed for an issue, using predefined systems such as Linear, Fibonacci, Squares, or custom progressions.", + "contains": [ + "Point key (`key`).", + "Description of the point (`description`).", + "Point value (`value`).", + "Reference to the parent estimate (`estimate_id`).", + "Reference to the project (`project_id`)." + ], + "does_not_contain": ["Actual estimates assigned to issues (refer to the 'estimates' table)."], + "relationships": { + "foreign_keys": { + "created_by_id": "users(id)", + "estimate_id": "estimates(id)", + "project_id": "projects(id)", + "updated_by_id": "users(id)", + "workspace_id": "workspaces(id)" + } + } + }, + "estimates": { + "about": "The 'estimates' table stores configuration settings for estimating the effort required for issues in a project. It includes systems like Points and Categories for quantifying work complexity.", + "contains": [ + "Name of the estimation method (`name`).", + "Description of the estimation method (`description`).", + "Type of estimation system (`type`).", + "Default usage flag (`last_used`).", + "Reference to the project (`project_id`)." + ], + "does_not_contain": ["Point values (refer to the 'estimate_points' table)."], + "relationships": { + "foreign_keys": { + "created_by_id": "users(id)", + "project_id": "projects(id)", + "updated_by_id": "users(id)", + "workspace_id": "workspaces(id)" + } + } + }, + "issue_mentions": { + "about": "The 'issue_mentions' table tracks references to users within issue comments. Mentions allow users to notify or involve specific team members in discussions related to an issue.", + "contains": [ + "Reference to the mentioned user (`mention_id`).", + "Reference to the related issue (`issue_id`).", + "Reference to the project (`project_id`)." + ], + "does_not_contain": ["Content where the mention occurred (refer to 'issue_comments')."], + "relationships": { + "foreign_keys": { + "created_by_id": "users(id)", + "issue_id": "issues(id)", + "mention_id": "users(id)", + "project_id": "projects(id)", + "updated_by_id": "users(id)", + "workspace_id": "workspaces(id)" + } + } + }, + "issue_relations": { + "about": "The 'issue_relations' table defines connections between issues within a project. Relations include 'Blocking/Blocked by' to represent dependencies, 'Relates to' for contextual or similar issues, and 'Duplicate of' to identify redundant issues.", + "contains": [ + "Type of relationship (`relation_type`).", + "Reference to the primary issue (`issue_id`).", + "Reference to the related issue (`related_issue_id`).", + "Reference to the project (`project_id`)." + ], + "does_not_contain": ["Detailed issue content (refer to the 'issues' table)."], + "relationships": { + "foreign_keys": { + "created_by_id": "users(id)", + "issue_id": "issues(id)", + "related_issue_id": "issues(id)", + "project_id": "projects(id)", + "updated_by_id": "users(id)", + "workspace_id": "workspaces(id)" + } + } + }, + "issue_sequences": { + "about": "The 'issue_sequences' table organizes issues into a specific order or sequence.", + "contains": ["Sequence identifiers for issues to maintain order.", "References to the issue and project."], + "does_not_contain": ["Issue details or content.", "Sequence history."], + "relationships": { + "foreign_keys": { + "created_by_id": "users(id)", + "issue_id": "issues(id)", + "project_id": "projects(id)", + "updated_by_id": "users(id)", + "workspace_id": "workspaces(id)" + } + } + }, + "issue_subscribers": { + "about": "The 'issue_subscribers' table tracks users who are subscribed to specific issues. Subscribers receive notifications about updates, comments, and changes to the issues.", + "contains": [ + "Information about users following issues for updates.", + "References to the issue, subscriber, and project." + ], + "does_not_contain": ["Notification settings or preferences.", "Issue content."], + "relationships": { + "foreign_keys": { + "assignee_id": "users(id)", + "created_by_id": "users(id)", + "issue_id": "issues(id)", + "project_id": "projects(id)", + "updated_by_id": "users(id)", + "workspace_id": "workspaces(id)" + } + } + }, + "issue_activities": { + "about": "The 'issue_activities' table records a history of actions performed on issues within a project. It tracks details such as the action (verb), changes to fields (old and new values), comments, attachments, and the users involved in the activity.", + "contains": [ + "Records of actions taken on issues (e.g., status changes, comments).", + "Details about the change, old and new values.", + "References to the issue, related comments, and users involved." + ], + "does_not_contain": ["Current issue state (see 'issues' table).", "Activity on other entities."], + "relationships": { + "foreign_keys": { + "actor_id": "users(id)", + "created_by_id": "users(id)", + "issue_comment_id": "issue_comments(id)", + "issue_id": "issues(id)", + "project_id": "projects(id)", + "updated_by_id": "users(id)", + "workspace_id": "workspaces(id)" + } + } + }, + "issue_assignees": { + "about": "The 'issue_assignees' table manages the assignment of users to specific issues. It links issues to their assignees.", + "contains": [ + "Information about users assigned to work on issues.", + "References to the issue, assignee, and project." + ], + "does_not_contain": [ + "Assignment history.", + "Issue details.", + "User details like username, first_name (see 'users' table)." + ], + "relationships": { + "foreign_keys": { + "assignee_id": "users(id)", + "created_by_id": "users(id)", + "issue_id": "issues(id)", + "project_id": "projects(id)", + "updated_by_id": "users(id)", + "workspace_id": "workspaces(id)" + } + } + }, + "issue_labels": { + "about": "The 'issue_labels' table links issues to labels, enabling categorization and organization of tasks within projects.", + "contains": ["Links between issues and labels for categorization.", "References to the issue, label, and project."], + "does_not_contain": ["Label definitions (see 'labels' table).", "Issue content."], + "relationships": { + "foreign_keys": { + "created_by_id": "users(id)", + "issue_id": "issues(id)", + "label_id": "labels(id)", + "project_id": "projects(id)", + "updated_by_id": "users(id)", + "workspace_id": "workspaces(id)" + } + } + }, + "issue_links": { + "about": "The 'issue_links' table stores user-added external URLs (e.g., documentation, references, third-party tools) that are attached to issues.", + "contains": [ + "External URLs and optional titles that users manually attach to issues (e.g., documentation links, reference materials, external tools).", + "References to the issue and project that the external link is attached to." + ], + "does_not_contain": [ + "Issue content.", + "Link previews or metadata.", + "URLs to view the issues themselves in Plane (these are generated programmatically, not stored).", + "Navigation links between issues or to other Plane entities." + ], + "relationships": { + "foreign_keys": { + "created_by_id": "users(id)", + "issue_id": "issues(id)", + "project_id": "projects(id)", + "updated_by_id": "users(id)", + "workspace_id": "workspaces(id)" + } + } + }, + "cycles": { + "about": "The 'cycles' table represents time-bound periods in Plane, similar to sprints in Agile methodologies. It tracks details such as start and due dates. IMPORTANT: This table does NOT have a 'status' column - status is computed dynamically based on comparing start_date and end_date with the current time.", + "contains": [ + "Cycle details including name, description, start and end dates.", + "References to the project and owner (user).", + "Status is computed: current (start_date <= NOW() AND end_date >= NOW()), upcoming (start_date > NOW()), completed (end_date < NOW()), draft (both dates NULL)." + ], + "does_not_contain": [ + "Issues within the cycle (see 'cycle_issues' table).", + "Cycle statistics.", + "A 'status' column (status is computed from dates)." + ], + "relationships": { + "foreign_keys": { + "created_by_id": "users(id)", + "owned_by_id": "users(id)", + "project_id": "projects(id)", + "updated_by_id": "users(id)", + "workspace_id": "workspaces(id)" + } + } + }, + "modules": { + "about": "The 'modules' stores information about modules in a project. Modules act as containers to group related tasks.", + "contains": [ + "Module details including name, description, status, and dates.", + "References to the project and lead (user)." + ], + "does_not_contain": [ + "Issues within the module (see 'module_issues' table).", + "Module members (see 'module_members' table)." + ], + "relationships": { + "foreign_keys": { + "created_by_id": "users(id)", + "lead_id": "users(id)", + "project_id": "projects(id)", + "updated_by_id": "users(id)", + "workspace_id": "workspaces(id)" + } + } + }, + "issues": { + "about": "The 'issues' table stores the fundamental units of work within a project in Plane. Issues represent tasks or objectives, with attributes such as priority, description, estimated effort, state, and type.", + "contains": [ + "Issue details including name, description, priority, dates, and status.", + "References to the project, creator, assignees, and other attributes." + ], + "does_not_contain": [ + "Comments on issues (see 'issue_comments' table).", + "Attachments (see 'issue_attachments' table).", + "Links (see 'issue_links' table).", + "Assignees (see 'issue_assignees' table)." + ], + "relationships": { + "foreign_keys": { + "created_by_id": "users(id)", + "parent_id": "issues(id)", + "project_id": "projects(id)", + "state_id": "states(id)", + "type_id": "issue_types(id)", + "estimate_point_id": "estimate_points(id)", + "updated_by_id": "users(id)", + "workspace_id": "workspaces(id)" + } + } + }, + "module_issues": { + "about": "The 'module_issues' table links issues to specific modules within Plane. Modules are structural components used to group related tasks.", + "contains": ["Links between issues and modules.", "References to the issue, module, and project."], + "does_not_contain": ["Issue or module details.", "Assignment history."], + "relationships": { + "foreign_keys": { + "created_by_id": "users(id)", + "issue_id": "issues(id)", + "module_id": "modules(id)", + "project_id": "projects(id)", + "updated_by_id": "users(id)", + "workspace_id": "workspaces(id)" + } + } + }, + "module_links": { + "about": "The 'module_links' table stores external links attached to specific modules in Plane.", + "contains": ["URLs and titles of links associated with modules.", "References to the module and project."], + "does_not_contain": ["Module content.", "Link previews or metadata."], + "relationships": { + "foreign_keys": { + "created_by_id": "users(id)", + "module_id": "modules(id)", + "project_id": "projects(id)", + "updated_by_id": "users(id)", + "workspace_id": "workspaces(id)" + } + } + }, + "labels": { + "about": "The 'labels' table stores details about labels, categorization tags used to organize and filter issues in Plane.", + "contains": [ + "Label details including name, description, and hierarchy.", + "References to parent labels for nested labeling." + ], + "does_not_contain": [ + "Associations with issues or pages (see 'issue_labels' and 'page_labels').", + "Usage statistics." + ], + "relationships": { + "foreign_keys": { + "created_by_id": "users(id)", + "parent_id": "labels(id)", + "project_id": "projects(id)", + "updated_by_id": "users(id)", + "workspace_id": "workspaces(id)" + } + } + }, + "issue_views": { + "about": "The 'issue_views' table stores customizable saved views or filters for issues in Plane.", + "contains": [ + "View configurations including name, description, and access level.", + "References to the owner (user) and project." + ], + "does_not_contain": ["Actual issues (it defines how to filter or view them).", "View usage data."], + "relationships": { + "foreign_keys": { + "created_by_id": "users(id)", + "owned_by_id": "users(id)", + "project_id": "projects(id)", + "updated_by_id": "users(id)", + "workspace_id": "workspaces(id)" + } + } + }, + "issue_worklogs": { + "about": "The 'issue_worklogs' table logs time entries for work done on specific issues in Plane. It tracks details such as duration, descriptions, and the user who logged the time.", + "contains": [ + "Worklog entries with duration, description, and timestamps.", + "References to the issue, user who logged the time, and project." + ], + "does_not_contain": ["Issue content.", "Billing or invoicing information."], + "relationships": { + "foreign_keys": { + "created_by_id": "users(id)", + "issue_id": "issues(id)", + "logged_by_id": "users(id)", + "project_id": "projects(id)", + "updated_by_id": "users(id)", + "workspace_id": "workspaces(id)" + } + } + }, + "module_members": { + "about": "The 'module_members' table associates users with specific modules in Plane.", + "contains": [ + "Information about users who are members of modules.", + "References to the module, member (user), and project.", + "Members include real users but not bots like intake bots." + ], + "does_not_contain": ["Module content.", "User roles or permissions."], + "relationships": { + "foreign_keys": { + "created_by_id": "users(id)", + "member_id": "users(id)", + "module_id": "modules(id)", + "project_id": "projects(id)", + "updated_by_id": "users(id)", + "workspace_id": "workspaces(id)" + } + } + }, + "project_identifiers": { + "about": "The 'project_identifiers' table stores unique identifiers and names for projects in Plane. These identifiers help distinguish projects within a workspace and are used for efficient project management and API interactions.", + "contains": [ + "Project identifiers used for referencing or display.", + "References to the project and users involved." + ], + "does_not_contain": ["Project details (see 'projects' table).", "Configuration settings."], + "relationships": { + "foreign_keys": { + "created_by_id": "users(id)", + "project_id": "projects(id)", + "updated_by_id": "users(id)", + "workspace_id": "workspaces(id)" + } + } + }, + "pages": { + "about": "The 'pages' table stores structured content or documents within Plane.", + "contains": [ + "Page details including name, content (in various formats), and access levels.", + "References to the owner, creator, and hierarchical parent pages." + ], + "does_not_contain": [ + "Direct associations with projects (see 'project_pages').", + "Page version history (see 'page_versions')." + ], + "relationships": { + "foreign_keys": { + "created_by_id": "users(id)", + "owned_by_id": "users(id)", + "parent_id": "pages(id)", + "updated_by_id": "users(id)", + "workspace_id": "workspaces(id)" + } + } + }, + "page_labels": { + "about": "The 'page_labels' table links labels to pages in Plane. These labels help categorize and organize pages.", + "contains": ["Links between pages and labels for categorization.", "References to the page, label, and workspace."], + "does_not_contain": ["Label definitions (see 'labels' table).", "Page content."], + "relationships": { + "foreign_keys": { + "created_by_id": "users(id)", + "label_id": "labels(id)", + "page_id": "pages(id)", + "updated_by_id": "users(id)", + "workspace_id": "workspaces(id)" + } + } + }, + "project_attributes": { + "about": "The 'project_attributes' table stores additional metadata for projects in Plane, such as priority, start and target dates, and state.", + "contains": [ + "Attributes like priority, start date, target date, and state.", + "References to the project, state, and users." + ], + "does_not_contain": ["Basic project information (see 'projects').", "Issue data."], + "relationships": { + "foreign_keys": { + "created_by_id": "users(id)", + "project_id": "projects(id)", + "state_id": "project_states(id)", + "updated_by_id": "users(id)", + "workspace_id": "workspaces(id)" + } + } + }, + "project_states": { + "about": "The 'project_states' table stores the state of the project. Examples include states like 'Draft' 'Planning' or 'Monitoring'.", + "contains": [ + "Project State details including name, description, and its group.", + "References to the workspace and users." + ], + "does_not_contain": [ + "Associations with specific projects (see 'project_attributes').", + "Issue states (see 'states' table)." + ], + "relationships": { + "foreign_keys": { + "created_by_id": "users(id)", + "updated_by_id": "users(id)", + "workspace_id": "workspaces(id)" + } + } + }, + "project_issue_types": { + "about": "The 'project_issue_types' table associates customizable issue types with specific projects in Plane.", + "contains": [ + "Information about which issue types are enabled in projects.", + "References to the project, issue type, and users." + ], + "does_not_contain": ["Issue details.", "Issue type definitions (see 'issue_types')."], + "relationships": { + "foreign_keys": { + "created_by_id": "users(id)", + "issue_type_id": "issue_types(id)", + "project_id": "projects(id)", + "updated_by_id": "users(id)", + "workspace_id": "workspaces(id)" + } + } + }, + "project_members": { + "about": "The 'project_members' table tracks users who are members of projects in Plane. It includes roles, membership status, and other details to manage collaboration and access within projects.", + "contains": [ + "Information about project membership, roles, and activity status (If they are active)", + "References to the project and member (user).", + "Members include real users but not bots like intake bots." + ], + "does_not_contain": ["Workspace membership (see 'workspace_members').", "User permissions."], + "relationships": { + "foreign_keys": { + "created_by_id": "users(id)", + "member_id": "users(id)", + "project_id": "projects(id)", + "updated_by_id": "users(id)", + "workspace_id": "workspaces(id)" + } + } + }, + "project_pages": { + "about": "The 'project_pages' table associates pages with projects in Plane.", + "contains": ["Links between projects and pages.", "References to the project, page, and workspace."], + "does_not_contain": ["Page content (see 'pages' table).", "Project details."], + "relationships": { + "foreign_keys": { + "created_by_id": "users(id)", + "page_id": "pages(id)", + "project_id": "projects(id)", + "updated_by_id": "users(id)", + "workspace_id": "workspaces(id)" + } + } + }, + "users": { + "about": "The 'users' table stores information about user accounts in Plane. It includes details such as username, email, names, activity timestamps, and user-specific attributes like timezone and bot status.", + "contains": [ + "User details including username, names, and status flags.", + "Timestamps for login and activity tracking.", + "Include both real users and bots." + ], + "does_not_contain": ["User roles in projects or workspaces.", "project_id", "workspace_id"], + "relationships": { + "referenced_by": [ + "Many tables via user-related foreign keys (e.g., 'created_by_id' or 'updated_by_id').", + "Membership related tables also reference the user via the 'member__id' field." + ] + } + }, + "projects": { + "about": "The 'projects' table stores core information about projects in Plane. Projects act as containers for tasks, timelines, pages, and resources.", + "contains": [ + "Project details including name, description, settings, public/private, and configurations.", + "References to the project lead, default assignee, and workspace." + ], + "does_not_contain": [ + "Issues within the project (see 'issues' table).", + "Project membership (see 'project_members').", + "Project state name (see 'project_states').", + "Project start date, end date, state and priority (see 'project_attributes')." + ], + "relationships": { + "foreign_keys": { + "created_by_id": "users(id)", + "default_assignee_id": "users(id)", + "project_lead_id": "users(id)", + "updated_by_id": "users(id)", + "workspace_id": "workspaces(id)", + "default_state_id": "states(id)", + "estimate_id": "estimates(id)" + } + } + }, + "states": { + "about": "The 'states' table defines possible stages or phases that tasks or issues can go through within a project in Plane. These states, such as 'To Do,' 'In Progress,' and 'Completed,' help track progress and organize workflows.", + "contains": [ + "State details including name, description, sequence", + "References to the project and workspace.", + "The name of state group which state belongs to." + ], + "does_not_contain": [ + "Associations with specific issues (see 'issues' table).", + "Project states (see 'project_states')." + ], + "relationships": { + "foreign_keys": { + "created_by_id": "users(id)", + "project_id": "projects(id)", + "updated_by_id": "users(id)", + "workspace_id": "workspaces(id)" + } + } + }, + "issue_properties": { + "about": "The 'issue_properties' table defines custom fields for issues in Plane. These properties allow teams to capture specific information, such as text, numbers, dates, or dropdown options, tailored to project needs.", + "contains": [ + "Property definitions including name, type, default value, and requirements.", + "References to the issue type and workspace." + ], + "does_not_contain": ["Property values for specific issues (see 'issue_property_values').", "Issue details."], + "relationships": { + "foreign_keys": { + "created_by_id": "users(id)", + "issue_type_id": "issue_types(id)", + "project_id": "projects(id)", + "updated_by_id": "users(id)", + "workspace_id": "workspaces(id)" + } + } + }, + "issue_property_options": { + "about": "The 'issue_property_options' table stores selectable options for dropdown or multi-select custom properties in Plane. These options allow users to define and choose values for custom fields tailored to project needs.", + "contains": [ + "Options for properties that require selection (e.g., dropdowns).", + "References to the property and potentially parent options." + ], + "does_not_contain": ["Property definitions (see 'issue_properties').", "Issue property values."], + "relationships": { + "foreign_keys": { + "parent_id": "issue_property_options(id)", + "property_id": "issue_properties(id)", + "created_by_id": "users(id)", + "project_id": "projects(id)", + "updated_by_id": "users(id)", + "workspace_id": "workspaces(id)" + } + } + }, + "issue_property_values": { + "about": "The 'issue_property_values' table stores the assigned values of custom properties for issues in Plane. These values capture specific information, such as text, numbers, dates, or selected options, based on the custom properties defined for an issue.", + "contains": [ + "Actual values of properties for issues across various data types.", + "References to the issue, property, and any selected options." + ], + "does_not_contain": ["Property definitions.", "Issue details."], + "relationships": { + "foreign_keys": { + "created_by_id": "users(id)", + "issue_id": "issues(id)", + "property_id": "issue_properties(id)", + "value_option_id": "issue_property_options(id)", + "project_id": "projects(id)", + "updated_by_id": "users(id)", + "workspace_id": "workspaces(id)" + } + } + }, + "issue_types": { + "about": "The 'issue_types' table defines various categories of issues in Plane. Issue types allow teams to classify and manage tasks based on specific needs tailored to the project.", + "contains": [ + "Issue type details including name, description, and flags (e.g., is_epic).", + "References to the workspace and users." + ], + "does_not_contain": [ + "Actual issues (see 'issues' table).", + "Issue type configurations for projects (see 'project_issue_types')." + ], + "relationships": { + "foreign_keys": { + "created_by_id": "users(id)", + "updated_by_id": "users(id)", + "workspace_id": "workspaces(id)" + } + } + }, + "workspaces": { + "about": "The 'workspaces' table stores information about workspaces in Plane, which serve as containers for organizing projects and teams.", + "contains": [ + "Workspace details including name, slug, owner, and settings.", + "References to the owner and creator." + ], + "does_not_contain": [ + "Projects within the workspace (see 'projects' table).", + "Workspace members (see 'workspace_members')." + ], + "relationships": { + "foreign_keys": { + "created_by_id": "users(id)", + "owner_id": "users(id)", + "updated_by_id": "users(id)" + } + } + }, + "workspace_members": { + "about": "The 'workspace_members' table tracks users who are members of workspaces in Plane. It includes roles, membership status, and other details at workspace level.", + "contains": [ + "Information about workspace membership, roles, and activity status (If they are active).", + "References to the member (user) and workspace.", + "Members include real users but not bots like intake bots." + ], + "does_not_contain": ["Project membership.", "User permissions."], + "relationships": { + "foreign_keys": { + "created_by_id": "users(id)", + "member_id": "users(id)", + "updated_by_id": "users(id)", + "workspace_id": "workspaces(id)" + } + } + }, + "page_logs": { + "about": "The 'page_logs' table records changes and transactions made to pages in Plane. It tracks updates, edits, and actions.", + "contains": [ + "Records of actions performed on pages.", + "References to the page, user who made the change, and workspace." + ], + "does_not_contain": ["Page content.", "Version history (see 'page_versions')."], + "relationships": { + "foreign_keys": { + "created_by_id": "users(id)", + "page_id": "pages(id)", + "updated_by_id": "users(id)", + "workspace_id": "workspaces(id)" + } + } + }, + "page_versions": { + "about": "The 'page_versions' table stores historical versions of pages in Plane.", + "contains": ["Archived versions of page content with timestamps.", "References to the page, owner, and workspace."], + "does_not_contain": ["Current page content (see 'pages' table).", "Change logs (see 'page_logs')."], + "relationships": { + "foreign_keys": { + "created_by_id": "users(id)", + "owned_by_id": "users(id)", + "page_id": "pages(id)", + "updated_by_id": "users(id)", + "workspace_id": "workspaces(id)" + } + } + }, + "team_spaces": { + "about": "The 'team_spaces' table stores information about team spaces in Plane, which serve as dedicated areas for team collaboration and work tracking.", + "contains": [ + "Team space details including name, description, and logo properties.", + "References to the lead, creator, and workspace." + ], + "does_not_contain": ["Team members (see 'team_space_members').", "Team projects (see 'team_space_projects')."], + "relationships": { + "foreign_keys": { + "created_by_id": "users(id)", + "lead_id": "users(id)", + "updated_by_id": "users(id)", + "workspace_id": "workspaces(id)" + } + } + }, + "team_space_members": { + "about": "The 'team_space_members' table tracks users who are members of team spaces in Plane.", + "contains": ["Information about team space membership.", "References to the team space and member (user)."], + "does_not_contain": ["Team space details.", "User permissions."], + "relationships": { + "foreign_keys": { + "created_by_id": "users(id)", + "member_id": "users(id)", + "team_space_id": "team_spaces(id)", + "updated_by_id": "users(id)", + "workspace_id": "workspaces(id)" + } + } + }, + "team_space_projects": { + "about": "The 'team_space_projects' table links projects to team spaces in Plane.", + "contains": ["Links between projects and team spaces.", "References to the project and team space."], + "does_not_contain": ["Project details.", "Team space details."], + "relationships": { + "foreign_keys": { + "created_by_id": "users(id)", + "project_id": "projects(id)", + "team_space_id": "team_spaces(id)", + "updated_by_id": "users(id)", + "workspace_id": "workspaces(id)" + } + } + }, + "team_space_pages": { + "about": "The 'team_space_pages' table links pages to team spaces in Plane.", + "contains": ["Links between pages and team spaces.", "References to the page and team space."], + "does_not_contain": ["Page content.", "Team space details."], + "relationships": { + "foreign_keys": { + "created_by_id": "users(id)", + "page_id": "pages(id)", + "team_space_id": "team_spaces(id)", + "updated_by_id": "users(id)", + "workspace_id": "workspaces(id)" + } + } + }, + "team_space_labels": { + "about": "The 'team_space_labels' table links labels to team spaces in Plane.", + "contains": ["Links between labels and team spaces.", "References to the label and team space."], + "does_not_contain": ["Label definitions.", "Team space details."], + "relationships": { + "foreign_keys": { + "created_by_id": "users(id)", + "label_id": "labels(id)", + "team_space_id": "team_spaces(id)", + "updated_by_id": "users(id)", + "workspace_id": "workspaces(id)" + } + } + }, + "team_space_comments": { + "about": "The 'team_space_comments' table stores comments related to team spaces in Plane.", + "contains": [ + "Comment content in various formats (plain text, HTML, JSON).", + "References to the team space and comment author." + ], + "does_not_contain": ["Team space details.", "Comment attachments."], + "relationships": { + "foreign_keys": { + "actor_id": "users(id)", + "created_by_id": "users(id)", + "parent_id": "team_space_comments(id)", + "team_space_id": "team_spaces(id)", + "updated_by_id": "users(id)", + "workspace_id": "workspaces(id)" + } + } + }, + "stickies": { + "about": "The 'stickies' table stores personal notes and reminders in Plane.", + "contains": ["Sticky note content and formatting.", "References to the owner and workspace."], + "does_not_contain": ["Attachments.", "Comments."], + "relationships": { + "foreign_keys": { + "created_by_id": "users(id)", + "owner_id": "users(id)", + "updated_by_id": "users(id)", + "workspace_id": "workspaces(id)" + } + } + }, + "issue_votes": { + "about": "The 'issue_votes' table tracks user votes on issues in Plane.", + "contains": ["Vote value and metadata.", "References to the issue and voter."], + "does_not_contain": ["Issue details.", "Vote history."], + "relationships": { + "foreign_keys": { + "actor_id": "users(id)", + "created_by_id": "users(id)", + "issue_id": "issues(id)", + "project_id": "projects(id)", + "updated_by_id": "users(id)", + "workspace_id": "workspaces(id)" + } + } + }, + "initiatives": { + "about": "The 'initiatives' table stores high-level strategic objectives and metadata for initiatives in Plane.", + "contains": [ + "Initiative details including name, descriptions (plain text, HTML, stripped, and binary).", + "Timeline information such as start and end dates.", + "State of the initiative and logo properties stored as JSON.", + "References to the lead, creator, updater, and workspace." + ], + "does_not_contain": [ + "Associated projects (see 'initiative_projects').", + "Initiative epics (see 'initiative_epics')." + ], + "relationships": { + "foreign_keys": { + "created_by_id": "users(id)", + "lead_id": "users(id)", + "updated_by_id": "users(id)", + "workspace_id": "workspaces(id)" + } + } + }, + "initiative_projects": { + "about": "The 'initiative_projects' table links projects to initiatives in Plane.", + "contains": ["Links between projects and initiatives.", "References to the project and initiative."], + "does_not_contain": ["Project details.", "Initiative details."], + "relationships": { + "foreign_keys": { + "created_by_id": "users(id)", + "initiative_id": "initiatives(id)", + "project_id": "projects(id)", + "updated_by_id": "users(id)", + "workspace_id": "workspaces(id)" + } + } + }, + "initiative_epics": { + "about": "The 'initiative_epics' table links epics to initiatives in Plane.", + "contains": ["Links between epics and initiatives.", "References to the epic and initiative."], + "does_not_contain": ["Epic details.", "Initiative details."], + "relationships": { + "foreign_keys": { + "created_by_id": "users(id)", + "epic_id": "issues(id)", + "initiative_id": "initiatives(id)", + "updated_by_id": "users(id)", + "workspace_id": "workspaces(id)" + } + } + }, + "initiative_labels": { + "about": "The 'initiative_labels' table stores label definitions used for initiatives in Plane.", + "contains": [ + "Label details including name, color, and description.", + "References to the workspace and users who created or updated the label." + ], + "does_not_contain": [ + "Links between initiatives and labels (this relationship no longer exists here).", + "Label usage mapping across initiatives." + ], + "relationships": { + "foreign_keys": { + "created_by_id": "users(id)", + "updated_by_id": "users(id)", + "workspace_id": "workspaces(id)" + } + } + }, + "initiative_links": { + "about": "The 'initiative_links' table stores external links attached to initiatives in Plane.", + "contains": [ + "URLs and metadata for links associated with initiatives.", + "References to the initiative and workspace." + ], + "does_not_contain": ["Initiative details.", "Link previews."], + "relationships": { + "foreign_keys": { + "created_by_id": "users(id)", + "initiative_id": "initiatives(id)", + "updated_by_id": "users(id)", + "workspace_id": "workspaces(id)" + } + } + }, + "initiative_comments": { + "about": "The 'initiative_comments' table stores comments related to initiatives in Plane.", + "contains": [ + "Comment content in multiple formats (plain text, HTML, JSON).", + "Metadata such as access level, external source, and edit timestamps.", + "References to the initiative and comment author." + ], + "does_not_contain": ["Initiative details.", "Comment attachments."], + "relationships": { + "foreign_keys": { + "actor_id": "users(id)", + "created_by_id": "users(id)", + "initiative_id": "initiatives(id)", + "updated_by_id": "users(id)", + "workspace_id": "workspaces(id)" + } + } + }, + "initiative_activities": { + "about": "The 'initiative_activities' table records changes and actions performed on initiatives in Plane.", + "contains": [ + "Activity details such as verb, field changes, old/new values, and comments.", + "References to the initiative, actor, and related comment (if any)." + ], + "does_not_contain": ["Initiative details.", "Activity attachments."], + "relationships": { + "foreign_keys": { + "actor_id": "users(id)", + "created_by_id": "users(id)", + "initiative_id": "initiatives(id)", + "initiative_comment_id": "initiative_comments(id)", + "updated_by_id": "users(id)", + "workspace_id": "workspaces(id)" + } + } + } +} diff --git a/apps/pi/pi/app/api/v1/endpoints/chat.py b/apps/pi/pi/app/api/v1/endpoints/chat.py index 96e794cd09..8d290f92b6 100644 --- a/apps/pi/pi/app/api/v1/endpoints/chat.py +++ b/apps/pi/pi/app/api/v1/endpoints/chat.py @@ -381,6 +381,11 @@ async def get_answer_for_silo_app(data: ChatRequest, request: Request, db: Async # Add this action's artifact to the list artifact_data_objects.append(ArtifactData(artifact_id=artifact_id, is_edited=False, action_data=actions_data)) + # Type assertions for mypy (validated above) + assert workspace_id is not None + assert chat_id is not None + assert message_id is not None + # Execute batch actions using the service with ALL artifacts service = BuildModeToolExecutor(chatbot=PlaneChatBot(plane_apps_llm), db=db) result = await service.execute( @@ -411,7 +416,7 @@ async def get_answer_for_silo_app(data: ChatRequest, request: Request, db: Async formatted_context = result # Parse JSON response for proper API format (avoid double-encoded JSON string) - parsed_response: Union[str, Dict[str, Any]] = final_response + parsed_response: Union[str, Dict[str, Any], Any] = final_response if response_type == "response" and final_response: try: # Try to parse as JSON for structured app responses @@ -516,6 +521,11 @@ async def get_answer_for_slack(data: ChatRequest, request: Request, db: AsyncSes # Add this action's artifact to the list artifact_data_objects.append(ArtifactData(artifact_id=artifact_id, is_edited=False, action_data=actions_data)) + # Type assertions for mypy (validated above) + assert workspace_id is not None + assert chat_id is not None + assert message_id is not None + # Execute batch actions using the service with ALL artifacts service = BuildModeToolExecutor(chatbot=PlaneChatBot(slack_ai_llm), db=db) result = await service.execute( diff --git a/apps/pi/pi/app/api/v2/endpoints/responses.py b/apps/pi/pi/app/api/v2/endpoints/responses.py index ea152d11e1..cb132405c0 100644 --- a/apps/pi/pi/app/api/v2/endpoints/responses.py +++ b/apps/pi/pi/app/api/v2/endpoints/responses.py @@ -178,6 +178,11 @@ async def create_response_slack(data: ChatRequest, request: Request, db: AsyncSe artifact_data_objects.append(ArtifactData(artifact_id=artifact_id, is_edited=False, action_data=actions_data)) + # Type assertions for mypy (validated above) + assert workspace_id is not None + assert chat_id is not None + assert message_id is not None + # Execute batch actions using the service service = BuildModeToolExecutor(chatbot=PlaneChatBot("gpt-4.1"), db=db) diff --git a/apps/pi/pi/config.py b/apps/pi/pi/config.py index 083ceb2ed4..e228c9bbc4 100644 --- a/apps/pi/pi/config.py +++ b/apps/pi/pi/config.py @@ -428,6 +428,10 @@ class Settings: colorlog.getLogger("httpx").setLevel(colorlog.WARNING) colorlog.getLogger("httpcore").setLevel(colorlog.WARNING) + # Suppress Anthropic client debug logs + colorlog.getLogger("anthropic").setLevel(colorlog.WARNING) + colorlog.getLogger("anthropic._base_client").setLevel(colorlog.WARNING) + # Suppress boto3/botocore debug logs colorlog.getLogger("boto3").setLevel(colorlog.INFO) colorlog.getLogger("botocore").setLevel(colorlog.INFO) @@ -453,13 +457,32 @@ class Settings: colorlog.getLogger("datadog").setLevel(colorlog.INFO) colorlog.getLogger("datadog.dogstatsd").setLevel(colorlog.INFO) - colorlog.getLogger("ddtrace.internal.telemetry").setLevel(colorlog.INFO) - colorlog.getLogger("ddtrace.internal.telemetry.writer").setLevel(colorlog.INFO) + # Conditional logging format based on DD_ENABLED (Datadog) + # When Datadog is enabled (production), use JSON logging for structured logs + # When Datadog is disabled (local dev), use colored logs for readability + dd_enabled = get_env_bool("DD_ENABLED", "0") + if dd_enabled: + # Production: Use JSON formatter for structured logging (Datadog) + from pythonjsonlogger.json import JsonFormatter - from pythonjsonlogger.json import JsonFormatter - - json_formatter = JsonFormatter(fmt="%(asctime)s %(name)s %(levelname)s %(message)s", datefmt="%Y-%m-%d %H:%M:%S") - handler.setFormatter(json_formatter) + colorlog.getLogger("ddtrace.internal.telemetry").setLevel(colorlog.INFO) + colorlog.getLogger("ddtrace.internal.telemetry.writer").setLevel(colorlog.INFO) + json_formatter = JsonFormatter(fmt="%(asctime)s %(name)s %(levelname)s %(message)s", datefmt="%Y-%m-%d %H:%M:%S") + handler.setFormatter(json_formatter) + else: + # Local development: Use colorlog for readable output + color_formatter = colorlog.ColoredFormatter( + "%(log_color)s%(asctime)s %(name)-20s %(levelname)-8s%(reset)s %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + log_colors={ + "DEBUG": "cyan", + "INFO": "green", + "WARNING": "yellow", + "ERROR": "red", + "CRITICAL": "red,bg_white", + }, + ) + handler.setFormatter(color_formatter) # Get the root logger and configure it root_logger = colorlog.getLogger() diff --git a/apps/pi/pi/services/actions/artifacts/schemas/project.py b/apps/pi/pi/services/actions/artifacts/schemas/project.py index 02a6343ed3..8263b1c08f 100644 --- a/apps/pi/pi/services/actions/artifacts/schemas/project.py +++ b/apps/pi/pi/services/actions/artifacts/schemas/project.py @@ -17,8 +17,9 @@ from typing import Optional from pydantic import BaseModel # Field list for projects (matching edit modal fields) -# Edit modal shows: name, description, logo_props (emoji/icon), cover_image, network, project_lead -PROJECT_FIELDS = ["name", "description", "logo_props", "cover_image", "network", "project_lead"] +# Edit modal shows: name, description, network, project_lead +# NOTE: logo_props, icon_prop, emoji, cover_image are auto-generated by SDK and should not be included +PROJECT_FIELDS = ["name", "description", "network", "project_lead"] class ProjectProperties(BaseModel): diff --git a/apps/pi/pi/services/actions/plane_actions_executor.py b/apps/pi/pi/services/actions/plane_actions_executor.py index 21f82d801e..02a4e51831 100644 --- a/apps/pi/pi/services/actions/plane_actions_executor.py +++ b/apps/pi/pi/services/actions/plane_actions_executor.py @@ -23,6 +23,8 @@ from typing import List from typing import Optional from typing import Union +from plane.errors import HttpError # type: ignore[attr-defined] + from .plane_sdk_adapter import PlaneSDKAdapter from .registry import get_available_categories from .registry import get_category_methods @@ -62,6 +64,11 @@ class PlaneActionsExecutor: self.states = self.sdk_adapter self.users = self.sdk_adapter self.workitems = self.sdk_adapter + self.workspaces = self.sdk_adapter + self.initiatives = self.sdk_adapter + self.teamspaces = self.sdk_adapter + self.stickies = self.sdk_adapter + self.customers = self.sdk_adapter def get_api_categories(self) -> Dict[str, str]: """ @@ -105,6 +112,31 @@ class PlaneActionsExecutor: return {"success": True, "category": category, "method": method, "data": result} + except HttpError as e: + # Centralized handling for auth errors (401/403) + status_code = getattr(e, "status_code", None) + if status_code in (401, 403): + log.warning(f"Auth error ({status_code}) in {category}.{method}: {e}") + return { + "success": False, + "error": "unauthorized", + "message": "Authentication or authorization failed. Please check your credentials and try again.", + "status_code": status_code, + "category": category, + "method": method, + } + # Non-auth HTTP errors + error_msg = f"HTTP error executing {category}.{method}: {str(e)}" + log.error(error_msg) + return { + "success": False, + "category": category, + "method": method, + "error": error_msg, + "error_type": "HttpError", + "status_code": status_code, + } + except Exception as e: error_msg = f"Error executing {category}.{method}: {str(e)}" log.error(error_msg) diff --git a/apps/pi/pi/services/actions/plane_sdk_adapter.py b/apps/pi/pi/services/actions/plane_sdk_adapter.py index 9b8b8fad6f..6c11b69a2b 100644 --- a/apps/pi/pi/services/actions/plane_sdk_adapter.py +++ b/apps/pi/pi/services/actions/plane_sdk_adapter.py @@ -23,6 +23,7 @@ Only this file should need changes when the SDK evolves. import logging from typing import Any from typing import Dict +from typing import Iterable from typing import List from typing import Optional from typing import Union @@ -104,9 +105,10 @@ class PlaneSDKAdapter: # Handle Pydantic v2 models if hasattr(model, "model_dump"): - # Use Pydantic v2's model_dump method - data = model.model_dump() - # Recursively convert nested models + # Use Pydantic v2's model_dump method with mode='json' to ensure + # all types (including enums, dates, UUIDs) are converted to JSON-compatible primitives + data = model.model_dump(mode="json") + # Recursively convert any remaining nested models return {k: self._model_to_dict(v) for k, v in data.items()} elif hasattr(model, "dict"): # Fallback for backwards compatibility @@ -116,6 +118,12 @@ class PlaneSDKAdapter: # Return primitive types as-is return model + def _filter_payload(self, data: Dict[str, Any], allowed_keys: Optional[Iterable[str]] = None, remove_none: bool = True) -> Dict[str, Any]: + """ + Filter dictionary with options to remove None values and restrict to allowed keys. + """ + return {k: v for k, v in data.items() if (not remove_none or v is not None) and (allowed_keys is None or k in allowed_keys)} + def _safe_model_to_dict(self, model: Any) -> Union[Dict[str, Any], List[Any], Any]: """ Safely convert Pydantic v2 models to plain dictionaries. @@ -187,10 +195,13 @@ class PlaneSDKAdapter: data["start_date"] = start_date if target_date: data["target_date"] = target_date + # Explicitly add type_id if provided + if kwargs.get("type_id") is not None: + data["type_id"] = kwargs.pop("type_id") # Allow extra fields to pass-through (SDK DTO uses extra="ignore") if kwargs: - filtered_kwargs = {k: v for k, v in kwargs.items() if v is not None} + filtered_kwargs = self._filter_payload(kwargs) data.update(filtered_kwargs) request_model = CreateWorkItem(**data) @@ -270,6 +281,29 @@ class PlaneSDKAdapter: log.error(f"Failed to retrieve work item: {e} ({getattr(e, "status_code", None)})") raise except Exception as e: + # Handle Pydantic validation errors when using selective fields with expand + error_str = str(e) + if "validation error" in error_str.lower() and params: + log.warning(f"SDK validation failed with expand, using raw HTTP fallback: {e}") + try: + import httpx + + url = f"{self._base_url}/api/v1/workspaces/{workspace_slug}/projects/{project_id}/work-items/{issue_id}/" + if params and params.expand: + url += f"?expand={params.expand}" + + # Use access_token or api_key for auth + if self._access_token: + headers = {"Authorization": f"Bearer {self._access_token}"} + else: + headers = {"X-API-Key": str(self._api_key)} + + response = httpx.get(url, headers=headers, timeout=30.0) + response.raise_for_status() + return response.json() + except Exception as fallback_error: + log.error(f"Fallback request failed: {fallback_error}") + raise e log.error(f"Failed to retrieve work item: {str(e)}") raise @@ -414,6 +448,49 @@ class PlaneSDKAdapter: log.error(f"Failed to create work item relation: {str(e)}") raise + def search_work_items(self, workspace_slug: str, query: str, **kwargs) -> Dict[str, Any]: + """Search work items across workspace using v0.2 client. + + Args: + workspace_slug: Workspace slug + query: Search query string + **kwargs: Optional parameters like expand, fields, etc. + + Returns: + Dict with search results + """ + try: + params = None + if kwargs: + from plane.models.query_params import RetrieveQueryParams # type: ignore[attr-defined] + + expand = kwargs.get("expand") + fields = kwargs.get("fields") + params = RetrieveQueryParams(expand=expand, fields=fields) + + response = self.client.work_items.search( + workspace_slug=workspace_slug, + query=query, + params=params, + ) + + # Extract results from search response + results = self._model_to_dict(getattr(response, "results", [])) + if not isinstance(results, list): + results = [results] if results else [] + + return { + "results": results, + "count": len(results), + "total_results": getattr(response, "total_count", len(results)), + } + except HttpError as e: + log.error(f"Failed to search work items: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to search work items: {str(e)}") + raise + # ============================================================================ # USERS API METHODS # ============================================================================ @@ -464,7 +541,7 @@ class PlaneSDKAdapter: } if kwargs: - filtered_kwargs = {k: v for k, v in kwargs.items() if v is not None} + filtered_kwargs = self._filter_payload(kwargs) data.update(filtered_kwargs) project = self.client.projects.create(workspace_slug=workspace_slug, data=CreateProject(**data)) @@ -582,6 +659,50 @@ class PlaneSDKAdapter: log.error(f"Failed to delete project: {str(e)}") raise + def get_project_features(self, workspace_slug: str, project_id: str) -> Dict[str, Any]: + """Get enabled project features (v0.2.1+).""" + try: + features = self.client.projects.get_features(workspace_slug=workspace_slug, project_id=project_id) + result = self._model_to_dict(features) + if isinstance(result, dict): + return result + else: + return {"data": result} + except HttpError as e: + log.error(f"Failed to get project features: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to get project features: {str(e)}") + raise + + def update_project_features(self, workspace_slug: str, project_id: str, **features) -> Dict[str, Any]: + """Update project features (v0.2.1+). Enable/disable epics, cycles, modules, etc.""" + try: + from plane.models.projects import ProjectFeature # type: ignore[attr-defined] + + # Build feature update payload + feature_data = {} + for key in ["epics", "modules", "cycles", "views", "pages", "intakes", "work_item_types"]: + if key in features and features[key] is not None: + feature_data[key] = features[key] + + if not feature_data: + raise ValueError("At least one feature field must be provided") + + data_model = ProjectFeature(**feature_data) + result = self.client.projects.update_features(workspace_slug=workspace_slug, project_id=project_id, data=data_model) + result_dict = self._model_to_dict(result) + if isinstance(result_dict, dict): + return result_dict + else: + return {"data": result_dict} + except HttpError as e: + log.error(f"Failed to update project features: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to update project features: {str(e)}") + raise + # ============================================================================ # ASSETS API METHODS # ============================================================================ @@ -699,7 +820,7 @@ class PlaneSDKAdapter: def update_label(self, workspace_slug: str, project_id: str, label_id: str, **kwargs) -> Dict[str, Any]: """Update a label (v0.2).""" try: - payload = {k: v for k, v in kwargs.items() if v is not None} + payload = self._filter_payload(kwargs) data_model = UpdateLabel(**payload) resp = self.client.labels.update(workspace_slug, project_id, label_id, data=data_model) return cast(Dict[str, Any], self._model_to_dict(resp)) @@ -765,7 +886,7 @@ class PlaneSDKAdapter: required = {"name", "color", "group"} if not required.issubset(set(kwargs.keys())): raise ValueError("name, color, group are required to create a state") - data_model = CreateState(**{k: v for k, v in kwargs.items() if v is not None}) + data_model = CreateState(**self._filter_payload(kwargs)) resp = self.client.states.create(workspace_slug, project_id, data=data_model) return cast(Dict[str, Any], self._model_to_dict(resp)) except HttpError as e: @@ -778,7 +899,7 @@ class PlaneSDKAdapter: def update_state(self, workspace_slug: str, project_id: str, state_id: str, **kwargs) -> Dict[str, Any]: """Update a state (v0.2).""" try: - data_model = UpdateState(**{k: v for k, v in kwargs.items() if v is not None}) + data_model = UpdateState(**self._filter_payload(kwargs)) resp = self.client.states.update(workspace_slug, project_id, state_id, data=data_model) return cast(Dict[str, Any], self._model_to_dict(resp)) except HttpError as e: @@ -885,7 +1006,7 @@ class PlaneSDKAdapter: def update_module(self, workspace_slug: str, project_id: str, module_id: str, **kwargs) -> Dict[str, Any]: """Update a module (v0.2).""" try: - payload = {k: v for k, v in kwargs.items() if v is not None} + payload = self._filter_payload(kwargs) data_model = ModulesUpdateModule(**payload) resp = self.client.modules.update(workspace_slug, project_id, module_id, data=data_model) return cast(Dict[str, Any], self._model_to_dict(resp)) @@ -1064,7 +1185,7 @@ class PlaneSDKAdapter: payload["logo_props"] = logo_props # Add any extra kwargs - payload.update({k: v for k, v in kwargs.items() if v is not None}) + payload.update(self._filter_payload(kwargs)) # Ensure name is present (required field) if "name" not in payload or not payload["name"]: @@ -1127,7 +1248,7 @@ class PlaneSDKAdapter: payload["logo_props"] = logo_props # Add any extra kwargs - payload.update({k: v for k, v in kwargs.items() if v is not None}) + payload.update(self._filter_payload(kwargs)) # Ensure name is present (required field) if "name" not in payload or not payload["name"]: @@ -1146,48 +1267,6 @@ class PlaneSDKAdapter: log.error(f"Failed to create workspace page: {str(e)}") raise - def list_workspace_pages(self, workspace_slug: str) -> Dict[str, Any]: - """List workspace pages (v0.2).""" - try: - response = self.client.pages.list_workspace_pages(workspace_slug) - results = self._model_to_dict(getattr(response, "results", [])) - if not isinstance(results, list): - results = [results] if results else [] - return { - "results": results, - "count": len(results), - "total_results": getattr(response, "total_count", len(results)), - "next_cursor": str(getattr(response, "next_page_number", "")) if getattr(response, "next_page_number", None) else None, - "prev_cursor": str(getattr(response, "prev_page_number", "")) if getattr(response, "prev_page_number", None) else None, - } - except HttpError as e: - log.error(f"Failed to list workspace pages: {e} ({getattr(e, "status_code", None)})") - raise - except Exception as e: - log.error(f"Failed to list workspace pages: {str(e)}") - raise - - def list_project_pages(self, workspace_slug: str, project_id: str) -> Dict[str, Any]: - """List project pages (v0.2).""" - try: - response = self.client.pages.list_project_pages(workspace_slug, project_id) - results = self._model_to_dict(getattr(response, "results", [])) - if not isinstance(results, list): - results = [results] if results else [] - return { - "results": results, - "count": len(results), - "total_results": getattr(response, "total_count", len(results)), - "next_cursor": str(getattr(response, "next_page_number", "")) if getattr(response, "next_page_number", None) else None, - "prev_cursor": str(getattr(response, "prev_page_number", "")) if getattr(response, "prev_page_number", None) else None, - } - except HttpError as e: - log.error(f"Failed to list project pages: {e} ({getattr(e, "status_code", None)})") - raise - except Exception as e: - log.error(f"Failed to list project pages: {str(e)}") - raise - def retrieve_workspace_page(self, workspace_slug: str, page_id: str) -> Dict[str, Any]: """Retrieve a workspace page (v0.2).""" try: @@ -1248,23 +1327,37 @@ class PlaneSDKAdapter: # CreateCycle requires owned_by; fail early with clear message raise ValueError("owner could not be determined; please provide owned_by explicitly") - data["project_id"] = project_id - # Only include kwargs that are not None to avoid sending null values to API if kwargs: - filtered_kwargs = {k: v for k, v in kwargs.items() if v is not None} + filtered_kwargs = self._filter_payload(kwargs) data.update(filtered_kwargs) - print("create cycle p args", workspace_slug, project_id) - print("create cycle data", data) model = CreateCycle(**data) cycle = self.client.cycles.create(workspace_slug, project_id, data=model) return cast(Dict[str, Any], self._model_to_dict(cycle)) except HttpError as e: + # Try to extract detailed error message from response + error_detail = "No detail available" + try: + if hasattr(e, "body") and e.body: + error_detail = str(e.body) + elif hasattr(e, "response") and e.response: + if hasattr(e.response, "text"): + error_detail = e.response.text + elif hasattr(e.response, "content"): + error_detail = e.response.content.decode("utf-8") if isinstance(e.response.content, bytes) else str(e.response.content) + elif hasattr(e, "message"): + error_detail = e.message + except Exception as extract_err: + log.error(f"Failed to extract error detail: {extract_err}") + log.error(f"Failed to create cycle: {e} ({getattr(e, "status_code", None)})") + log.error(f"Cycle creation data: {data}") + log.error(f"API error detail: {error_detail}") raise except Exception as e: log.error(f"Failed to create cycle: {str(e)}") + log.error(f"Cycle creation data: {data}") raise def list_cycles( @@ -1330,7 +1423,7 @@ class PlaneSDKAdapter: """Update a cycle using PlaneClient v0.2 and return as dict.""" try: # Build payload with only non-None values - payload = {k: v for k, v in kwargs.items() if v is not None} + payload = self._filter_payload(kwargs) model = UpdateCycle(**payload) cycle = self.client.cycles.update(workspace_slug, project_id, cycle_id, data=model) @@ -1465,10 +1558,27 @@ class PlaneSDKAdapter: # ============================================================================ def create_intake_work_item(self, workspace_slug: str, project_id: str, **kwargs) -> Dict[str, Any]: - """Create a new intake work item (v0.2).""" + """Create a new intake work item (v0.2). + + The SDK expects: CreateIntakeWorkItem(issue=WorkItemForIntakeRequest(name=..., ...)) + We receive flat kwargs like: name, description_html, priority + """ + from plane.models.work_items import WorkItemForIntakeRequest # type: ignore[attr-defined] + try: - payload = {k: v for k, v in kwargs.items() if v is not None} - data_model = CreateIntakeWorkItem(**payload) + # Extract issue-related fields from kwargs + issue_fields = {} + for field in ["name", "description", "description_html", "priority"]: + if field in kwargs and kwargs[field] is not None: + issue_fields[field] = kwargs[field] + + if not issue_fields.get("name"): + raise ValueError("'name' is required for intake work item creation") + + # Create the nested structure expected by SDK + issue_request = WorkItemForIntakeRequest(**issue_fields) + data_model = CreateIntakeWorkItem(issue=issue_request) + resp = self.client.intake.create(workspace_slug, project_id, data=data_model) return cast(Dict[str, Any], self._model_to_dict(resp)) except HttpError as e: @@ -1496,15 +1606,29 @@ class PlaneSDKAdapter: # Pass params to SDK response = self.client.intake.list(workspace_slug, project_id, params=params or None) - results = self._model_to_dict(getattr(response, "results", [])) + + # Handle both dict and Pydantic model responses + if isinstance(response, dict): + raw_results = response.get("results", []) + total_count = response.get("total_count", len(raw_results) if raw_results else 0) + next_cursor = response.get("next_page_number") + prev_cursor = response.get("prev_page_number") + else: + raw_results = getattr(response, "results", []) + total_count = getattr(response, "total_count", len(raw_results) if raw_results else 0) + next_cursor = getattr(response, "next_page_number", None) + prev_cursor = getattr(response, "prev_page_number", None) + + results = self._model_to_dict(raw_results) if not isinstance(results, list): results = [results] if results else [] + return { "results": results, "count": len(results), - "total_results": getattr(response, "total_count", len(results)), - "next_cursor": str(getattr(response, "next_page_number", "")) if getattr(response, "next_page_number", None) else None, - "prev_cursor": str(getattr(response, "prev_page_number", "")) if getattr(response, "prev_page_number", None) else None, + "total_results": total_count, + "next_cursor": str(next_cursor) if next_cursor else None, + "prev_cursor": str(prev_cursor) if prev_cursor else None, } except HttpError as e: log.error(f"Failed to list intake work items: {e} ({getattr(e, "status_code", None)})") @@ -1526,10 +1650,46 @@ class PlaneSDKAdapter: raise def update_intake_work_item(self, workspace_slug: str, project_id: str, intake_id: str, **kwargs) -> Dict[str, Any]: - """Update an intake work item (v0.2).""" + """Update an intake work item (v0.2). + + The SDK expects issue fields in: UpdateIntakeWorkItem(issue={...}) + Non-issue fields (status, snoozed_till, duplicate_to, source, source_email) are passed directly. + + Note: WorkItemForIntakeRequest requires 'name'. If updating other issue fields without name, + we must fetch the current name first to satisfy the SDK model validation. + """ try: - payload = {k: v for k, v in kwargs.items() if v is not None} - data_model = UpdateIntakeWorkItem(**payload) + # Separate issue fields from other intake fields + issue_field_names = {"name", "description", "description_html", "priority"} + intake_field_names = {"status", "snoozed_till", "duplicate_to", "source", "source_email"} + + issue_fields = {} + intake_fields = {} + + for k, v in kwargs.items(): + if v is not None: + if k in issue_field_names: + issue_fields[k] = v + elif k in intake_field_names: + intake_fields[k] = v + + # Build the update payload + if issue_fields: + # If name is missing but we have other issue fields, fetch current name + if "name" not in issue_fields: + try: + current_item = self.retrieve_intake_work_item(workspace_slug, project_id, intake_id) + # The retrieve response has 'issue_detail' which contains the name + issue_name = (current_item or {}).get("issue_detail", {}).get("name") + if issue_name: + issue_fields["name"] = issue_name + except Exception as e: + # Fallback or log if retrieval fails, though validation will likely fail next + log.warning(f"Failed to fetch current intake name for update: {e}") + + intake_fields["issue"] = issue_fields # Pass as dict to let Pydantic handle it + + data_model = UpdateIntakeWorkItem(**intake_fields) resp = self.client.intake.update(workspace_slug, project_id, intake_id, data=data_model) return cast(Dict[str, Any], self._model_to_dict(resp)) except HttpError as e: @@ -1607,14 +1767,10 @@ class PlaneSDKAdapter: # ACTIVITY API METHODS # ============================================================================ - def list_work_item_activities(self, workspace_slug: str, project_id: str, issue_id: Optional[str] = None) -> Dict[str, Any]: - """List work item activities (v0.2).""" + def list_work_item_activities(self, workspace_slug: str, project_id: str, issue_id: str) -> Dict[str, Any]: + """List work item activities (v0.2). Requires issue_id - activities are work-item-specific.""" try: - if issue_id: - response = self.client.work_items.activity.list(workspace_slug, project_id, issue_id) - else: - # Best-effort: try project-level activities - response = self.client.work_items.activity.list(workspace_slug, project_id, "") + response = self.client.work_items.activities.list(workspace_slug, project_id, issue_id) results = self._model_to_dict(getattr(response, "results", [])) if not isinstance(results, list): results = [results] if results else [] @@ -1635,7 +1791,7 @@ class PlaneSDKAdapter: def retrieve_work_item_activity(self, workspace_slug: str, project_id: str, issue_id: str, activity_id: str) -> Dict[str, Any]: """Retrieve a specific work item activity (v0.2).""" try: - resp = self.client.work_items.activity.retrieve(workspace_slug, project_id, issue_id, activity_id) + resp = self.client.work_items.activities.retrieve(workspace_slug, project_id, issue_id, activity_id) return cast(Dict[str, Any], self._model_to_dict(resp)) except HttpError as e: log.error(f"Failed to retrieve work item activity: {e} ({getattr(e, "status_code", None)})") @@ -1651,7 +1807,7 @@ class PlaneSDKAdapter: def create_work_item_attachment(self, workspace_slug: str, project_id: str, issue_id: str, **kwargs) -> Dict[str, Any]: """Create a work item attachment (v0.2).""" try: - payload = {k: v for k, v in kwargs.items() if v is not None} + payload = self._filter_payload(kwargs) data_model = WorkItemAttachmentUploadRequest(**payload) resp = self.client.work_items.attachments.create(workspace_slug, project_id, issue_id, data=data_model) return cast(Dict[str, Any], self._model_to_dict(resp)) @@ -1702,7 +1858,7 @@ class PlaneSDKAdapter: def update_work_item_attachment(self, workspace_slug: str, project_id: str, issue_id: str, attachment_id: str, **kwargs) -> Dict[str, Any]: """Update a work item attachment (v0.2).""" try: - payload = {k: v for k, v in kwargs.items() if v is not None} + payload = self._filter_payload(kwargs) data_model = UpdateWorkItemAttachment(**payload) resp = self.client.work_items.attachments.update(workspace_slug, project_id, issue_id, attachment_id, data=data_model) return cast(Dict[str, Any], self._model_to_dict(resp)) @@ -1732,7 +1888,7 @@ class PlaneSDKAdapter: def create_work_item_comment(self, workspace_slug: str, project_id: str, issue_id: str, **kwargs) -> Dict[str, Any]: """Create a work item comment (v0.2). Requires comment or comment_html.""" try: - payload = {k: v for k, v in kwargs.items() if v is not None} + payload = self._filter_payload(kwargs) data_obj = CreateWorkItemComment(**payload) resp = self.client.work_items.comments.create(workspace_slug, project_id, issue_id, data=data_obj) return cast(Dict[str, Any], self._model_to_dict(resp)) @@ -1783,7 +1939,7 @@ class PlaneSDKAdapter: def update_work_item_comment(self, workspace_slug: str, project_id: str, issue_id: str, comment_id: str, **kwargs) -> Dict[str, Any]: """Update a work item comment (v0.2).""" try: - payload = {k: v for k, v in kwargs.items() if v is not None} + payload = self._filter_payload(kwargs) data_obj = UpdateWorkItemComment(**payload) resp = self.client.work_items.comments.update(workspace_slug, project_id, issue_id, comment_id, data=data_obj) return cast(Dict[str, Any], self._model_to_dict(resp)) @@ -1811,9 +1967,23 @@ class PlaneSDKAdapter: # ============================================================================ def create_work_item_link(self, workspace_slug: str, project_id: str, issue_id: str, **kwargs) -> Dict[str, Any]: - """Create a work item link (v0.2). Requires url.""" + """Create a work item link (v0.2). Requires url. + + Note: The CreateWorkItemLink model only accepts 'url' field. Other fields like 'title' + and 'metadata' are not supported and will be filtered out. + """ try: - payload = {k: v for k, v in kwargs.items() if v is not None} + # Filter payload to only include fields that the CreateWorkItemLink model accepts + # CreateWorkItemLink only has 'url' field + allowed_fields = {"url"} + payload = self._filter_payload(kwargs, allowed_fields) + + # Ensure URL has a protocol + if "url" in payload and payload["url"]: + url = payload["url"] + if not url.startswith(("http://", "https://")): + payload["url"] = f"https://{url}" + data_model = CreateWorkItemLink(**payload) resp = self.client.work_items.links.create(workspace_slug, project_id, issue_id, data=data_model) return cast(Dict[str, Any], self._model_to_dict(resp)) @@ -1864,7 +2034,7 @@ class PlaneSDKAdapter: def update_issue_link(self, workspace_slug: str, project_id: str, issue_id: str, link_id: str, **kwargs) -> Dict[str, Any]: """Update a work item link (v0.2).""" try: - payload = {k: v for k, v in kwargs.items() if v is not None} + payload = self._filter_payload(kwargs) data_model = UpdateWorkItemLink(**payload) resp = self.client.work_items.links.update(workspace_slug, project_id, issue_id, link_id, data=data_model) return cast(Dict[str, Any], self._model_to_dict(resp)) @@ -1893,7 +2063,20 @@ class PlaneSDKAdapter: def create_issue_property(self, workspace_slug: str, project_id: str, type_id: str, **kwargs) -> Dict[str, Any]: """Create an issue property (v0.2).""" + from plane.models.work_item_property_configurations import DateAttributeSettings # type: ignore[attr-defined] + from plane.models.work_item_property_configurations import TextAttributeSettings # type: ignore[attr-defined] + try: + # Inject default settings if missing + if "settings" not in kwargs: + prop_type = kwargs.get("property_type") + if prop_type == "DATETIME": + # Use a sensible default format + kwargs["settings"] = DateAttributeSettings(display_format="MMM dd, yyyy") + elif prop_type == "TEXT": + # Default to multi-line text + kwargs["settings"] = TextAttributeSettings(display_format="multi-line") + # Use SDK model for validation and serialization property_data = CreateWorkItemProperty(**kwargs) resp = self.client.work_item_properties.create(workspace_slug, project_id, type_id, data=property_data) @@ -1968,36 +2151,169 @@ class PlaneSDKAdapter: def create_issue_property_option( self, workspace_slug: str, project_id: str, property_id: str, type_id: Optional[str] = None, **kwargs ) -> Dict[str, Any]: - """Create an issue property option (v0.2 - not yet implemented).""" - raise NotImplementedError("create_issue_property_option not yet available in v0.2") + """Create an issue property option (v0.2).""" + from plane.models.work_item_properties import CreateWorkItemPropertyOption # type: ignore[attr-defined] + + try: + payload = self._filter_payload(kwargs) + data_model = CreateWorkItemPropertyOption(**payload) + resp = self.client.work_item_properties.options.create(workspace_slug, project_id, property_id, data=data_model) + return cast(Dict[str, Any], self._model_to_dict(resp)) + except HttpError as e: + log.error(f"Failed to create issue property option: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to create issue property option: {str(e)}") + raise def create_issue_property_value( self, workspace_slug: str, project_id: str, property_id: str, type_id: Optional[str] = None, issue_id: Optional[str] = None, **kwargs ) -> Dict[str, Any]: - """Create an issue property value (v0.2 - not yet implemented).""" - raise NotImplementedError("create_issue_property_value not yet available in v0.2") + """Create or update an issue property value (v0.2). + + Note: The SDK's create method acts as an upsert (create or update). + For multi-value properties, existing values are replaced. + + Args: + issue_id: Work item ID (required) + property_id: Property ID (required) + value: The value to set (passed in kwargs) + """ + from plane.models.work_item_properties import CreateWorkItemPropertyValue # type: ignore[attr-defined] + + if not issue_id: + raise ValueError("issue_id is required to set a property value") + + try: + # value is passed in kwargs, e.g. from ToolParameter "value" + # Filter to only allowed fields: 'value', 'external_id', 'external_source' + allowed_fields = {"value", "external_id", "external_source"} + payload = self._filter_payload(kwargs, allowed_fields) + + # Ensure value is present + if "value" not in payload: + raise ValueError("value is required") + + # Convert value to appropriate type based on string representation + val = payload["value"] + + # Handle stringified lists (e.g., for multi-select OPTION properties) + if isinstance(val, str) and val.strip().startswith("[") and val.strip().endswith("]"): + try: + import json + + parsed = json.loads(val) + if isinstance(parsed, list): + payload["value"] = parsed + val = parsed # Update val for further processing + except Exception: + # Ignore parsing errors, assume it's just a string value starting with [ + pass + + # Handle boolean conversion (e.g., "true" -> True, "false" -> False) + if isinstance(val, str) and val.lower() in ("true", "false"): + payload["value"] = val.lower() == "true" + # Handle numeric conversion for DECIMAL properties (e.g., "10" -> 10.0) + elif isinstance(val, str): + try: + # Check if it's a valid number + num_val = float(val) + # If it's a whole number, keep it as int, otherwise float + payload["value"] = int(num_val) if num_val.is_integer() else num_val + except (ValueError, AttributeError): + # Not a number. Check if it looks like a URL missing protocol (heuristic for URL properties) + if ( + "." in val + and " " not in val + and len(val) > 3 + and not val.startswith(("http://", "https://", "ftp://")) + and not val.startswith("/") + ): + # Heuristic: prepend https:// if it looks like a domain + payload["value"] = f"https://{val}" + + data_model = CreateWorkItemPropertyValue(**payload) + + # Note: type_id is unused for values.create but kept in signature for tool compatibility + resp = self.client.work_item_properties.values.create(workspace_slug, project_id, issue_id, property_id, data=data_model) + + # Response can be a single object or list (for multi-value) + results = self._model_to_dict(resp) + return {"result": results, "success": True} + + except HttpError as e: + log.error(f"Failed to create issue property value: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to create issue property value: {str(e)}") + raise def list_issue_property_options(self, workspace_slug: str, project_id: str, property_id: str, type_id: Optional[str] = None) -> Dict[str, Any]: - """List issue property options (v0.2 - not yet implemented).""" - raise NotImplementedError("list_issue_property_options not yet available in v0.2") + """List issue property options (v0.2).""" + try: + response = self.client.work_item_properties.options.list(workspace_slug, project_id, property_id) + # SDK returns list directly, not paginated + results = self._model_to_dict(response) + if not isinstance(results, list): + results = [results] if results else [] + return { + "results": results, + "count": len(results), + "total_results": len(results), + } + except HttpError as e: + log.error(f"Failed to list issue property options: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to list issue property options: {str(e)}") + raise def list_issue_property_values(self, workspace_slug: str, project_id: str, type_id: str, issue_id: str) -> Dict[str, Any]: """List issue property values (v0.2 - not yet implemented).""" + # TODO: Implement when SDK supports property values listing raise NotImplementedError("list_issue_property_values not yet available in v0.2") def retrieve_issue_property_option(self, workspace_slug: str, project_id: str, property_id: str, type_id: str, option_id: str) -> Dict[str, Any]: - """Retrieve an issue property option (v0.2 - not yet implemented).""" - raise NotImplementedError("retrieve_issue_property_option not yet available in v0.2") + """Retrieve an issue property option (v0.2).""" + try: + resp = self.client.work_item_properties.options.retrieve(workspace_slug, project_id, property_id, option_id) + return cast(Dict[str, Any], self._model_to_dict(resp)) + except HttpError as e: + log.error(f"Failed to retrieve issue property option: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to retrieve issue property option: {str(e)}") + raise def update_issue_property_option( self, workspace_slug: str, project_id: str, property_id: str, type_id: str, option_id: str, **kwargs ) -> Dict[str, Any]: - """Update an issue property option (v0.2 - not yet implemented).""" - raise NotImplementedError("update_issue_property_option not yet available in v0.2") + """Update an issue property option (v0.2).""" + from plane.models.work_item_properties import UpdateWorkItemPropertyOption # type: ignore[attr-defined] + + try: + payload = self._filter_payload(kwargs) + data_model = UpdateWorkItemPropertyOption(**payload) + resp = self.client.work_item_properties.options.update(workspace_slug, project_id, property_id, option_id, data=data_model) + return cast(Dict[str, Any], self._model_to_dict(resp)) + except HttpError as e: + log.error(f"Failed to update issue property option: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to update issue property option: {str(e)}") + raise def delete_issue_property_option(self, workspace_slug: str, project_id: str, property_id: str, type_id: str, option_id: str) -> Dict[str, Any]: - """Delete an issue property option (v0.2 - not yet implemented).""" - raise NotImplementedError("delete_issue_property_option not yet available in v0.2") + """Delete an issue property option (v0.2).""" + try: + self.client.work_item_properties.options.delete(workspace_slug, project_id, property_id, option_id) + return {"success": True} + except HttpError as e: + log.error(f"Failed to delete issue property option: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to delete issue property option: {str(e)}") + raise # ============================================================================ # TYPES API METHODS @@ -2006,7 +2322,7 @@ class PlaneSDKAdapter: def create_issue_type(self, workspace_slug: str, project_id: str, **kwargs) -> Dict[str, Any]: """Create an issue type (v0.2).""" try: - payload = {k: v for k, v in kwargs.items() if v is not None} + payload = self._filter_payload(kwargs) resp = self.client.work_item_types.create(workspace_slug, project_id, data=CreateWorkItemType(**payload)) return cast(Dict[str, Any], self._model_to_dict(resp)) except HttpError as e: @@ -2052,7 +2368,7 @@ class PlaneSDKAdapter: def update_issue_type(self, workspace_slug: str, project_id: str, type_id: str, **kwargs) -> Dict[str, Any]: """Update an issue type (v0.2).""" try: - payload = {k: v for k, v in kwargs.items() if v is not None} + payload = self._filter_payload(kwargs) resp = self.client.work_item_types.update(workspace_slug, project_id, type_id, data=UpdateWorkItemType(**payload)) return cast(Dict[str, Any], self._model_to_dict(resp)) except HttpError as e: @@ -2081,7 +2397,7 @@ class PlaneSDKAdapter: def create_issue_worklog(self, workspace_slug: str, project_id: str, issue_id: str, **kwargs) -> Dict[str, Any]: """Create an issue worklog (v0.2).""" try: - payload = {k: v for k, v in kwargs.items() if v is not None} + payload = self._filter_payload(kwargs) resp = self.client.work_items.work_logs.create(workspace_slug, project_id, work_item_id=issue_id, data=payload) return cast(Dict[str, Any], self._model_to_dict(resp)) except HttpError as e: @@ -2128,7 +2444,7 @@ class PlaneSDKAdapter: def update_issue_worklog(self, workspace_slug: str, project_id: str, issue_id: str, worklog_id: str, **kwargs) -> Dict[str, Any]: """Update an issue worklog (v0.2).""" try: - payload = {k: v for k, v in kwargs.items() if v is not None} + payload = self._filter_payload(kwargs) resp = self.client.work_items.work_logs.update(workspace_slug, project_id, work_item_id=issue_id, work_log_id=worklog_id, data=payload) return cast(Dict[str, Any], self._model_to_dict(resp)) except HttpError as e: @@ -2149,3 +2465,673 @@ class PlaneSDKAdapter: except Exception as e: log.error(f"Failed to delete issue worklog: {str(e)}") raise + + # ============================================================================ + # WORKSPACES API METHODS + # ============================================================================ + + def get_workspace_features(self, workspace_slug: str) -> Dict[str, Any]: + """Get enabled workspace features (v0.2.1+).""" + try: + features = self.client.workspaces.get_features(workspace_slug=workspace_slug) + result = self._model_to_dict(features) + if isinstance(result, dict): + return result + else: + return {"data": result} + except HttpError as e: + log.error(f"Failed to get workspace features: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to get workspace features: {str(e)}") + raise + + def update_workspace_features(self, workspace_slug: str, **features) -> Dict[str, Any]: + """Update workspace features (v0.2.1+). Enable/disable initiatives, teams, customers, etc.""" + try: + from plane.models.workspaces import WorkspaceFeature # type: ignore[attr-defined] + + # Build feature update payload + feature_data = {} + for key in ["project_grouping", "initiatives", "teams", "customers", "wiki", "pi"]: + if key in features and features[key] is not None: + feature_data[key] = features[key] + + if not feature_data: + raise ValueError("At least one feature field must be provided") + + data_model = WorkspaceFeature(**feature_data) + result = self.client.workspaces.update_features(workspace_slug=workspace_slug, data=data_model) + result_dict = self._model_to_dict(result) + if isinstance(result_dict, dict): + return result_dict + else: + return {"data": result_dict} + except HttpError as e: + log.error(f"Failed to update workspace features: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to update workspace features: {str(e)}") + raise + + # ============================================================================ + # INITIATIVES API METHODS + # ============================================================================ + + def create_initiative(self, workspace_slug: str, name: str, **kwargs) -> Dict[str, Any]: + """Create a new initiative (v0.2.1+).""" + try: + from plane.models.initiatives import CreateInitiative # type: ignore[attr-defined] + + payload = {"name": name} + for key in ["description_html", "start_date", "end_date", "logo_props", "state", "lead"]: + if key in kwargs and kwargs[key] is not None: + payload[key] = kwargs[key] + + data_model = CreateInitiative(**payload) + # SDK bug workaround: SDK calls data.model_dump(exclude_none=True) but doesn't use mode="json", + # causing enum objects to leak into json.dumps and crash. We wrap model_dump to fix this. + original_model_dump = data_model.model_dump + data_model.model_dump = lambda **kw: original_model_dump(mode="json", exclude_none=True) # type: ignore[method-assign] + initiative = self.client.initiatives.create(workspace_slug=workspace_slug, data=data_model) + return cast(Dict[str, Any], self._model_to_dict(initiative)) + except HttpError as e: + log.error(f"Failed to create initiative: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to create initiative: {str(e)}") + raise + + def list_initiatives(self, workspace_slug: str) -> Dict[str, Any]: + """List initiatives (v0.2.1+).""" + try: + response = self.client.initiatives.list(workspace_slug=workspace_slug) + results = self._model_to_dict(getattr(response, "results", [])) + if not isinstance(results, list): + results = [results] if results else [] + return { + "results": results, + "count": len(results), + "total_results": getattr(response, "total_count", len(results)), + "next_cursor": str(getattr(response, "next_page_number", "")) if getattr(response, "next_page_number", None) else None, + "prev_cursor": str(getattr(response, "prev_page_number", "")) if getattr(response, "prev_page_number", None) else None, + } + except HttpError as e: + log.error(f"Failed to list initiatives: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to list initiatives: {str(e)}") + raise + + def retrieve_initiative(self, workspace_slug: str, initiative_id: str) -> Dict[str, Any]: + """Retrieve a single initiative (v0.2.1+).""" + try: + initiative = self.client.initiatives.retrieve(workspace_slug=workspace_slug, initiative_id=initiative_id) + return cast(Dict[str, Any], self._model_to_dict(initiative)) + except HttpError as e: + log.error(f"Failed to retrieve initiative: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to retrieve initiative: {str(e)}") + raise + + def update_initiative(self, workspace_slug: str, initiative_id: str, **kwargs) -> Dict[str, Any]: + """Update an initiative (v0.2.1+).""" + try: + from plane.models.initiatives import UpdateInitiative # type: ignore[attr-defined] + + payload = {} + for k, v in kwargs.items(): + if v is not None: + payload[k] = v + data_model = UpdateInitiative(**payload) + # SDK bug workaround: SDK calls data.model_dump(exclude_none=True) but doesn't use mode="json" + original_model_dump = data_model.model_dump + data_model.model_dump = lambda **kw: original_model_dump(mode="json", exclude_none=True) # type: ignore[method-assign] + initiative = self.client.initiatives.update(workspace_slug=workspace_slug, initiative_id=initiative_id, data=data_model) + return cast(Dict[str, Any], self._model_to_dict(initiative)) + except HttpError as e: + log.error(f"Failed to update initiative: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to update initiative: {str(e)}") + raise + + def delete_initiative(self, workspace_slug: str, initiative_id: str) -> Dict[str, Any]: + """Delete an initiative (v0.2.1+).""" + try: + self.client.initiatives.delete(workspace_slug=workspace_slug, initiative_id=initiative_id) + return {"success": True} + except HttpError as e: + log.error(f"Failed to delete initiative: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to delete initiative: {str(e)}") + raise + + # Initiative Labels + def create_initiative_label(self, workspace_slug: str, name: str, **kwargs) -> Dict[str, Any]: + """Create initiative label (v0.2.1+), which are workspace-scoped, not initiative-scoped.""" + try: + from plane.models.initiatives import CreateInitiativeLabel # type: ignore[attr-defined] + + payload = {"name": name} + for key in ["color", "description"]: + if key in kwargs and kwargs[key] is not None: + payload[key] = kwargs[key] + + data_model = CreateInitiativeLabel(**payload) + label = self.client.initiatives.labels.create(workspace_slug=workspace_slug, data=data_model) + return cast(Dict[str, Any], self._model_to_dict(label)) + except HttpError as e: + log.error(f"Failed to create initiative label: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to create initiative label: {str(e)}") + raise + + def list_initiative_labels(self, workspace_slug: str) -> Dict[str, Any]: + """List all initiative labels in workspace (v0.2.1+).""" + try: + response = self.client.initiatives.labels.list(workspace_slug=workspace_slug) + results = self._model_to_dict(getattr(response, "results", [])) + if not isinstance(results, list): + results = [results] if results else [] + return { + "results": results, + "count": len(results), + "total_results": getattr(response, "total_count", len(results)), + } + except HttpError as e: + log.error(f"Failed to list initiative labels: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to list initiative labels: {str(e)}") + raise + + def retrieve_initiative_label(self, workspace_slug: str, label_id: str) -> Dict[str, Any]: + """Retrieve initiative label (v0.2.1+).""" + try: + label = self.client.initiatives.labels.retrieve(workspace_slug=workspace_slug, label_id=label_id) + return cast(Dict[str, Any], self._model_to_dict(label)) + except HttpError as e: + log.error(f"Failed to retrieve initiative label: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to retrieve initiative label: {str(e)}") + raise + + def update_initiative_label(self, workspace_slug: str, label_id: str, **kwargs) -> Dict[str, Any]: + """Update initiative label (v0.2.1+).""" + try: + from plane.models.initiatives import UpdateInitiativeLabel # type: ignore[attr-defined] + + payload = self._filter_payload(kwargs) + data_model = UpdateInitiativeLabel(**payload) + label = self.client.initiatives.labels.update(workspace_slug=workspace_slug, label_id=label_id, data=data_model) + return cast(Dict[str, Any], self._model_to_dict(label)) + except HttpError as e: + log.error(f"Failed to update initiative label: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to update initiative label: {str(e)}") + raise + + def delete_initiative_label(self, workspace_slug: str, label_id: str) -> Dict[str, Any]: + """Delete initiative label (v0.2.1+).""" + try: + self.client.initiatives.labels.delete(workspace_slug=workspace_slug, label_id=label_id) + return {"success": True} + except HttpError as e: + log.error(f"Failed to delete initiative label: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to delete initiative label: {str(e)}") + raise + + def add_initiative_labels(self, workspace_slug: str, initiative_id: str, label_ids: List[str]) -> Dict[str, Any]: + """Add labels to an initiative (v0.2.1+).""" + try: + labels = self.client.initiatives.labels.add_labels(workspace_slug=workspace_slug, initiative_id=initiative_id, label_ids=label_ids) + return cast(Dict[str, Any], {"success": True, "labels_added": len(label_ids), "labels": self._model_to_dict(labels)}) + except HttpError as e: + log.error(f"Failed to add initiative labels: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to add initiative labels: {str(e)}") + raise + + def remove_initiative_labels(self, workspace_slug: str, initiative_id: str, label_ids: List[str]) -> Dict[str, Any]: + """Remove labels from an initiative (v0.2.1+).""" + try: + self.client.initiatives.labels.remove_labels(workspace_slug=workspace_slug, initiative_id=initiative_id, label_ids=label_ids) + return {"success": True, "labels_removed": len(label_ids)} + except HttpError as e: + log.error(f"Failed to remove initiative labels: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to remove initiative labels: {str(e)}") + raise + + # Initiative Projects + def add_initiative_projects(self, workspace_slug: str, initiative_id: str, project_ids: List[str]) -> Dict[str, Any]: + """Add projects to initiative (v0.2.1+).""" + try: + self.client.initiatives.projects.add(workspace_slug=workspace_slug, initiative_id=initiative_id, project_ids=project_ids) + return {"success": True, "projects_added": len(project_ids)} + except HttpError as e: + log.error(f"Failed to add initiative projects: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to add initiative projects: {str(e)}") + raise + + def list_initiative_projects(self, workspace_slug: str, initiative_id: str) -> Dict[str, Any]: + """List initiative projects (v0.2.1+).""" + try: + response = self.client.initiatives.projects.list(workspace_slug=workspace_slug, initiative_id=initiative_id) + results = self._model_to_dict(getattr(response, "results", [])) + if not isinstance(results, list): + results = [results] if results else [] + return { + "results": results, + "count": len(results), + "total_results": getattr(response, "total_count", len(results)), + } + except HttpError as e: + log.error(f"Failed to list initiative projects: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to list initiative projects: {str(e)}") + raise + + def remove_initiative_projects(self, workspace_slug: str, initiative_id: str, project_ids: List[str]) -> Dict[str, Any]: + """Remove projects from initiative (v0.2.1+).""" + try: + self.client.initiatives.projects.remove(workspace_slug=workspace_slug, initiative_id=initiative_id, project_ids=project_ids) + return {"success": True, "projects_removed": len(project_ids)} + except HttpError as e: + log.error(f"Failed to remove initiative projects: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to remove initiative projects: {str(e)}") + raise + + # Initiative Epics + def add_initiative_epics(self, workspace_slug: str, initiative_id: str, epic_ids: List[str]) -> Dict[str, Any]: + """Add epics to initiative (v0.2.1+).""" + try: + self.client.initiatives.epics.add(workspace_slug=workspace_slug, initiative_id=initiative_id, epic_ids=epic_ids) + return {"success": True, "epics_added": len(epic_ids)} + except HttpError as e: + log.error(f"Failed to add initiative epics: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to add initiative epics: {str(e)}") + raise + + def list_initiative_epics(self, workspace_slug: str, initiative_id: str) -> Dict[str, Any]: + """List initiative epics (v0.2.1+).""" + try: + response = self.client.initiatives.epics.list(workspace_slug=workspace_slug, initiative_id=initiative_id) + results = self._model_to_dict(getattr(response, "results", [])) + if not isinstance(results, list): + results = [results] if results else [] + return { + "results": results, + "count": len(results), + "total_results": getattr(response, "total_count", len(results)), + } + except HttpError as e: + log.error(f"Failed to list initiative epics: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to list initiative epics: {str(e)}") + raise + + def remove_initiative_epics(self, workspace_slug: str, initiative_id: str, epic_ids: List[str]) -> Dict[str, Any]: + """Remove epics from initiative (v0.2.1+).""" + try: + self.client.initiatives.epics.remove(workspace_slug=workspace_slug, initiative_id=initiative_id, epic_ids=epic_ids) + return {"success": True, "epics_removed": len(epic_ids)} + except HttpError as e: + log.error(f"Failed to remove initiative epics: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to remove initiative epics: {str(e)}") + raise + + # ============================================================================ + # TEAMSPACES API METHODS + # ============================================================================ + + def create_teamspace(self, workspace_slug: str, name: str, **kwargs) -> Dict[str, Any]: + """Create a new teamspace (v0.2.1+).""" + try: + from plane.models.teamspaces import CreateTeamspace # type: ignore[attr-defined] + + payload = {"name": name} + for key in ["description_html", "logo_props", "lead"]: + if key in kwargs and kwargs[key] is not None: + payload[key] = kwargs[key] + + data_model = CreateTeamspace(**payload) + teamspace = self.client.teamspaces.create(workspace_slug=workspace_slug, data=data_model) + return cast(Dict[str, Any], self._model_to_dict(teamspace)) + except HttpError as e: + log.error(f"Failed to create teamspace: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to create teamspace: {str(e)}") + raise + + def list_teamspaces(self, workspace_slug: str) -> Dict[str, Any]: + """List teamspaces (v0.2.1+).""" + try: + response = self.client.teamspaces.list(workspace_slug=workspace_slug) + results = self._model_to_dict(getattr(response, "results", [])) + if not isinstance(results, list): + results = [results] if results else [] + return { + "results": results, + "count": len(results), + "total_results": getattr(response, "total_count", len(results)), + "next_cursor": str(getattr(response, "next_page_number", "")) if getattr(response, "next_page_number", None) else None, + "prev_cursor": str(getattr(response, "prev_page_number", "")) if getattr(response, "prev_page_number", None) else None, + } + except HttpError as e: + log.error(f"Failed to list teamspaces: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to list teamspaces: {str(e)}") + raise + + def retrieve_teamspace(self, workspace_slug: str, teamspace_id: str) -> Dict[str, Any]: + """Retrieve a single teamspace (v0.2.1+).""" + try: + teamspace = self.client.teamspaces.retrieve(workspace_slug=workspace_slug, teamspace_id=teamspace_id) + return cast(Dict[str, Any], self._model_to_dict(teamspace)) + except HttpError as e: + log.error(f"Failed to retrieve teamspace: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to retrieve teamspace: {str(e)}") + raise + + def update_teamspace(self, workspace_slug: str, teamspace_id: str, **kwargs) -> Dict[str, Any]: + """Update a teamspace (v0.2.1+).""" + try: + from plane.models.teamspaces import UpdateTeamspace # type: ignore[attr-defined] + + payload = self._filter_payload(kwargs) + data_model = UpdateTeamspace(**payload) + teamspace = self.client.teamspaces.update(workspace_slug=workspace_slug, teamspace_id=teamspace_id, data=data_model) + return cast(Dict[str, Any], self._model_to_dict(teamspace)) + except HttpError as e: + log.error(f"Failed to update teamspace: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to update teamspace: {str(e)}") + raise + + def delete_teamspace(self, workspace_slug: str, teamspace_id: str) -> Dict[str, Any]: + """Delete a teamspace (v0.2.1+).""" + try: + self.client.teamspaces.delete(workspace_slug=workspace_slug, teamspace_id=teamspace_id) + return {"success": True} + except HttpError as e: + log.error(f"Failed to delete teamspace: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to delete teamspace: {str(e)}") + raise + + # Teamspace Members + def add_teamspace_members(self, workspace_slug: str, teamspace_id: str, member_ids: List[str]) -> Dict[str, Any]: + """Add members to teamspace (v0.2.1+).""" + try: + self.client.teamspaces.members.add(workspace_slug=workspace_slug, teamspace_id=teamspace_id, member_ids=member_ids) + return {"success": True, "members_added": len(member_ids)} + except HttpError as e: + log.error(f"Failed to add teamspace members: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to add teamspace members: {str(e)}") + raise + + def list_teamspace_members(self, workspace_slug: str, teamspace_id: str) -> Dict[str, Any]: + """List teamspace members (v0.2.1+).""" + try: + response = self.client.teamspaces.members.list(workspace_slug=workspace_slug, teamspace_id=teamspace_id) + results = self._model_to_dict(getattr(response, "results", [])) + if not isinstance(results, list): + results = [results] if results else [] + return { + "results": results, + "count": len(results), + "total_results": getattr(response, "total_count", len(results)), + } + except HttpError as e: + log.error(f"Failed to list teamspace members: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to list teamspace members: {str(e)}") + raise + + def remove_teamspace_members(self, workspace_slug: str, teamspace_id: str, member_ids: List[str]) -> Dict[str, Any]: + """Remove members from teamspace (v0.2.1+).""" + try: + self.client.teamspaces.members.remove(workspace_slug=workspace_slug, teamspace_id=teamspace_id, member_ids=member_ids) + return {"success": True, "members_removed": len(member_ids)} + except HttpError as e: + log.error(f"Failed to remove teamspace members: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to remove teamspace members: {str(e)}") + raise + + # Teamspace Projects + def add_teamspace_projects(self, workspace_slug: str, teamspace_id: str, project_ids: List[str]) -> Dict[str, Any]: + """Add projects to teamspace (v0.2.1+).""" + try: + self.client.teamspaces.projects.add(workspace_slug=workspace_slug, teamspace_id=teamspace_id, project_ids=project_ids) + return {"success": True, "projects_added": len(project_ids)} + except HttpError as e: + log.error(f"Failed to add teamspace projects: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to add teamspace projects: {str(e)}") + raise + + def list_teamspace_projects(self, workspace_slug: str, teamspace_id: str) -> Dict[str, Any]: + """List teamspace projects (v0.2.1+).""" + try: + response = self.client.teamspaces.projects.list(workspace_slug=workspace_slug, teamspace_id=teamspace_id) + results = self._model_to_dict(getattr(response, "results", [])) + if not isinstance(results, list): + results = [results] if results else [] + return { + "results": results, + "count": len(results), + "total_results": getattr(response, "total_count", len(results)), + } + except HttpError as e: + log.error(f"Failed to list teamspace projects: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to list teamspace projects: {str(e)}") + raise + + def remove_teamspace_projects(self, workspace_slug: str, teamspace_id: str, project_ids: List[str]) -> Dict[str, Any]: + """Remove projects from teamspace (v0.2.1+).""" + try: + self.client.teamspaces.projects.remove(workspace_slug=workspace_slug, teamspace_id=teamspace_id, project_ids=project_ids) + return {"success": True, "projects_removed": len(project_ids)} + except HttpError as e: + log.error(f"Failed to remove teamspace projects: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to remove teamspace projects: {str(e)}") + raise + + # ============================================================================ + # STICKIES API METHODS + # ============================================================================ + + def create_sticky(self, workspace_slug: str, **kwargs) -> Dict[str, Any]: + """Create a new sticky note (v0.2.1+).""" + try: + from plane.models.stickies import CreateSticky # type: ignore[attr-defined] + + payload = self._filter_payload(kwargs) + data_model = CreateSticky(**payload) + sticky = self.client.stickies.create(workspace_slug=workspace_slug, data=data_model) + return cast(Dict[str, Any], self._model_to_dict(sticky)) + except HttpError as e: + log.error(f"Failed to create sticky: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to create sticky: {str(e)}") + raise + + def list_stickies(self, workspace_slug: str) -> Dict[str, Any]: + """List stickies (v0.2.1+).""" + try: + response = self.client.stickies.list(workspace_slug=workspace_slug) + results = self._model_to_dict(getattr(response, "results", [])) + if not isinstance(results, list): + results = [results] if results else [] + return { + "results": results, + "count": len(results), + "total_results": getattr(response, "total_count", len(results)), + "next_cursor": str(getattr(response, "next_page_number", "")) if getattr(response, "next_page_number", None) else None, + "prev_cursor": str(getattr(response, "prev_page_number", "")) if getattr(response, "prev_page_number", None) else None, + } + except HttpError as e: + log.error(f"Failed to list stickies: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to list stickies: {str(e)}") + raise + + def retrieve_sticky(self, workspace_slug: str, sticky_id: str) -> Dict[str, Any]: + """Retrieve a single sticky (v0.2.1+).""" + try: + sticky = self.client.stickies.retrieve(workspace_slug=workspace_slug, sticky_id=sticky_id) + return cast(Dict[str, Any], self._model_to_dict(sticky)) + except HttpError as e: + log.error(f"Failed to retrieve sticky: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to retrieve sticky: {str(e)}") + raise + + def update_sticky(self, workspace_slug: str, sticky_id: str, **kwargs) -> Dict[str, Any]: + """Update a sticky (v0.2.1+).""" + try: + from plane.models.stickies import UpdateSticky # type: ignore[attr-defined] + + payload = self._filter_payload(kwargs) + data_model = UpdateSticky(**payload) + sticky = self.client.stickies.update(workspace_slug=workspace_slug, sticky_id=sticky_id, data=data_model) + return cast(Dict[str, Any], self._model_to_dict(sticky)) + except HttpError as e: + log.error(f"Failed to update sticky: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to update sticky: {str(e)}") + raise + + def delete_sticky(self, workspace_slug: str, sticky_id: str) -> Dict[str, Any]: + """Delete a sticky (v0.2.1+).""" + try: + self.client.stickies.delete(workspace_slug=workspace_slug, sticky_id=sticky_id) + return {"success": True} + except HttpError as e: + log.error(f"Failed to delete sticky: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to delete sticky: {str(e)}") + raise + + # ============================================================================ + # CUSTOMERS API METHODS + # ============================================================================ + + def create_customer(self, workspace_slug: str, **kwargs) -> Dict[str, Any]: + """Create a new customer (v0.2.1+).""" + try: + from plane.models.customers import CreateCustomer # type: ignore[attr-defined] + + payload = self._filter_payload(kwargs) + data_model = CreateCustomer(**payload) + customer = self.client.customers.create(workspace_slug=workspace_slug, data=data_model) + return cast(Dict[str, Any], self._model_to_dict(customer)) + except HttpError as e: + log.error(f"Failed to create customer: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to create customer: {str(e)}") + raise + + def list_customers(self, workspace_slug: str) -> Dict[str, Any]: + """List customers (v0.2.1+).""" + try: + response = self.client.customers.list(workspace_slug=workspace_slug) + results = self._model_to_dict(getattr(response, "results", [])) + if not isinstance(results, list): + results = [results] if results else [] + return { + "results": results, + "count": len(results), + "total_results": getattr(response, "total_count", len(results)), + "next_cursor": str(getattr(response, "next_page_number", "")) if getattr(response, "next_page_number", None) else None, + "prev_cursor": str(getattr(response, "prev_page_number", "")) if getattr(response, "prev_page_number", None) else None, + } + except HttpError as e: + log.error(f"Failed to list customers: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to list customers: {str(e)}") + raise + + def retrieve_customer(self, workspace_slug: str, customer_id: str) -> Dict[str, Any]: + """Retrieve a single customer (v0.2.1+).""" + try: + customer = self.client.customers.retrieve(workspace_slug=workspace_slug, customer_id=customer_id) + return cast(Dict[str, Any], self._model_to_dict(customer)) + except HttpError as e: + log.error(f"Failed to retrieve customer: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to retrieve customer: {str(e)}") + raise + + def update_customer(self, workspace_slug: str, customer_id: str, **kwargs) -> Dict[str, Any]: + """Update a customer (v0.2.1+).""" + try: + from plane.models.customers import UpdateCustomer # type: ignore[attr-defined] + + payload = self._filter_payload(kwargs) + data_model = UpdateCustomer(**payload) + customer = self.client.customers.update(workspace_slug=workspace_slug, customer_id=customer_id, data=data_model) + return cast(Dict[str, Any], self._model_to_dict(customer)) + except HttpError as e: + log.error(f"Failed to update customer: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to update customer: {str(e)}") + raise + + def delete_customer(self, workspace_slug: str, customer_id: str) -> Dict[str, Any]: + """Delete a customer (v0.2.1+).""" + try: + self.client.customers.delete(workspace_slug=workspace_slug, customer_id=customer_id) + return {"success": True} + except HttpError as e: + log.error(f"Failed to delete customer: {e} ({getattr(e, "status_code", None)})") + raise + except Exception as e: + log.error(f"Failed to delete customer: {str(e)}") + raise diff --git a/apps/pi/pi/services/actions/registry.py b/apps/pi/pi/services/actions/registry.py index 100eb1cb54..995fb66269 100644 --- a/apps/pi/pi/services/actions/registry.py +++ b/apps/pi/pi/services/actions/registry.py @@ -30,6 +30,10 @@ API_CATEGORIES: Dict[str, str] = { "users": "Manage user information and profiles", "workitems": "Work items/issues - create, update, manage work items", "intake": "Submit and manage work items in intake queue for triage", + "initiatives": "Manage strategic initiatives with epics, labels, and projects", + "teamspaces": "Manage team collaboration spaces with members and projects", + "stickies": "Create and manage quick sticky notes and annotations", + "customers": "Manage customer relationships and information", "members": "Manage workspace and project members", "activity": "Track and manage work item activities", "attachments": "Manage file attachments on work items", @@ -38,6 +42,7 @@ API_CATEGORIES: Dict[str, str] = { "properties": "Manage custom properties and their values", "types": "Manage work item types", "worklogs": "Track and manage time entries", + "workspaces": "Manage workspace settings and features", } # Centralized registry of methods for each category @@ -45,12 +50,11 @@ API_METHODS: Dict[str, Dict[str, str]] = { "workitems": { "create": "Create a new work item/issue", "update": "Update an existing work item/issue", - "create_relation": "Create relationships between work items (blocking, blocked_by, duplicate, etc.)", - # "list": "List work items with filtering", - # "retrieve": "Get a single work item by ID", + "retrieve": "Get details of a single work item", + "list": "List work items in a project", "delete": "Delete a work item", - # "search": "Search work items by criteria", - # "get_workspace": "Get work item across workspace", + "create_relation": "Create a relationship between work items (blocks, relates_to, etc.)", + "search": "Search work items across workspace by query string", }, "projects": { "create": "Create a new project", @@ -58,8 +62,8 @@ API_METHODS: Dict[str, Dict[str, str]] = { "retrieve": "Get details of a single project", "update": "Update project details", "delete": "Delete a project", - "archive": "Archive a project", - "unarchive": "Restore an archived project", + "get_features": "Get enabled project features (epics, cycles, modules, etc.)", + "update_features": "Enable or disable project features", }, "cycles": { "create": "Create a new cycle", @@ -117,6 +121,53 @@ API_METHODS: Dict[str, Dict[str, str]] = { "update": "Update intake work item details", "delete": "Remove intake work item", }, + "initiatives": { + "create": "Create a new initiative", + "list": "List initiatives in workspace", + "retrieve": "Get single initiative by ID", + "update": "Update initiative details", + "delete": "Delete an initiative", + "create_label": "Create initiative label", + "list_labels": "List initiative labels", + "retrieve_label": "Get initiative label by ID", + "update_label": "Update initiative label", + "delete_label": "Delete initiative label", + "add_labels": "Add labels to initiative", + "remove_labels": "Remove labels from initiative", + "add_projects": "Link projects to initiative", + "list_projects": "List initiative's projects", + "remove_projects": "Unlink projects from initiative", + "add_epics": "Link epics to initiative", + "list_epics": "List initiative's epics", + "remove_epics": "Unlink epics from initiative", + }, + "teamspaces": { + "create": "Create a new teamspace", + "list": "List teamspaces in workspace", + "retrieve": "Get single teamspace by ID", + "update": "Update teamspace details", + "delete": "Delete a teamspace", + "add_members": "Add members to teamspace", + "list_members": "List teamspace members", + "remove_members": "Remove members from teamspace", + "add_projects": "Add projects to teamspace", + "list_projects": "List teamspace projects", + "remove_projects": "Remove projects from teamspace", + }, + "stickies": { + "create": "Create a new sticky note", + "list": "List stickies in workspace", + "retrieve": "Get single sticky by ID", + "update": "Update sticky details", + "delete": "Delete a sticky", + }, + "customers": { + "create": "Create a new customer", + "list": "List customers in workspace", + "retrieve": "Get single customer by ID", + "update": "Update customer details", + "delete": "Delete a customer", + }, "members": { "get_workspace_members": "List all workspace members", "get_project_members": "List all project members", @@ -173,6 +224,10 @@ API_METHODS: Dict[str, Dict[str, str]] = { "update": "Update time entry", "delete": "Delete time entry", }, + "workspaces": { + "get_features": "Get enabled workspace features (initiatives, teams, customers, etc.)", + "update_features": "Enable or disable workspace features", + }, } @@ -201,18 +256,19 @@ METHOD_NAME_MAP: Dict[str, Dict[str, str]] = { "create_relation": "create_work_item_relation", "list": "list_work_items", "retrieve": "retrieve_work_item", - "search": "search_work_items", - "get_workspace": "get_workspace_work_item", "delete": "delete_work_item", + "search": "search_work_items", + "create_epic": "create_work_item", # Epic is a workitem with specific type_id + "update_epic": "update_work_item", # Epic is a workitem with specific type_id }, "projects": { "create": "create_project", "list": "list_projects", "retrieve": "retrieve_project", "update": "update_project", - "archive": "archive_project", - "unarchive": "unarchive_project", "delete": "delete_project", + "get_features": "get_project_features", + "update_features": "update_project_features", }, "cycles": { "create": "create_cycle", @@ -224,7 +280,6 @@ METHOD_NAME_MAP: Dict[str, Dict[str, str]] = { "list_archived": "list_archived_cycles", "add_work_items": "add_cycle_work_items", "list_work_items": "list_cycle_work_items", - "retrieve_work_item": "retrieve_cycle_work_item", "remove_work_item": "remove_cycle_work_item", "transfer_work_items": "transfer_cycle_work_items", "delete": "delete_cycle", @@ -232,7 +287,7 @@ METHOD_NAME_MAP: Dict[str, Dict[str, str]] = { "labels": { "create": "create_label", "list": "list_labels", - "retrieve": "get_labels", + "retrieve": "retrieve_label", "update": "update_label", "delete": "delete_label", }, @@ -259,6 +314,8 @@ METHOD_NAME_MAP: Dict[str, Dict[str, str]] = { "pages": { "create_project_page": "create_project_page", "create_workspace_page": "create_workspace_page", + "retrieve_project": "retrieve_project_page", + "retrieve_workspace": "retrieve_workspace_page", }, "assets": { "create": "create_generic_asset_upload", @@ -279,6 +336,53 @@ METHOD_NAME_MAP: Dict[str, Dict[str, str]] = { "update": "update_intake_work_item", "delete": "delete_intake_work_item", }, + "initiatives": { + "create": "create_initiative", + "list": "list_initiatives", + "retrieve": "retrieve_initiative", + "update": "update_initiative", + "delete": "delete_initiative", + "create_label": "create_initiative_label", + "list_labels": "list_initiative_labels", + "retrieve_label": "retrieve_initiative_label", + "update_label": "update_initiative_label", + "delete_label": "delete_initiative_label", + "add_labels": "add_initiative_labels", + "remove_labels": "remove_initiative_labels", + "add_projects": "add_initiative_projects", + "list_projects": "list_initiative_projects", + "remove_projects": "remove_initiative_projects", + "add_epics": "add_initiative_epics", + "list_epics": "list_initiative_epics", + "remove_epics": "remove_initiative_epics", + }, + "teamspaces": { + "create": "create_teamspace", + "list": "list_teamspaces", + "retrieve": "retrieve_teamspace", + "update": "update_teamspace", + "delete": "delete_teamspace", + "add_members": "add_teamspace_members", + "list_members": "list_teamspace_members", + "remove_members": "remove_teamspace_members", + "add_projects": "add_teamspace_projects", + "list_projects": "list_teamspace_projects", + "remove_projects": "remove_teamspace_projects", + }, + "stickies": { + "create": "create_sticky", + "list": "list_stickies", + "retrieve": "retrieve_sticky", + "update": "update_sticky", + "delete": "delete_sticky", + }, + "customers": { + "create": "create_customer", + "list": "list_customers", + "retrieve": "retrieve_customer", + "update": "update_customer", + "delete": "delete_customer", + }, "members": { "get_workspace_members": "get_workspace_members", "get_project_members": "get_project_members", @@ -291,6 +395,7 @@ METHOD_NAME_MAP: Dict[str, Dict[str, str]] = { "create": "create_work_item_attachment", "list": "list_work_item_attachments", "retrieve": "retrieve_work_item_attachment", + "update": "update_work_item_attachment", "delete": "delete_work_item_attachment", }, "comments": { @@ -335,6 +440,10 @@ METHOD_NAME_MAP: Dict[str, Dict[str, str]] = { "update": "update_issue_worklog", "delete": "delete_issue_worklog", }, + "workspaces": { + "get_features": "get_workspace_features", + "update_features": "update_workspace_features", + }, } @@ -371,3 +480,80 @@ def resolve_actual_method_name(category: str, method: str) -> Optional[str]: # If not a simplified name, assume it could already be actual; allow pass-through return method + + +# ============================================================================ +# CENTRALIZED TOOL DEFINITIONS (Phase 1: Foundation) +# ============================================================================ +# This is the single source of truth for all tool metadata. +# Auto-generation in progress - currently only labels category defined. + +from pi.services.actions.tool_metadata import ToolMetadata + +# Tool definitions - now imported from individual tool modules for modularity +# Each tool file defines its own metadata, special handlers, and business logic +from pi.services.actions.tools.activity import ACTIVITY_TOOL_DEFINITIONS +from pi.services.actions.tools.assets import ASSET_TOOL_DEFINITIONS +from pi.services.actions.tools.comments import COMMENTS_TOOL_DEFINITIONS +from pi.services.actions.tools.customers import CUSTOMER_TOOL_DEFINITIONS +from pi.services.actions.tools.cycles import CYCLES_TOOL_DEFINITIONS +from pi.services.actions.tools.initiatives import INITIATIVE_TOOL_DEFINITIONS +from pi.services.actions.tools.intake import INTAKE_TOOL_DEFINITIONS +from pi.services.actions.tools.labels import LABEL_TOOL_DEFINITIONS +from pi.services.actions.tools.links import LINKS_TOOL_DEFINITIONS +from pi.services.actions.tools.members import MEMBER_TOOL_DEFINITIONS +from pi.services.actions.tools.modules import MODULES_TOOL_DEFINITIONS +from pi.services.actions.tools.pages import PAGE_TOOL_DEFINITIONS +from pi.services.actions.tools.projects import PROJECT_TOOL_DEFINITIONS +from pi.services.actions.tools.properties import PROPERTIES_TOOL_DEFINITIONS +from pi.services.actions.tools.states import STATE_TOOL_DEFINITIONS +from pi.services.actions.tools.stickies import STICKY_TOOL_DEFINITIONS +from pi.services.actions.tools.teamspaces import TEAMSPACE_TOOL_DEFINITIONS +from pi.services.actions.tools.types import TYPE_TOOL_DEFINITIONS +from pi.services.actions.tools.users import USER_TOOL_DEFINITIONS +from pi.services.actions.tools.workitems import WORKITEMS_TOOL_DEFINITIONS +from pi.services.actions.tools.worklogs import WORKLOGS_TOOL_DEFINITIONS +from pi.services.actions.tools.workspaces import WORKSPACE_TOOL_DEFINITIONS + +TOOL_DEFINITIONS: Dict[str, Dict[str, ToolMetadata]] = { + "activity": ACTIVITY_TOOL_DEFINITIONS, + "assets": ASSET_TOOL_DEFINITIONS, + "comments": COMMENTS_TOOL_DEFINITIONS, + "customers": CUSTOMER_TOOL_DEFINITIONS, + "cycles": CYCLES_TOOL_DEFINITIONS, + "initiatives": INITIATIVE_TOOL_DEFINITIONS, + "intake": INTAKE_TOOL_DEFINITIONS, + "labels": LABEL_TOOL_DEFINITIONS, + "links": LINKS_TOOL_DEFINITIONS, + "members": MEMBER_TOOL_DEFINITIONS, + "modules": MODULES_TOOL_DEFINITIONS, + "pages": PAGE_TOOL_DEFINITIONS, + "projects": PROJECT_TOOL_DEFINITIONS, + "properties": PROPERTIES_TOOL_DEFINITIONS, + "states": STATE_TOOL_DEFINITIONS, + "stickies": STICKY_TOOL_DEFINITIONS, + "teamspaces": TEAMSPACE_TOOL_DEFINITIONS, + "types": TYPE_TOOL_DEFINITIONS, + "users": USER_TOOL_DEFINITIONS, + "workitems": WORKITEMS_TOOL_DEFINITIONS, + "worklogs": WORKLOGS_TOOL_DEFINITIONS, + "workspaces": WORKSPACE_TOOL_DEFINITIONS, +} + + +def get_tool_metadata(category: str, method: str) -> Optional[ToolMetadata]: + """Get tool metadata for a specific category and method. + + Args: + category: API category (e.g., "labels", "workitems") + method: Simplified method name (e.g., "create", "list") + + Returns: + ToolMetadata if found, None otherwise + """ + return TOOL_DEFINITIONS.get(category, {}).get(method) + + +def get_all_tool_metadata() -> Dict[str, Dict[str, ToolMetadata]]: + """Get all tool metadata across all categories.""" + return TOOL_DEFINITIONS.copy() diff --git a/apps/pi/pi/services/actions/tool_generator.py b/apps/pi/pi/services/actions/tool_generator.py new file mode 100644 index 0000000000..4b87830046 --- /dev/null +++ b/apps/pi/pi/services/actions/tool_generator.py @@ -0,0 +1,315 @@ +# SPDX-FileCopyrightText: 2023-present Plane Software, Inc. +# SPDX-License-Identifier: LicenseRef-Plane-Commercial +# +# Licensed under the Plane Commercial License (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# https://plane.so/legals/eula +# +# DO NOT remove or modify this notice. +# NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited. + +""" +Tool generator for auto-generating LangChain tools from centralized metadata. + +This module provides functionality to dynamically create LangChain tools +from ToolMetadata definitions, eliminating manual tool duplication. +""" + +import inspect +from typing import Any +from typing import Callable +from typing import Dict +from typing import List + +from langchain_core.tools import tool + +from pi import logger +from pi.services.actions.tool_metadata import ToolMetadata +from pi.services.actions.tools.base import PlaneToolBase + +log = logger.getChild(__name__) + + +# Complete type mapping - no string parsing needed +from typing import Optional + +_TYPE_MAP = { + # Basic types + "str": str, + "int": int, + "float": float, + "bool": bool, + "dict": dict, + "list": list, + # Optional types + "Optional[str]": Optional[str], + "Optional[int]": Optional[int], + "Optional[float]": Optional[float], + "Optional[bool]": Optional[bool], + "Optional[dict]": Optional[dict], + "Optional[list]": Optional[list], + # Container types + "List[str]": list, + "List[int]": list, + "List[dict]": list, + "Dict[str, Any]": dict, + "Dict[str, str]": dict, +} + + +def _parse_type_annotation(type_str: str) -> Any: + """Parse type annotation string into actual Python type. + + Args: + type_str: Type as string (e.g., "str", "Optional[str]", "List[str]") + + Returns: + Python type object or typing generic + """ + return _TYPE_MAP.get(type_str, str) + + +def _build_function_signature(metadata: ToolMetadata) -> Dict[str, inspect.Parameter]: + """Build function signature from metadata parameters. + + Args: + metadata: Tool metadata + + Returns: + Dictionary of parameter name to inspect.Parameter objects + """ + sig_params = {} + + for param in metadata.parameters: + # Determine default value + if param.required: + default = inspect.Parameter.empty + else: + default = param.default # type: ignore[assignment] + + # Create parameter + sig_params[param.name] = inspect.Parameter( + name=param.name, + kind=inspect.Parameter.KEYWORD_ONLY, + default=default, + annotation=_parse_type_annotation(param.type), + ) + + return sig_params + + +def _build_type_annotations(metadata: ToolMetadata) -> Dict[str, Any]: + """Build type annotations dictionary for function. + + Args: + metadata: Tool metadata + + Returns: + Dictionary of parameter names to types + """ + annotations = {} + + for param in metadata.parameters: + annotations[param.name] = _parse_type_annotation(param.type) + + # Add return type + annotations["return"] = Dict[str, Any] + + return annotations + + +def _build_docstring(metadata: ToolMetadata) -> str: + """Generate Google-style docstring from metadata. + + Args: + metadata: Tool metadata + + Returns: + Formatted docstring + """ + lines = [metadata.description, ""] + + if metadata.parameters: + lines.append("Args:") + for param in metadata.parameters: + # Format: param_name: Description + required_marker = "(required)" if param.required else "" + lines.append(f" {param.name}: {param.description} {required_marker}".strip()) + lines.append("") + + return "\n".join(lines) + + +async def _apply_pre_processing( + metadata: ToolMetadata, + kwargs: Dict[str, Any], + context: Dict[str, Any], + category: str, + method_key: str, + method_executor: Any = None, +) -> Dict[str, Any]: + """Apply pre-processing via custom handler if provided. + + Args: + metadata: Tool metadata + kwargs: Tool arguments + context: Execution context + category: API category + method_key: Method key + method_executor: Method executor instance (for making additional API calls) + + Returns: + Modified kwargs + """ + if metadata.pre_handler: + return await metadata.pre_handler(metadata, kwargs, context, category, method_key, method_executor) + return kwargs + + +async def _apply_post_processing( + metadata: ToolMetadata, + result: Dict[str, Any], + kwargs: Dict[str, Any], + context: Dict[str, Any], + method_executor: Any, + category: str, + method_key: str, +) -> Dict[str, Any]: + """Apply post-processing via custom handler if provided. + + Args: + metadata: Tool metadata + result: Execution result + kwargs: Original tool arguments + context: Execution context + method_executor: Method executor instance + category: API category + method_key: Method key + + Returns: + Modified result + """ + if metadata.post_handler: + return await metadata.post_handler(metadata, result, kwargs, context, method_executor, category, method_key) + return result + + +def generate_tool_from_metadata( + category: str, + method_key: str, + metadata: ToolMetadata, + method_executor: Any, + context: Dict[str, Any], +) -> Callable: + """Generate a LangChain tool function from metadata. + + This function creates a fully functional LangChain tool that: + - Has the correct signature and type annotations + - Auto-fills context parameters (workspace_slug, project_id) + - Calls method_executor.execute() with the right category/method + - Formats responses using PlaneToolBase helpers + + Args: + category: API category (e.g., "labels") + method_key: Simplified method name (e.g., "create") + metadata: Complete tool metadata + method_executor: MethodExecutor instance for calling SDK adapter + context: Execution context with workspace_slug, project_id, etc. + + Returns: + Decorated LangChain tool function + """ + + # Create async function dynamically + async def tool_func(**kwargs): + """Dynamically generated tool function.""" + + # Auto-fill context values for parameters marked as auto_fill_from_context + for param in metadata.parameters: + if param.auto_fill_from_context and param.name in context: + if param.name not in kwargs or kwargs[param.name] is None: + kwargs[param.name] = context[param.name] + log.debug(f"Auto-filled {param.name} = {context[param.name]} for {metadata.name}") + + # PRE-PROCESSING: Apply custom handler if provided + if metadata.pre_handler: + try: + kwargs = await _apply_pre_processing(metadata, kwargs, context, category, method_key, method_executor) + except ValueError as e: + # Pre-processing validation failed + return PlaneToolBase.format_error_payload(str(e), "") + + # Execute via method_executor + result = await method_executor.execute(category, method_key, **kwargs) + + # POST-PROCESSING: Apply custom handler if provided + if metadata.post_handler: + result = await _apply_post_processing(metadata, result, kwargs, context, method_executor, category, method_key) + + # Format response based on whether it returns an entity + if result["success"]: + if metadata.returns_entity_type: + # Format with entity URL + return await PlaneToolBase.format_success_payload_with_url( + f"Successfully executed {metadata.name}", result.get("data"), metadata.returns_entity_type, context + ) + else: + # Simple success payload + return PlaneToolBase.format_success_payload("Action successfully executed", result["data"]) + else: + # Error payload + return PlaneToolBase.format_error_payload(f"Failed to execute {metadata.name}", result["error"]) + + # Set function metadata + tool_func.__name__ = metadata.name + tool_func.__doc__ = _build_docstring(metadata) + tool_func.__annotations__ = _build_type_annotations(metadata) + + # Build signature (for inspect.signature compatibility) + sig_params = _build_function_signature(metadata) + tool_func.__signature__ = inspect.Signature(parameters=list(sig_params.values())) # type: ignore[attr-defined] + + # Apply @tool decorator + decorated_tool = tool(tool_func) + + log.info(f"Generated tool: {metadata.name} (category={category}, method={method_key})") + + return decorated_tool # type: ignore[return-value] + + +def generate_tools_for_category( + category: str, + method_executor: Any, + context: Dict[str, Any], + tool_definitions: Dict[str, ToolMetadata], +) -> List[Callable]: + """Generate all tools for a category from metadata. + + Args: + category: API category (e.g., "labels") + method_executor: MethodExecutor instance + context: Execution context + tool_definitions: Dictionary of method_key -> ToolMetadata for this category + + Returns: + List of generated LangChain tools + """ + tools = [] + + for method_key, metadata in tool_definitions.items(): + try: + tool_func = generate_tool_from_metadata( + category=category, + method_key=method_key, + metadata=metadata, + method_executor=method_executor, + context=context, + ) + tools.append(tool_func) + except Exception as e: + log.error(f"Error generating tool for {category}.{method_key}: {e}", exc_info=True) + # Continue generating other tools + + log.info(f"Generated {len(tools)} tools for category '{category}'") + return tools diff --git a/apps/pi/pi/services/actions/tool_metadata.py b/apps/pi/pi/services/actions/tool_metadata.py new file mode 100644 index 0000000000..5ef14c2f70 --- /dev/null +++ b/apps/pi/pi/services/actions/tool_metadata.py @@ -0,0 +1,92 @@ +# SPDX-FileCopyrightText: 2023-present Plane Software, Inc. +# SPDX-License-Identifier: LicenseRef-Plane-Commercial +# +# Licensed under the Plane Commercial License (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# https://plane.so/legals/eula +# +# DO NOT remove or modify this notice. +# NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited. + +""" +Tool metadata models for centralized tool definition and auto-generation. + +This module defines Pydantic models that serve as the single source of truth +for all LangChain tool definitions. Tool metadata includes: +- Tool name and description +- All parameters with types, descriptions, and defaults +- SDK adapter method mapping +- Entity type for URL construction +""" + +from typing import Any +from typing import List +from typing import Literal +from typing import Optional + +from pydantic import BaseModel + + +class ToolParameter(BaseModel): + """Metadata for a single tool parameter. + + Attributes: + name: Parameter name (e.g., "project_id", "color", "assignees") + type: Python type annotation as string (e.g., "str", "Optional[str]", "List[str]") + required: Whether parameter is required + description: Human-readable parameter description for LLM + default: Default value if parameter is optional + auto_fill_from_context: Whether to auto-fill from context (workspace_slug, project_id) + property_transform: How to transform for UI property mapper (Phase 2 feature) + """ + + name: str + type: str + required: bool = True + description: str + default: Optional[Any] = None + auto_fill_from_context: bool = False + property_transform: Literal["name", "id", "id_list", "bool", "date", "json", "int", "skip"] = "name" + + class Config: + # Allow extra fields for future extensions + extra = "allow" + + +class ToolMetadata(BaseModel): + """Metadata for a single tool definition. + + This serves as the single source of truth for tool configuration, + used to auto-generate LangChain tools and UI property mappings. + """ + + name: str + description: str + sdk_method: str + parameters: List[ToolParameter] = [] + returns_entity_type: Optional[str] = None + pre_handler: Optional[Any] = None # Custom async function(metadata, kwargs, context, category, method_key) -> kwargs + post_handler: Optional[Any] = None # Custom async function(metadata, result, kwargs, context, method_executor, category, method_key) -> result + + class Config: + arbitrary_types_allowed = True # Allow function types + + def get_parameter(self, name: str) -> Optional[ToolParameter]: + """Get parameter by name.""" + for param in self.parameters: + if param.name == name: + return param + return None + + def get_required_parameters(self) -> List[ToolParameter]: + """Get all required parameters.""" + return [p for p in self.parameters if p.required] + + def get_optional_parameters(self) -> List[ToolParameter]: + """Get all optional parameters.""" + return [p for p in self.parameters if not p.required] + + def get_auto_fill_parameters(self) -> List[ToolParameter]: + """Get parameters that should be auto-filled from context.""" + return [p for p in self.parameters if p.auto_fill_from_context] diff --git a/apps/pi/pi/services/actions/tools/__init__.py b/apps/pi/pi/services/actions/tools/__init__.py index 6dae37c0b9..287c814516 100644 --- a/apps/pi/pi/services/actions/tools/__init__.py +++ b/apps/pi/pi/services/actions/tools/__init__.py @@ -29,7 +29,9 @@ from .activity import get_activity_tools from .assets import get_asset_tools from .attachments import get_attachment_tools from .comments import get_comment_tools +from .customers import get_customer_tools from .cycles import get_cycle_tools +from .initiatives import get_initiative_tools from .intake import get_intake_tools from .labels import get_label_tools from .links import get_link_tools @@ -39,10 +41,13 @@ from .pages import get_page_tools from .projects import get_project_tools from .properties import get_property_tools from .states import get_state_tools +from .stickies import get_sticky_tools +from .teamspaces import get_teamspace_tools from .types import get_type_tools from .users import get_user_tools from .workitems import get_workitem_tools from .worklogs import get_worklog_tools +from .workspaces import get_workspace_tools CATEGORY_TO_PROVIDER: Dict[str, Callable] = { "activity": get_activity_tools, @@ -53,10 +58,14 @@ CATEGORY_TO_PROVIDER: Dict[str, Callable] = { "attachment": get_attachment_tools, "comments": get_comment_tools, "comment": get_comment_tools, + "customers": get_customer_tools, + "customer": get_customer_tools, "cycles": get_cycle_tools, "cycle": get_cycle_tools, "intake": get_intake_tools, "intakes": get_intake_tools, + "initiatives": get_initiative_tools, + "initiative": get_initiative_tools, "labels": get_label_tools, "label": get_label_tools, "links": get_link_tools, @@ -73,6 +82,10 @@ CATEGORY_TO_PROVIDER: Dict[str, Callable] = { "property": get_property_tools, "states": get_state_tools, "state": get_state_tools, + "stickies": get_sticky_tools, + "sticky": get_sticky_tools, + "teamspaces": get_teamspace_tools, + "teamspace": get_teamspace_tools, "types": get_type_tools, "type": get_type_tools, "users": get_user_tools, @@ -81,6 +94,8 @@ CATEGORY_TO_PROVIDER: Dict[str, Callable] = { "workitem": get_workitem_tools, "worklogs": get_worklog_tools, "worklog": get_worklog_tools, + "workspaces": get_workspace_tools, + "workspace": get_workspace_tools, } diff --git a/apps/pi/pi/services/actions/tools/activity.py b/apps/pi/pi/services/actions/tools/activity.py index 25d7f5a992..377d8d1cfd 100644 --- a/apps/pi/pi/services/actions/tools/activity.py +++ b/apps/pi/pi/services/actions/tools/activity.py @@ -15,62 +15,132 @@ Activity API tools for Plane activity tracking operations. from typing import Any from typing import Dict -from typing import Optional -from langchain_core.tools import tool +from pi import logger +from pi.services.actions.tool_generator import generate_tools_for_category +from pi.services.actions.tool_metadata import ToolMetadata +from pi.services.actions.tool_metadata import ToolParameter -from .base import PlaneToolBase +# ============================================================================ +# ACTIVITY-SPECIFIC HANDLER FUNCTIONS +# ============================================================================ +log = logger.getChild("activity") -# Factory wired via CATEGORY_TO_PROVIDER in tools/__init__.py -# Returns LangChain tools implementing activity actions + +async def _activity_pre_handler( + metadata: ToolMetadata, + kwargs: Dict[str, Any], + context: Dict[str, Any], + category: str, + method_key: str, + method_executor: Any = None, +) -> Dict[str, Any]: + """Pre-processing handler for activity tools. + + Handles: + - Auto-detect project_id from issue_id if missing + """ + # If project_id is missing but we have issue_id, look it up in the database + if kwargs.get("issue_id") and not kwargs.get("project_id"): + issue_id = kwargs["issue_id"] + log.debug(f"project_id missing for activity_{method_key}, looking up issue_id={issue_id}") + + try: + from pi.app.api.v1.helpers.plane_sql_queries import get_issue_identifier_for_artifact + + work_item = await get_issue_identifier_for_artifact(issue_id) + if work_item and work_item.get("project_id"): + kwargs["project_id"] = work_item["project_id"] + log.debug(f"Auto-filled project_id={kwargs["project_id"]} from work item lookup") + else: + log.warning(f"Could not find project_id for issue_id={issue_id}") + except Exception as e: + log.error(f"Failed to lookup work item for project_id: {e}") + # Don't fail the entire operation, let the SDK call fail with proper error + + return kwargs + + +# ============================================================================ +# ACTIVITY TOOL DEFINITIONS +# ============================================================================ + +ACTIVITY_TOOL_DEFINITIONS: Dict[str, ToolMetadata] = { + "list": ToolMetadata( + name="activity_list", + description="List activity for a specific work item", + sdk_method="list_work_item_activities", + pre_handler=_activity_pre_handler, + parameters=[ + ToolParameter( + name="issue_id", + type="str", + required=True, + description="Work item ID (required) - activities are specific to individual work items", + ), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (auto-detected from context)", + auto_fill_from_context=True, + ), + ToolParameter( + name="project_id", + type="Optional[str]", + required=False, + description="Project ID (auto-detected from context)", + auto_fill_from_context=True, + ), + ], + ), + "retrieve": ToolMetadata( + name="activity_retrieve", + description="Get a single activity by ID", + sdk_method="retrieve_work_item_activity", + pre_handler=_activity_pre_handler, + parameters=[ + ToolParameter( + name="activity_id", + type="str", + required=True, + description="Activity ID (required)", + ), + ToolParameter( + name="issue_id", + type="str", + required=True, + description="Work item ID (required)", + ), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (auto-detected from context)", + auto_fill_from_context=True, + ), + ToolParameter( + name="project_id", + type="Optional[str]", + required=False, + description="Project ID (auto-detected from context)", + auto_fill_from_context=True, + ), + ], + ), +} + + +# ============================================================================ +# TOOL FACTORY +# ============================================================================ def get_activity_tools(method_executor, context): - """Return LangChain tools for the activity category using method_executor and context.""" - - @tool - async def activity_list(project_id: Optional[str] = None, workspace_slug: Optional[str] = None) -> Dict[str, Any]: - """List activity for a project. - - Args: - project_id: Parameter description (optional) - workspace_slug: Parameter description (optional) - """ - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - result = await method_executor.execute("activity", "list", project_id=project_id, workspace_slug=workspace_slug) - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully retrieved activity list", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to list activity", result["error"]) - - @tool - async def activity_retrieve( - activity_id: str, - project_id: Optional[str] = None, - workspace_slug: Optional[str] = None, - ) -> Dict[str, Any]: - """Get a single activity by ID.""" - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - result = await method_executor.execute( - "activity", - "retrieve", - activity_id=activity_id, - project_id=project_id, - workspace_slug=workspace_slug, - ) - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully retrieved activity", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to retrieve activity", result["error"]) - - return [activity_list, activity_retrieve] + """Return LangChain tools for the activity category using auto-generation from metadata.""" + return generate_tools_for_category( + category="activity", + method_executor=method_executor, + context=context, + tool_definitions=ACTIVITY_TOOL_DEFINITIONS, + ) diff --git a/apps/pi/pi/services/actions/tools/assets.py b/apps/pi/pi/services/actions/tools/assets.py index 245c37407d..c035770b4f 100644 --- a/apps/pi/pi/services/actions/tools/assets.py +++ b/apps/pi/pi/services/actions/tools/assets.py @@ -13,128 +13,160 @@ Assets API tools for Plane asset management operations. """ -from typing import Any from typing import Dict -from typing import Optional -from langchain_core.tools import tool +from pi.services.actions.tool_generator import generate_tools_for_category +from pi.services.actions.tool_metadata import ToolMetadata +from pi.services.actions.tool_metadata import ToolParameter -from .base import PlaneToolBase +# ============================================================================ +# ASSETS TOOL DEFINITIONS +# ============================================================================ -# Factory wired via CATEGORY_TO_PROVIDER in tools/__init__.py -# Returns LangChain tools implementing asset actions +ASSET_TOOL_DEFINITIONS: Dict[str, ToolMetadata] = { + "create": ToolMetadata( + name="assets_create", + description="Create a new generic asset", + sdk_method="create_generic_asset_upload", + parameters=[ + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (auto-detected from context)", + auto_fill_from_context=True, + ), + ToolParameter( + name="project_id", + type="Optional[str]", + required=False, + description="Project ID (auto-detected from context)", + auto_fill_from_context=True, + ), + ], + ), + "create_user_upload": ToolMetadata( + name="assets_create_user_upload", + description="Upload user-specific assets", + sdk_method="create_user_asset_upload", + parameters=[ + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (auto-detected from context)", + auto_fill_from_context=True, + ), + ToolParameter( + name="project_id", + type="Optional[str]", + required=False, + description="Project ID (auto-detected from context)", + auto_fill_from_context=True, + ), + ], + ), + "get_generic": ToolMetadata( + name="assets_get_generic", + description="Retrieve generic assets", + sdk_method="get_generic_asset", + parameters=[ + ToolParameter(name="asset_id", type="str", required=True, description="Asset ID (required)"), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (auto-detected from context)", + auto_fill_from_context=True, + ), + ToolParameter( + name="project_id", + type="Optional[str]", + required=False, + description="Project ID (auto-detected from context)", + auto_fill_from_context=True, + ), + ], + ), + "update_generic": ToolMetadata( + name="assets_update_generic", + description="Update generic assets", + sdk_method="update_generic_asset", + parameters=[ + ToolParameter(name="asset_id", type="str", required=True, description="Asset ID (required)"), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (auto-detected from context)", + auto_fill_from_context=True, + ), + ToolParameter( + name="project_id", + type="Optional[str]", + required=False, + description="Project ID (auto-detected from context)", + auto_fill_from_context=True, + ), + ], + ), + "update_user": ToolMetadata( + name="assets_update_user", + description="Update user assets", + sdk_method="update_user_asset", + parameters=[ + ToolParameter(name="asset_id", type="str", required=True, description="Asset ID (required)"), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (auto-detected from context)", + auto_fill_from_context=True, + ), + ToolParameter( + name="project_id", + type="Optional[str]", + required=False, + description="Project ID (auto-detected from context)", + auto_fill_from_context=True, + ), + ], + ), + "delete_user": ToolMetadata( + name="assets_delete_user", + description="Delete user assets", + sdk_method="delete_user_asset", + parameters=[ + ToolParameter(name="asset_id", type="str", required=True, description="Asset ID (required)"), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (auto-detected from context)", + auto_fill_from_context=True, + ), + ToolParameter( + name="project_id", + type="Optional[str]", + required=False, + description="Project ID (auto-detected from context)", + auto_fill_from_context=True, + ), + ], + ), +} + + +# ============================================================================ +# TOOL FACTORY +# ============================================================================ def get_asset_tools(method_executor, context): - """Return LangChain tools for the assets category using method_executor and context.""" - - @tool - async def assets_create(project_id: Optional[str] = None, workspace_slug: Optional[str] = None, **kwargs) -> Dict[str, Any]: - """Create a new generic asset. - - Args: - project_id: Parameter description (optional) - workspace_slug: Parameter description (optional) - """ - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - result = await method_executor.execute("assets", "create", project_id=project_id, workspace_slug=workspace_slug, **kwargs) - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully created asset", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to create asset", result["error"]) - - @tool - async def assets_create_user_upload(project_id: Optional[str] = None, workspace_slug: Optional[str] = None, **kwargs) -> Dict[str, Any]: - """Upload user-specific assets.""" - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - result = await method_executor.execute("assets", "create_user_upload", project_id=project_id, workspace_slug=workspace_slug, **kwargs) - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully created user asset upload", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to create user asset upload", result["error"]) - - @tool - async def assets_get_generic( - asset_id: str, - project_id: Optional[str] = None, - workspace_slug: Optional[str] = None, - ) -> Dict[str, Any]: - """Retrieve generic assets.""" - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - result = await method_executor.execute("assets", "get_generic", asset_id=asset_id, project_id=project_id, workspace_slug=workspace_slug) - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully retrieved generic asset", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to get generic asset", result["error"]) - - @tool - async def assets_update_generic( - asset_id: str, project_id: Optional[str] = None, workspace_slug: Optional[str] = None, **kwargs - ) -> Dict[str, Any]: - """Update generic assets.""" - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - result = await method_executor.execute( - "assets", "update_generic", asset_id=asset_id, project_id=project_id, workspace_slug=workspace_slug, **kwargs - ) - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully updated generic asset", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to update generic asset", result["error"]) - - @tool - async def assets_update_user(asset_id: str, project_id: Optional[str] = None, workspace_slug: Optional[str] = None, **kwargs) -> Dict[str, Any]: - """Update user assets.""" - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - result = await method_executor.execute( - "assets", "update_user", asset_id=asset_id, project_id=project_id, workspace_slug=workspace_slug, **kwargs - ) - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully updated user asset", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to update user asset", result["error"]) - - @tool - async def assets_delete_user( - asset_id: str, - project_id: Optional[str] = None, - workspace_slug: Optional[str] = None, - ) -> Dict[str, Any]: - """Delete user assets.""" - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - result = await method_executor.execute("assets", "delete_user", asset_id=asset_id, project_id=project_id, workspace_slug=workspace_slug) - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully deleted user asset", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to delete user asset", result["error"]) - - return [assets_create, assets_create_user_upload, assets_get_generic, assets_update_generic, assets_update_user, assets_delete_user] + """Return LangChain tools for the assets category using auto-generation from metadata.""" + return generate_tools_for_category( + category="assets", + method_executor=method_executor, + context=context, + tool_definitions=ASSET_TOOL_DEFINITIONS, + ) diff --git a/apps/pi/pi/services/actions/tools/base.py b/apps/pi/pi/services/actions/tools/base.py index 6bda01805c..f0c90e1177 100644 --- a/apps/pi/pi/services/actions/tools/base.py +++ b/apps/pi/pi/services/actions/tools/base.py @@ -88,7 +88,7 @@ class PlaneToolBase: """ # Lazy import to avoid circular dependency - from pi.agents.sql_agent.tools import extract_entity_from_api_response + from pi.agents.sql_agent.helpers import extract_entity_from_api_response # Extract entity data from response entity_data = extract_entity_from_api_response(data, entity_type) @@ -107,7 +107,7 @@ class PlaneToolBase: # Construct entity URL try: # Lazy import to avoid circular dependency - from pi.agents.sql_agent.tools import construct_action_entity_url + from pi.agents.sql_agent.helpers import construct_action_entity_url # Since this method is now async, we can directly await the async function url_info = await construct_action_entity_url(entity_data, entity_type, workspace_slug, frontend_url) @@ -164,7 +164,7 @@ class PlaneToolBase: Structured success payload that also includes constructed entity URL information when available. """ # Lazy import to avoid circular dependency - from pi.agents.sql_agent.tools import extract_entity_from_api_response + from pi.agents.sql_agent.helpers import extract_entity_from_api_response from pi.config import settings as _settings entity: Dict[str, Any] = {} @@ -177,7 +177,7 @@ class PlaneToolBase: entity_data["workspace"] = str(context["workspace_id"]) if workspace_slug: try: - from pi.agents.sql_agent.tools import construct_action_entity_url + from pi.agents.sql_agent.helpers import construct_action_entity_url url_info = await construct_action_entity_url(entity_data, entity_type, workspace_slug, frontend_url) if url_info: diff --git a/apps/pi/pi/services/actions/tools/comments.py b/apps/pi/pi/services/actions/tools/comments.py index b18368a4ff..134c5f4757 100644 --- a/apps/pi/pi/services/actions/tools/comments.py +++ b/apps/pi/pi/services/actions/tools/comments.py @@ -16,291 +16,159 @@ Comments API tools for Plane issue comments operations. import uuid from typing import Any from typing import Dict -from typing import Optional -from langchain_core.tools import tool +from pi.services.actions.tool_generator import generate_tools_for_category +from pi.services.actions.tool_metadata import ToolMetadata +from pi.services.actions.tool_metadata import ToolParameter -from pi.config import settings -from .base import PlaneToolBase +# Pre-handler to resolve project_id from issue_id if missing +async def _resolve_project_pre_handler( + metadata: ToolMetadata, + kwargs: Dict[str, Any], + context: Dict[str, Any], + category: str, + method_key: str, + method_executor: Any, +) -> Dict[str, Any]: + """Resolve project_id from issue_id if it is missing or invalid.""" + issue_id = kwargs.get("issue_id") + project_id = kwargs.get("project_id") -# Factory wired via CATEGORY_TO_PROVIDER in tools/__init__.py -# Returns LangChain tools implementing comment actions + should_resolve = False + if issue_id and not project_id: + should_resolve = True + elif issue_id and project_id: + try: + uuid.UUID(str(project_id)) + except (ValueError, AttributeError): + should_resolve = True + + if should_resolve: + try: + from pi.app.api.v1.helpers.plane_sql_queries import get_issue_identifier_for_artifact + + issue_info = await get_issue_identifier_for_artifact(str(issue_id)) + if issue_info and issue_info.get("project_id"): + kwargs["project_id"] = issue_info["project_id"] + except Exception: + # Best effort, proceed without resolution if it fails + pass + + return kwargs + + +COMMENTS_TOOL_DEFINITIONS = { + "create": ToolMetadata( + name="comments_create", + description="Create a comment on an issue.", + sdk_method="create_work_item_comment", + parameters=[ + ToolParameter(name="issue_id", description="Issue ID", required=True, type="str"), + ToolParameter(name="comment_html", description="Comment content in HTML format", required=True, type="str"), + ToolParameter( + name="project_id", description="Project ID (optional, auto-filled)", required=False, type="Optional[str]", auto_fill_from_context=True + ), + ToolParameter( + name="workspace_slug", + description="Workspace slug (optional, auto-filled)", + required=False, + type="Optional[str]", + auto_fill_from_context=True, + ), + ToolParameter(name="external_source", description="External source identifier (e.g., 'jira')", required=False, type="Optional[str]"), + ToolParameter(name="external_id", description="External system ID", required=False, type="Optional[str]"), + ], + pre_handler=_resolve_project_pre_handler, + returns_entity_type="comment", + ), + "list": ToolMetadata( + name="comments_list", + description="List comments for an issue.", + sdk_method="list_work_item_comments", + parameters=[ + ToolParameter(name="issue_id", description="Issue ID", required=True, type="str"), + ToolParameter( + name="project_id", description="Project ID (optional, auto-filled)", required=False, type="Optional[str]", auto_fill_from_context=True + ), + ToolParameter( + name="workspace_slug", + description="Workspace slug (optional, auto-filled)", + required=False, + type="Optional[str]", + auto_fill_from_context=True, + ), + ], + pre_handler=_resolve_project_pre_handler, + ), + "retrieve": ToolMetadata( + name="comments_retrieve", + description="Get a single comment by ID.", + sdk_method="retrieve_work_item_comment", + parameters=[ + ToolParameter(name="comment_id", description="Comment ID", required=True, type="str"), + ToolParameter(name="issue_id", description="Issue ID", required=True, type="str"), + ToolParameter( + name="project_id", description="Project ID (optional, auto-filled)", required=False, type="Optional[str]", auto_fill_from_context=True + ), + ToolParameter( + name="workspace_slug", + description="Workspace slug (optional, auto-filled)", + required=False, + type="Optional[str]", + auto_fill_from_context=True, + ), + ], + pre_handler=_resolve_project_pre_handler, + ), + "update": ToolMetadata( + name="comments_update", + description="Update comment details.", + sdk_method="update_work_item_comment", + parameters=[ + ToolParameter(name="comment_id", description="Comment ID", required=True, type="str"), + ToolParameter(name="issue_id", description="Issue ID", required=True, type="str"), + ToolParameter(name="comment_html", description="Comment content in HTML format", required=False, type="Optional[str]"), + ToolParameter( + name="project_id", description="Project ID (optional, auto-filled)", required=False, type="Optional[str]", auto_fill_from_context=True + ), + ToolParameter( + name="workspace_slug", + description="Workspace slug (optional, auto-filled)", + required=False, + type="Optional[str]", + auto_fill_from_context=True, + ), + ToolParameter(name="external_source", description="External source identifier", required=False, type="Optional[str]"), + ToolParameter(name="external_id", description="External system ID", required=False, type="Optional[str]"), + ], + pre_handler=_resolve_project_pre_handler, + returns_entity_type="comment", + ), + "delete": ToolMetadata( + name="comments_delete", + description="Delete a comment.", + sdk_method="delete_work_item_comment", + parameters=[ + ToolParameter(name="comment_id", description="Comment ID", required=True, type="str"), + ToolParameter(name="issue_id", description="Issue ID", required=True, type="str"), + ToolParameter( + name="project_id", description="Project ID (optional, auto-filled)", required=False, type="Optional[str]", auto_fill_from_context=True + ), + ToolParameter( + name="workspace_slug", + description="Workspace slug (optional, auto-filled)", + required=False, + type="Optional[str]", + auto_fill_from_context=True, + ), + ], + pre_handler=_resolve_project_pre_handler, + returns_entity_type="comment", + ), +} def get_comment_tools(method_executor, context): """Return LangChain tools for the comments category using method_executor and context.""" - - async def _build_issue_entity(issue_id: str, comment_id: Optional[str] = None) -> Optional[Dict[str, Any]]: - """Build entity payload for comment actions. - - - entity_url points to the parent issue (workitem) URL - - entity_type is "comment" - - entity_id is the comment_id when available (update/delete always, create best-effort) - - entity_name is left null for now (TODO) - """ - try: - workspace_slug = context.get("workspace_slug") if isinstance(context, dict) else None - if not workspace_slug: - return None - from pi.agents.sql_agent.tools import construct_action_entity_url - - url_info = await construct_action_entity_url({"id": str(issue_id)}, "workitem", workspace_slug, settings.plane_api.FRONTEND_URL) - if not url_info or not isinstance(url_info, dict): - return None - # Normalize expected keys for execute-action consumers - issue_identifier = url_info.get("issue_identifier") - entity: Dict[str, Any] = { - "entity_url": url_info.get("entity_url"), - "entity_name": None, - "entity_type": "comment", - "entity_id": str(comment_id) if comment_id else None, - } - if issue_identifier: - entity["issue_identifier"] = issue_identifier - entity["entity_identifier"] = issue_identifier - return entity - except Exception: - return None - - @tool - async def comments_create( - issue_id: str, - comment_html: str, - project_id: Optional[str] = None, - workspace_slug: Optional[str] = None, - external_source: Optional[str] = None, - external_id: Optional[str] = None, - ) -> Dict[str, Any]: - """Create a comment on an issue. - - Args: - issue_id: Issue ID (required) - comment_html: Comment content in HTML format (required) - project_id: Project ID (optional, auto-filled from context or resolved from issue_id) - workspace_slug: Workspace slug (optional, auto-filled from context) - external_source: External source identifier (e.g., "jira") - external_id: External system ID - """ - print(f"Context in comments_create: {context}") - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - # Note: issue_id and project_id validation/resolution is handled centrally - # in action_execution_helpers.validate_and_resolve_ids() before tool execution - - result = await method_executor.execute( - "comments", - "create", - issue_id=issue_id, - comment_html=comment_html, - external_source=external_source, - external_id=external_id, - project_id=project_id, - workspace_slug=workspace_slug, - ) - if result["success"]: - created_comment_id = None - try: - data = result.get("data") if isinstance(result, dict) else None - if isinstance(data, dict) and data.get("id"): - created_comment_id = str(data["id"]) - except Exception: - created_comment_id = None - entity = await _build_issue_entity(issue_id, comment_id=created_comment_id) - return PlaneToolBase.format_success_payload("Successfully created comment", result["data"], entity=entity) - else: - return PlaneToolBase.format_error_payload("Failed to create comment", result["error"]) - - @tool - async def comments_list(issue_id: str, project_id: Optional[str] = None, workspace_slug: Optional[str] = None) -> Dict[str, Any]: - """List comments for an issue.""" - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - result = await method_executor.execute("comments", "list", issue_id=issue_id, project_id=project_id, workspace_slug=workspace_slug) - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully retrieved comments list", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to list comments", result["error"]) - - @tool - async def comments_retrieve( - comment_id: str, - issue_id: str, - project_id: Optional[str] = None, - workspace_slug: Optional[str] = None, - ) -> Dict[str, Any]: - """Get a single comment by ID. - - Args: - comment_id: Comment ID (required) - issue_id: Issue ID (required) - project_id: Project ID (optional, auto-filled from context or resolved from issue_id) - workspace_slug: Workspace slug (optional, auto-filled from context) - """ - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - # Programmatically resolve project_id from issue_id if missing or invalid (non-UUID) - should_resolve = False - if issue_id and not project_id: - should_resolve = True - elif issue_id and project_id: - try: - uuid.UUID(str(project_id)) - except (ValueError, AttributeError): - should_resolve = True - - if should_resolve: - try: - from pi.app.api.v1.helpers.plane_sql_queries import get_issue_identifier_for_artifact - - issue_info = await get_issue_identifier_for_artifact(str(issue_id)) - if issue_info and issue_info.get("project_id"): - project_id = issue_info["project_id"] - except Exception: - pass - - result = await method_executor.execute( - "comments", - "retrieve", - comment_id=comment_id, - issue_id=issue_id, - project_id=project_id, - workspace_slug=workspace_slug, - ) - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully retrieved comment", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to retrieve comment", result["error"]) - - @tool - async def comments_update( - comment_id: str, - issue_id: str, - comment_html: Optional[str] = None, - project_id: Optional[str] = None, - workspace_slug: Optional[str] = None, - external_source: Optional[str] = None, - external_id: Optional[str] = None, - ) -> Dict[str, Any]: - """Update comment details. - - Args: - comment_id: Comment ID (required) - issue_id: Issue ID (required) - comment_html: Comment content in HTML format - project_id: Project ID (optional, auto-filled from context or resolved from issue_id) - workspace_slug: Workspace slug (optional, auto-filled from context) - external_source: External source identifier (e.g., "jira") - external_id: External system ID - """ - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - # Programmatically resolve project_id from issue_id if missing or invalid (non-UUID) - should_resolve = False - if issue_id and not project_id: - should_resolve = True - elif issue_id and project_id: - try: - uuid.UUID(str(project_id)) - except (ValueError, AttributeError): - should_resolve = True - - if should_resolve: - try: - from pi.app.api.v1.helpers.plane_sql_queries import get_issue_identifier_for_artifact - - issue_info = await get_issue_identifier_for_artifact(str(issue_id)) - if issue_info and issue_info.get("project_id"): - project_id = issue_info["project_id"] - except Exception: - pass - - result = await method_executor.execute( - "comments", - "update", - comment_id=comment_id, - issue_id=issue_id, - comment_html=comment_html, - external_source=external_source, - external_id=external_id, - project_id=project_id, - workspace_slug=workspace_slug, - ) - if result["success"]: - entity = await _build_issue_entity(issue_id, comment_id=comment_id) - return PlaneToolBase.format_success_payload("Successfully updated comment", result["data"], entity=entity) - else: - return PlaneToolBase.format_error_payload("Failed to update comment", result["error"]) - - @tool - async def comments_delete( - comment_id: str, - issue_id: str, - project_id: Optional[str] = None, - workspace_slug: Optional[str] = None, - ) -> Dict[str, Any]: - """Delete a comment. - - Args: - comment_id: Comment ID (required) - issue_id: Issue ID (required) - project_id: Project ID (optional, auto-filled from context or resolved from issue_id) - workspace_slug: Workspace slug (optional, auto-filled from context) - """ - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - # Programmatically resolve project_id from issue_id if missing or invalid (non-UUID) - should_resolve = False - if issue_id and not project_id: - should_resolve = True - elif issue_id and project_id: - try: - uuid.UUID(str(project_id)) - except (ValueError, AttributeError): - should_resolve = True - - if should_resolve: - try: - from pi.app.api.v1.helpers.plane_sql_queries import get_issue_identifier_for_artifact - - issue_info = await get_issue_identifier_for_artifact(str(issue_id)) - if issue_info and issue_info.get("project_id"): - project_id = issue_info["project_id"] - except Exception: - pass - - result = await method_executor.execute( - "comments", - "delete", - comment_id=comment_id, - issue_id=issue_id, - project_id=project_id, - workspace_slug=workspace_slug, - ) - if result["success"]: - entity = await _build_issue_entity(issue_id, comment_id=comment_id) - return PlaneToolBase.format_success_payload("Successfully deleted comment", result["data"], entity=entity) - else: - return PlaneToolBase.format_error_payload("Failed to delete comment", result["error"]) - - return [comments_create, comments_list, comments_retrieve, comments_update, comments_delete] + return generate_tools_for_category("comments", method_executor, context, COMMENTS_TOOL_DEFINITIONS) diff --git a/apps/pi/pi/services/actions/tools/customers.py b/apps/pi/pi/services/actions/tools/customers.py new file mode 100644 index 0000000000..f11feef92b --- /dev/null +++ b/apps/pi/pi/services/actions/tools/customers.py @@ -0,0 +1,264 @@ +# SPDX-FileCopyrightText: 2023-present Plane Software, Inc. +# SPDX-License-Identifier: LicenseRef-Plane-Commercial +# +# Licensed under the Plane Commercial License (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# https://plane.so/legals/eula +# +# DO NOT remove or modify this notice. +# NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited. + +""" +Customers API tools for Plane customer relationship management. +""" + +from typing import Dict + +from pi.services.actions.tool_generator import generate_tools_for_category +from pi.services.actions.tool_metadata import ToolMetadata +from pi.services.actions.tool_metadata import ToolParameter + +# ============================================================================ +# CUSTOMERS TOOL DEFINITIONS +# ============================================================================ + +CUSTOMER_TOOL_DEFINITIONS: Dict[str, ToolMetadata] = { + "create": ToolMetadata( + name="customers_create", + description="Create a new customer", + sdk_method="create_customer", + returns_entity_type="customer", + parameters=[ + ToolParameter( + name="name", + type="str", + required=True, + description="Customer name (required)", + ), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (auto-detected from context)", + auto_fill_from_context=True, + ), + ToolParameter( + name="description", + type="Optional[str]", + required=False, + description="Plain text description (optional)", + ), + ToolParameter( + name="description_html", + type="Optional[str]", + required=False, + description="HTML description (optional)", + ), + ToolParameter( + name="email", + type="Optional[str]", + required=False, + description="Customer contact email (optional)", + ), + ToolParameter( + name="website_url", + type="Optional[str]", + required=False, + description="Customer website URL (optional)", + ), + ToolParameter( + name="logo_props", + type="Optional[dict]", + required=False, + description="Logo configuration JSON (optional)", + ), + ToolParameter( + name="domain", + type="Optional[str]", + required=False, + description="Customer domain (optional)", + ), + ToolParameter( + name="employees", + type="Optional[int]", + required=False, + description="Number of employees (optional)", + ), + ToolParameter( + name="stage", + type="Optional[str]", + required=False, + description="Customer stage/lifecycle (optional)", + ), + ToolParameter( + name="contract_status", + type="Optional[str]", + required=False, + description="Contract status (optional)", + ), + ToolParameter( + name="revenue", + type="Optional[float]", + required=False, + description="Annual revenue (optional)", + ), + ], + ), + "list": ToolMetadata( + name="customers_list", + description="List all customers in the workspace", + sdk_method="list_customers", + parameters=[ + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (auto-detected from context)", + auto_fill_from_context=True, + ), + ], + ), + "retrieve": ToolMetadata( + name="customers_retrieve", + description="Retrieve a single customer by ID", + sdk_method="retrieve_customer", + parameters=[ + ToolParameter( + name="customer_id", + type="str", + required=True, + description="Customer ID (required)", + ), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (auto-detected from context)", + auto_fill_from_context=True, + ), + ], + ), + "update": ToolMetadata( + name="customers_update", + description="Update customer details", + sdk_method="update_customer", + returns_entity_type="customer", + parameters=[ + ToolParameter( + name="customer_id", + type="str", + required=True, + description="Customer ID (required)", + ), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (auto-detected from context)", + auto_fill_from_context=True, + ), + ToolParameter( + name="name", + type="Optional[str]", + required=False, + description="New customer name", + ), + ToolParameter( + name="description", + type="Optional[str]", + required=False, + description="New plain text description", + ), + ToolParameter( + name="description_html", + type="Optional[str]", + required=False, + description="New HTML description", + ), + ToolParameter( + name="email", + type="Optional[str]", + required=False, + description="New contact email", + ), + ToolParameter( + name="website_url", + type="Optional[str]", + required=False, + description="New website URL", + ), + ToolParameter( + name="logo_props", + type="Optional[dict]", + required=False, + description="New logo configuration JSON", + ), + ToolParameter( + name="domain", + type="Optional[str]", + required=False, + description="New domain", + ), + ToolParameter( + name="employees", + type="Optional[int]", + required=False, + description="New employee count", + ), + ToolParameter( + name="stage", + type="Optional[str]", + required=False, + description="New customer stage", + ), + ToolParameter( + name="contract_status", + type="Optional[str]", + required=False, + description="New contract status", + ), + ToolParameter( + name="revenue", + type="Optional[float]", + required=False, + description="New annual revenue", + ), + ], + ), + "delete": ToolMetadata( + name="customers_delete", + description="Delete a customer", + sdk_method="delete_customer", + parameters=[ + ToolParameter( + name="customer_id", + type="str", + required=True, + description="Customer ID (required)", + ), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (auto-detected from context)", + auto_fill_from_context=True, + ), + ], + ), +} + + +# ============================================================================ +# TOOL FACTORY +# ============================================================================ + + +def get_customer_tools(method_executor, context): + """Return LangChain tools for the customers category using auto-generation from metadata.""" + return generate_tools_for_category( + category="customers", + method_executor=method_executor, + context=context, + tool_definitions=CUSTOMER_TOOL_DEFINITIONS, + ) diff --git a/apps/pi/pi/services/actions/tools/cycles.py b/apps/pi/pi/services/actions/tools/cycles.py index 29cbf28493..5a94f76fea 100644 --- a/apps/pi/pi/services/actions/tools/cycles.py +++ b/apps/pi/pi/services/actions/tools/cycles.py @@ -13,368 +13,169 @@ Cycles API tools for Plane project cycle management operations. """ -from typing import Any from typing import Dict -from typing import Optional -from langchain_core.tools import tool +from pi.services.actions.tool_generator import generate_tools_for_category +from pi.services.actions.tool_metadata import ToolMetadata +from pi.services.actions.tool_metadata import ToolParameter -from .base import PlaneToolBase +# ============================================================================ +# CYCLES TOOL DEFINITIONS +# ============================================================================ -# Factory wired via CATEGORY_TO_PROVIDER in tools/__init__.py -# Returns LangChain tools implementing cycle actions +CYCLES_TOOL_DEFINITIONS: Dict[str, ToolMetadata] = { + "create": ToolMetadata( + name="cycles_create", + description="Create a new cycle. Note: Both start_date and end_date must be provided together, or neither.", + sdk_method="create_cycle", + returns_entity_type="cycle", + parameters=[ + ToolParameter(name="name", type="str", required=True, description="Cycle name (required)"), + ToolParameter(name="project_id", type="Optional[str]", required=False, description="Project ID (required)", auto_fill_from_context=True), + ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True), + ToolParameter( + name="start_date", type="Optional[str]", required=False, description="Start date (YYYY-MM-DD). Must be paired with end_date." + ), + ToolParameter( + name="end_date", type="Optional[str]", required=False, description="End date (YYYY-MM-DD). Must be paired with start_date." + ), + ToolParameter(name="description", type="Optional[str]", required=False, description="Cycle description"), + ToolParameter(name="owned_by", type="Optional[str]", required=False, description="User ID who owns the cycle"), + ToolParameter(name="external_id", type="Optional[str]", required=False, description="External system identifier"), + ToolParameter(name="external_source", type="Optional[str]", required=False, description="External system source name"), + ], + ), + "list": ToolMetadata( + name="cycles_list", + description="List cycles in a project. Requires project_id.", + sdk_method="list_cycles", + parameters=[ + ToolParameter(name="project_id", type="Optional[str]", required=False, description="Project ID (required)", auto_fill_from_context=True), + ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True), + ToolParameter(name="per_page", type="Optional[int]", required=False, description="Number of cycles per page (default: 20)"), + ToolParameter(name="cursor", type="Optional[str]", required=False, description="Pagination cursor for next page"), + ToolParameter(name="cycle_view", type="Optional[str]", required=False, description="Filter cycles by status"), + ], + ), + "retrieve": ToolMetadata( + name="cycles_retrieve", + description="Retrieve a single cycle by ID.", + sdk_method="retrieve_cycle", + parameters=[ + ToolParameter(name="cycle_id", type="str", required=True, description="Cycle ID (required)"), + ToolParameter(name="project_id", type="str", required=True, description="Project ID (required)"), + ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True), + ], + ), + "update": ToolMetadata( + name="cycles_update", + description="Update cycle details.", + sdk_method="update_cycle", + returns_entity_type="cycle", + parameters=[ + ToolParameter(name="cycle_id", type="str", required=True, description="Cycle ID (required)"), + ToolParameter(name="project_id", type="str", required=True, description="Project ID (required)"), + ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True), + ToolParameter(name="name", type="Optional[str]", required=False, description="New cycle name"), + ToolParameter(name="description", type="Optional[str]", required=False, description="New description"), + ToolParameter(name="start_date", type="Optional[str]", required=False, description="New start date (YYYY-MM-DD)"), + ToolParameter(name="end_date", type="Optional[str]", required=False, description="New end date (YYYY-MM-DD)"), + ToolParameter(name="owned_by", type="Optional[str]", required=False, description="New owner user ID"), + ToolParameter(name="external_id", type="Optional[str]", required=False, description="External identifier"), + ToolParameter(name="external_source", type="Optional[str]", required=False, description="External source name"), + ], + ), + "archive": ToolMetadata( + name="cycles_archive", + description="Archive a cycle.", + sdk_method="archive_cycle", + parameters=[ + ToolParameter(name="cycle_id", type="str", required=True, description="Cycle ID (required)"), + ToolParameter(name="project_id", type="str", required=True, description="Project ID (required)"), + ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True), + ], + ), + "unarchive": ToolMetadata( + name="cycles_unarchive", + description="Unarchive (restore) a cycle.", + sdk_method="unarchive_cycle", + parameters=[ + ToolParameter(name="cycle_id", type="str", required=True, description="Cycle ID (required)"), + ToolParameter(name="project_id", type="str", required=True, description="Project ID (required)"), + ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True), + ], + ), + "list_archived": ToolMetadata( + name="cycles_list_archived", + description="List archived cycles in a project.", + sdk_method="list_archived_cycles", + parameters=[ + ToolParameter(name="project_id", type="str", required=True, description="Project ID (required)"), + ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True), + ], + ), + "add_work_items": ToolMetadata( + name="cycles_add_work_items", + description="Add work items to a cycle.", + sdk_method="add_cycle_work_items", + parameters=[ + ToolParameter(name="cycle_id", type="str", required=True, description="Cycle ID (required)"), + ToolParameter(name="issues", type="list", required=True, description="List of issue IDs to add to the cycle"), + ToolParameter(name="project_id", type="str", required=True, description="Project ID (required)"), + ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True), + ], + ), + "list_work_items": ToolMetadata( + name="cycles_list_work_items", + description="List work items in a cycle.", + sdk_method="list_cycle_work_items", + parameters=[ + ToolParameter(name="cycle_id", type="str", required=True, description="Cycle ID (required)"), + ToolParameter(name="project_id", type="str", required=True, description="Project ID (required)"), + ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True), + ], + ), + "retrieve_work_item": ToolMetadata( + name="cycles_retrieve_work_item", + description="Get a specific work item in a cycle.", + sdk_method="retrieve_cycle_work_item", + parameters=[ + ToolParameter(name="cycle_id", type="str", required=True, description="Cycle ID (required)"), + ToolParameter(name="issue_id", type="str", required=True, description="Issue ID (required)"), + ToolParameter(name="project_id", type="str", required=True, description="Project ID (required)"), + ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True), + ], + ), + "remove_work_item": ToolMetadata( + name="cycles_remove_work_item", + description="Remove a work item from a cycle.", + sdk_method="remove_cycle_work_item", + parameters=[ + ToolParameter(name="cycle_id", type="str", required=True, description="Cycle ID (required)"), + ToolParameter(name="issue_id", type="str", required=True, description="Issue ID to remove (required)"), + ToolParameter(name="project_id", type="str", required=True, description="Project ID (required)"), + ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True), + ], + ), + "transfer_work_items": ToolMetadata( + name="cycles_transfer_work_items", + description="Transfer work items between cycles.", + sdk_method="transfer_cycle_work_items", + parameters=[ + ToolParameter(name="cycle_id", type="str", required=True, description="Source cycle ID (required)"), + ToolParameter(name="new_cycle_id", type="str", required=True, description="Destination cycle ID (required)"), + ToolParameter(name="project_id", type="str", required=True, description="Project ID (required)"), + ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True), + ], + ), +} + + +# ============================================================================ +# TOOL FACTORY +# ============================================================================ def get_cycle_tools(method_executor, context): - """Return LangChain tools for the cycles category using method_executor and context.""" - - @tool - async def cycles_create( - name: str, - project_id: Optional[str] = None, - workspace_slug: Optional[str] = None, - start_date: Optional[str] = None, - end_date: Optional[str] = None, - description: Optional[str] = None, - owned_by: Optional[str] = None, - external_id: Optional[str] = None, - external_source: Optional[str] = None, - ) -> Dict[str, Any]: - """Create a new cycle. - - Args: - name: Cycle name (required) - project_id: Project ID (required - provide from conversation context or previous actions) - workspace_slug: Workspace slug (required - provide from conversation context) - start_date: Start date in YYYY-MM-DD format (IMPORTANT: if provided, end_date must also be provided) - end_date: End date in YYYY-MM-DD format (IMPORTANT: if provided, start_date must also be provided) - description: Cycle description (optional) - owned_by: User ID who owns the cycle (provide when user specifies ownership or assignment) - external_id: External system identifier (optional) - external_source: External system source name (optional) - - Note: Both start_date and end_date must be provided together, or neither should be provided. - You cannot provide only one date - the API will reject the request. - """ - # Auto-fill from context if not provided - if workspace_slug is None: - if "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None: - if "project_id" in context: - project_id = context["project_id"] - # project_id and workspace_slug are now required parameters - - result = await method_executor.execute( - "cycles", - "create", - name=name, - project_id=project_id, - workspace_slug=workspace_slug, - start_date=start_date, - end_date=end_date, - description=description, - owned_by=owned_by, - external_id=external_id, - external_source=external_source, - ) - - if result["success"]: - return await PlaneToolBase.format_success_payload_with_url(f"Successfully created cycle '{name}'", result["data"], "cycle", context) - else: - return PlaneToolBase.format_error_payload("Failed to create cycle", result["error"]) - - @tool - async def cycles_list( - project_id: Optional[str] = None, - workspace_slug: Optional[str] = None, - per_page: Optional[int] = 20, - cursor: Optional[str] = None, - cycle_view: Optional[str] = None, - ) -> Dict[str, Any]: - """List cycles in a project. - - Args: - project_id: Project ID (REQUIRED - provide from conversation context or previous actions) - workspace_slug: Workspace slug (auto-filled from context; LLM-supplied value will be overridden) - per_page: Number of cycles per page (default: 20, max: 100) - cursor: Pagination cursor for next page - cycle_view: Filter cycles by status (optional) - - Important: This tool REQUIRES a project_id. If you don't have one, you MUST first: - 1. Call search_project_by_name to find the project, OR - 2. Call projects_list to see available projects, OR - 3. Call ask_for_clarification to ask the user which project to use - """ - # Auto-fill and harden workspace/project context - # Always prefer the execution context over LLM-provided values to avoid permission errors - if "workspace_slug" in context and context.get("workspace_slug"): - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - # Validate required parameters after auto-fill - if not project_id or not workspace_slug: - return PlaneToolBase.format_error_payload( - "Failed to list cycles", - "Missing required context: project_id/workspace_slug", - ) - - result = await method_executor.execute( - "cycles", - "list", - project_id=project_id, - workspace_slug=workspace_slug, - per_page=per_page, - cursor=cursor, - cycle_view=cycle_view, - ) - - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully retrieved cycles list", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to list cycles", result["error"]) - - @tool - async def cycles_retrieve(cycle_id: str, project_id: str, workspace_slug: str) -> Dict[str, Any]: - """Retrieve a single cycle by ID. - - Args: - cycle_id: Cycle ID (required) - project_id: Project ID (required - provide from conversation context or previous actions) - workspace_slug: Workspace slug (provide if known, otherwise auto-detected) - """ - # project_id and workspace_slug are now required parameters - - result = await method_executor.execute("cycles", "retrieve", cycle_id=cycle_id, project_id=project_id, workspace_slug=workspace_slug) - - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully retrieved cycle", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to retrieve cycle", result["error"]) - - @tool - async def cycles_update( - cycle_id: str, - project_id: str, - workspace_slug: Optional[str] = None, - name: Optional[str] = None, - description: Optional[str] = None, - start_date: Optional[str] = None, - end_date: Optional[str] = None, - owned_by: Optional[str] = None, - external_id: Optional[str] = None, - external_source: Optional[str] = None, - ) -> Dict[str, Any]: - """Update cycle details. - - Args: - cycle_id: Cycle ID (required) - project_id: Project ID (required - provide from conversation context or previous actions) - workspace_slug: Workspace slug (provide if known, otherwise auto-detected) - name: New cycle name - description: New description - start_date: New start date (YYYY-MM-DD) - end_date: New end date (YYYY-MM-DD) - owned_by: New owner user ID (optional) - external_id: External system identifier (optional) - external_source: External system source name (optional) - """ - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - - # Build update request object with non-None values only - update_data = {} - if name is not None: - update_data["name"] = name - if description is not None: - update_data["description"] = description - if start_date is not None: - update_data["start_date"] = start_date - if end_date is not None: - update_data["end_date"] = end_date - if owned_by is not None: - update_data["owned_by"] = owned_by - if external_id is not None: - update_data["external_id"] = external_id - if external_source is not None: - update_data["external_source"] = external_source - - result = await method_executor.execute( - "cycles", "update", cycle_id=cycle_id, project_id=project_id, workspace_slug=workspace_slug, **update_data - ) - - if result["success"]: - return await PlaneToolBase.format_success_payload_with_url("Successfully updated cycle", result["data"], "cycle", context) - else: - return PlaneToolBase.format_error_payload("Failed to update cycle", result["error"]) - - @tool - async def cycles_archive(cycle_id: str, project_id: str, workspace_slug: str) -> Dict[str, Any]: - """Archive a cycle. - - Args: - cycle_id: Cycle ID (required) - project_id: Project ID (required - provide from conversation context or previous actions) - workspace_slug: Workspace slug (provide if known, otherwise auto-detected) - """ - # project_id and workspace_slug are now required parameters - - result = await method_executor.execute("cycles", "archive", cycle_id=cycle_id, project_id=project_id, workspace_slug=workspace_slug) - - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully archived cycle", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to archive cycle", result["error"]) - - @tool - async def cycles_add_work_items(cycle_id: str, issues: list, project_id: str, workspace_slug: Optional[str] = None) -> Dict[str, Any]: - """Add work items to a cycle. - - Args: - cycle_id: Cycle ID (required) - issues: List of issue IDs to add to the cycle (required) - project_id: Project ID (required - provide from conversation context or previous actions) - workspace_slug: Workspace slug (provide if known, otherwise auto-detected) - """ - # Auto-fill from context if not provided - if workspace_slug is None: - if "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - # Check if issues list is empty - if not issues or len(issues) == 0: - return PlaneToolBase.format_success_payload( - "No work items to add to cycle", {"message": "There are no work items satisfying the criteria to add to the cycle", "issues_count": 0} - ) - - # project_id and workspace_slug are now required parameters - result = await method_executor.execute( - "cycles", "add_work_items", cycle_id=cycle_id, issues=issues, project_id=project_id, workspace_slug=workspace_slug - ) - - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully added work items to cycle", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to add work items to cycle", result["error"]) - - @tool - async def cycles_remove_work_item(cycle_id: str, issue_id: str, project_id: str, workspace_slug: Optional[str] = None) -> Dict[str, Any]: - """Remove a work item from a cycle. - - Args: - cycle_id: Cycle ID (required) - issue_id: Issue ID to remove from the cycle (required) - project_id: Project ID (required - provide from conversation context or previous actions) - workspace_slug: Workspace slug (provide if known, otherwise auto-detected) - """ - # Auto-fill from context if not provided - if workspace_slug is None: - if "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - - # project_id and workspace_slug are now required parameters - result = await method_executor.execute( - "cycles", "remove_work_item", cycle_id=cycle_id, issue_id=issue_id, project_id=project_id, workspace_slug=workspace_slug - ) - - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully removed work item from cycle", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to remove work item from cycle", result["error"]) - - @tool - async def cycles_unarchive(cycle_id: str, project_id: str, workspace_slug: Optional[str] = None) -> Dict[str, Any]: - """Unarchive a cycle.""" - # Auto-fill from context if not provided - if workspace_slug is None: - if "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - - # project_id and workspace_slug are now required parameters - result = await method_executor.execute("cycles", "unarchive", cycle_id=cycle_id, project_id=project_id, workspace_slug=workspace_slug) - if result["success"]: - return PlaneToolBase.format_success_payload(f"Successfully unarchived cycle '{cycle_id}'", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to unarchive cycle", result["error"]) - - @tool - async def cycles_list_archived(project_id: str, workspace_slug: Optional[str] = None) -> Dict[str, Any]: - """List archived cycles.""" - # Auto-fill from context if not provided - if workspace_slug is None: - if "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - - # project_id and workspace_slug are now required parameters - result = await method_executor.execute("cycles", "list_archived", project_id=project_id, workspace_slug=workspace_slug) - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully retrieved archived cycles list", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to list archived cycles", result["error"]) - - @tool - async def cycles_list_work_items(cycle_id: str, project_id: str, workspace_slug: Optional[str] = None) -> Dict[str, Any]: - """List work items in a cycle.""" - # Auto-fill from context if not provided - if workspace_slug is None: - if "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - - # project_id and workspace_slug are now required parameters - result = await method_executor.execute("cycles", "list_work_items", cycle_id=cycle_id, project_id=project_id, workspace_slug=workspace_slug) - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully retrieved cycle work items list", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to list cycle work items", result["error"]) - - @tool - async def cycles_retrieve_work_item(cycle_id: str, issue_id: str, project_id: str, workspace_slug: Optional[str] = None) -> Dict[str, Any]: - """Get a specific work item in a cycle.""" - # Auto-fill from context if not provided - if workspace_slug is None: - if "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - - # project_id and workspace_slug are now required parameters - result = await method_executor.execute( - "cycles", "retrieve_work_item", cycle_id=cycle_id, issue_id=issue_id, project_id=project_id, workspace_slug=workspace_slug - ) - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully retrieved cycle work item", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to retrieve cycle work item", result["error"]) - - @tool - async def cycles_transfer_work_items(cycle_id: str, new_cycle_id: str, project_id: str, workspace_slug: Optional[str] = None) -> Dict[str, Any]: - """Transfer work items between cycles.""" - # Auto-fill from context if not provided - if workspace_slug is None: - if "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - - # project_id and workspace_slug are now required parameters - result = await method_executor.execute( - "cycles", "transfer_work_items", cycle_id=cycle_id, new_cycle_id=new_cycle_id, project_id=project_id, workspace_slug=workspace_slug - ) - if result["success"]: - return PlaneToolBase.format_success_payload( - f"Successfully transferred work items from cycle '{cycle_id}' to '{new_cycle_id}'", result["data"] - ) - else: - return PlaneToolBase.format_error_payload("Failed to transfer cycle work items", result["error"]) - - return [ - cycles_create, - cycles_update, - cycles_archive, - cycles_unarchive, - cycles_add_work_items, - cycles_remove_work_item, - cycles_transfer_work_items, - cycles_retrieve, - cycles_list, - cycles_list_archived, - cycles_list_work_items, - cycles_retrieve_work_item, - ] + """Return LangChain tools for the cycles category using auto-generation from metadata.""" + return generate_tools_for_category("cycles", method_executor, context, CYCLES_TOOL_DEFINITIONS) diff --git a/apps/pi/pi/services/actions/tools/initiatives.py b/apps/pi/pi/services/actions/tools/initiatives.py new file mode 100644 index 0000000000..29a8526292 --- /dev/null +++ b/apps/pi/pi/services/actions/tools/initiatives.py @@ -0,0 +1,535 @@ +# SPDX-FileCopyrightText: 2023-present Plane Software, Inc. +# SPDX-License-Identifier: LicenseRef-Plane-Commercial +# +# Licensed under the Plane Commercial License (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# https://plane.so/legals/eula +# +# DO NOT remove or modify this notice. +# NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited. + +""" +Initiatives API tools for Plane strategic planning operations. +""" + +from typing import Dict + +from pi.services.actions.tool_generator import generate_tools_for_category +from pi.services.actions.tool_metadata import ToolMetadata +from pi.services.actions.tool_metadata import ToolParameter + +# ============================================================================ +# INITIATIVES TOOL DEFINITIONS +# ============================================================================ + +INITIATIVE_TOOL_DEFINITIONS: Dict[str, ToolMetadata] = { + "create": ToolMetadata( + name="initiatives_create", + description="Create a new strategic initiative", + sdk_method="create_initiative", + returns_entity_type="initiative", + parameters=[ + ToolParameter( + name="name", + type="str", + required=True, + description="Initiative name (required)", + ), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (auto-detected from context)", + auto_fill_from_context=True, + ), + ToolParameter( + name="description_html", + type="Optional[str]", + required=False, + description="HTML description of the initiative", + ), + ToolParameter( + name="start_date", + type="Optional[str]", + required=False, + description="Initiative start date (YYYY-MM-DD)", + ), + ToolParameter( + name="end_date", + type="Optional[str]", + required=False, + description="Initiative end date (YYYY-MM-DD)", + ), + ToolParameter( + name="state", + type="Optional[str]", + required=False, + description="Initiative state. Must be one of: DRAFT, PLANNED, ACTIVE, COMPLETED, CLOSED (uppercase required). Defaults to DRAFT if not provided.", # noqa: E501 + ), + ToolParameter( + name="lead", + type="Optional[str]", + required=False, + description="Initiative lead user ID", + ), + ], + ), + "list": ToolMetadata( + name="initiatives_list", + description="List initiatives in the workspace", + sdk_method="list_initiatives", + parameters=[ + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (auto-detected from context)", + auto_fill_from_context=True, + ), + ], + ), + "retrieve": ToolMetadata( + name="initiatives_retrieve", + description="Retrieve a single initiative by ID", + sdk_method="retrieve_initiative", + parameters=[ + ToolParameter( + name="initiative_id", + type="str", + required=True, + description="Initiative ID (required)", + ), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (auto-detected from context)", + auto_fill_from_context=True, + ), + ], + ), + "update": ToolMetadata( + name="initiatives_update", + description="Update initiative details", + sdk_method="update_initiative", + returns_entity_type="initiative", + parameters=[ + ToolParameter( + name="initiative_id", + type="str", + required=True, + description="Initiative ID (required)", + ), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (auto-detected from context)", + auto_fill_from_context=True, + ), + ToolParameter( + name="name", + type="Optional[str]", + required=False, + description="New initiative name", + ), + ToolParameter( + name="description_html", + type="Optional[str]", + required=False, + description="New HTML description", + ), + ToolParameter( + name="start_date", + type="Optional[str]", + required=False, + description="New start date (YYYY-MM-DD)", + ), + ToolParameter( + name="end_date", + type="Optional[str]", + required=False, + description="New end date (YYYY-MM-DD)", + ), + ToolParameter( + name="logo_props", + type="Optional[dict]", + required=False, + description="New logo configuration JSON", + ), + ToolParameter( + name="state", + type="Optional[str]", + required=False, + description="New initiative state. Must be one of: DRAFT, PLANNED, ACTIVE, COMPLETED, CLOSED (uppercase required)", + ), + ToolParameter( + name="lead", + type="Optional[str]", + required=False, + description="New initiative lead user ID", + ), + ], + ), + "delete": ToolMetadata( + name="initiatives_delete", + description="Delete an initiative", + sdk_method="delete_initiative", + parameters=[ + ToolParameter( + name="initiative_id", + type="str", + required=True, + description="Initiative ID (required)", + ), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (auto-detected from context)", + auto_fill_from_context=True, + ), + ], + ), + # Label operations + "create_label": ToolMetadata( + name="initiatives_create_label", + description="Create a workspace-level initiative label (can later be attached to initiatives)", + sdk_method="create_initiative_label", + returns_entity_type="initiative_label", + parameters=[ + ToolParameter( + name="name", + type="str", + required=True, + description="Label name (required)", + ), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (auto-detected from context)", + auto_fill_from_context=True, + ), + ToolParameter( + name="color", + type="Optional[str]", + required=False, + description="Label color hex code", + ), + ToolParameter( + name="description", + type="Optional[str]", + required=False, + description="Label description", + ), + ], + ), + "list_labels": ToolMetadata( + name="initiatives_list_labels", + description="List all workspace-level initiative labels", + sdk_method="list_initiative_labels", + parameters=[ + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (auto-detected from context)", + auto_fill_from_context=True, + ), + ], + ), + "retrieve_label": ToolMetadata( + name="initiatives_retrieve_label", + description="Get a single initiative label by ID", + sdk_method="retrieve_initiative_label", + parameters=[ + ToolParameter( + name="label_id", + type="str", + required=True, + description="Label ID (required)", + ), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (auto-detected from context)", + auto_fill_from_context=True, + ), + ], + ), + "update_label": ToolMetadata( + name="initiatives_update_label", + description="Update an initiative label", + sdk_method="update_initiative_label", + returns_entity_type="initiative_label", + parameters=[ + ToolParameter( + name="label_id", + type="str", + required=True, + description="Label ID (required)", + ), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (auto-detected from context)", + auto_fill_from_context=True, + ), + ToolParameter( + name="name", + type="Optional[str]", + required=False, + description="New label name", + ), + ToolParameter( + name="color", + type="Optional[str]", + required=False, + description="New label color hex code", + ), + ToolParameter( + name="description", + type="Optional[str]", + required=False, + description="New label description", + ), + ], + ), + "delete_label": ToolMetadata( + name="initiatives_delete_label", + description="Delete an initiative label", + sdk_method="delete_initiative_label", + parameters=[ + ToolParameter( + name="label_id", + type="str", + required=True, + description="Label ID (required)", + ), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (auto-detected from context)", + auto_fill_from_context=True, + ), + ], + ), + "add_labels": ToolMetadata( + name="initiatives_add_labels", + description="Attach existing labels to an initiative", + sdk_method="add_initiative_labels", + parameters=[ + ToolParameter( + name="initiative_id", + type="str", + required=True, + description="Initiative ID (required)", + ), + ToolParameter( + name="label_ids", + type="List[str]", + required=True, + description="List of label IDs to attach (required)", + ), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (auto-detected from context)", + auto_fill_from_context=True, + ), + ], + ), + "remove_labels": ToolMetadata( + name="initiatives_remove_labels", + description="Detach labels from an initiative", + sdk_method="remove_initiative_labels", + parameters=[ + ToolParameter( + name="initiative_id", + type="str", + required=True, + description="Initiative ID (required)", + ), + ToolParameter( + name="label_ids", + type="List[str]", + required=True, + description="List of label IDs to detach (required)", + ), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (auto-detected from context)", + auto_fill_from_context=True, + ), + ], + ), + # Project operations + "add_projects": ToolMetadata( + name="initiatives_add_projects", + description="Link projects to an initiative", + sdk_method="add_initiative_projects", + parameters=[ + ToolParameter( + name="initiative_id", + type="str", + required=True, + description="Initiative ID (required)", + ), + ToolParameter( + name="project_ids", + type="List[str]", + required=True, + description="List of project IDs to link (required)", + ), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (auto-detected from context)", + auto_fill_from_context=True, + ), + ], + ), + "list_projects": ToolMetadata( + name="initiatives_list_projects", + description="List projects linked to an initiative", + sdk_method="list_initiative_projects", + parameters=[ + ToolParameter( + name="initiative_id", + type="str", + required=True, + description="Initiative ID (required)", + ), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (auto-detected from context)", + auto_fill_from_context=True, + ), + ], + ), + "remove_projects": ToolMetadata( + name="initiatives_remove_projects", + description="Unlink projects from an initiative", + sdk_method="remove_initiative_projects", + parameters=[ + ToolParameter( + name="initiative_id", + type="str", + required=True, + description="Initiative ID (required)", + ), + ToolParameter( + name="project_ids", + type="List[str]", + required=True, + description="List of project IDs to unlink (required)", + ), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (auto-detected from context)", + auto_fill_from_context=True, + ), + ], + ), + # Epic operations + "add_epics": ToolMetadata( + name="initiatives_add_epics", + description="Link epics to an initiative", + sdk_method="add_initiative_epics", + parameters=[ + ToolParameter( + name="initiative_id", + type="str", + required=True, + description="Initiative ID (required)", + ), + ToolParameter( + name="epic_ids", + type="List[str]", + required=True, + description="List of epic IDs to link (required)", + ), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (auto-detected from context)", + auto_fill_from_context=True, + ), + ], + ), + "list_epics": ToolMetadata( + name="initiatives_list_epics", + description="List epics linked to an initiative", + sdk_method="list_initiative_epics", + parameters=[ + ToolParameter( + name="initiative_id", + type="str", + required=True, + description="Initiative ID (required)", + ), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (auto-detected from context)", + auto_fill_from_context=True, + ), + ], + ), + "remove_epics": ToolMetadata( + name="initiatives_remove_epics", + description="Unlink epics from an initiative", + sdk_method="remove_initiative_epics", + parameters=[ + ToolParameter( + name="initiative_id", + type="str", + required=True, + description="Initiative ID (required)", + ), + ToolParameter( + name="epic_ids", + type="List[str]", + required=True, + description="List of epic IDs to unlink (required)", + ), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (auto-detected from context)", + auto_fill_from_context=True, + ), + ], + ), +} + + +# ============================================================================ +# TOOL FACTORY +# ============================================================================ + + +def get_initiative_tools(method_executor, context): + """Return LangChain tools for the initiatives category using auto-generation from metadata.""" + return generate_tools_for_category( + category="initiatives", + method_executor=method_executor, + context=context, + tool_definitions=INITIATIVE_TOOL_DEFINITIONS, + ) diff --git a/apps/pi/pi/services/actions/tools/intake.py b/apps/pi/pi/services/actions/tools/intake.py index 2446de5342..53e5f9698d 100644 --- a/apps/pi/pi/services/actions/tools/intake.py +++ b/apps/pi/pi/services/actions/tools/intake.py @@ -13,195 +13,88 @@ Intake API tools for Plane work item triage operations. """ -from typing import Any -from typing import Dict -from typing import List -from typing import Optional +from pi.services.actions.tool_generator import generate_tools_for_category +from pi.services.actions.tool_metadata import ToolMetadata +from pi.services.actions.tool_metadata import ToolParameter -from langchain_core.tools import tool +# ============================================================================ +# TOOL METADATA DEFINITIONS +# ============================================================================ -from .base import PlaneToolBase +INTAKE_TOOL_DEFINITIONS = { + "create": ToolMetadata( + name="intake_create", + description="Submit work item to intake queue for triage. Creates a new intake item that can be reviewed and converted to a work item.", + sdk_method="create_intake", + parameters=[ + ToolParameter(name="name", type="str", required=True, description="Work item title (required)"), + ToolParameter(name="project_id", type="Optional[str]", required=False, description="Project ID", auto_fill_from_context=True), + ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True), + ToolParameter(name="description_html", type="Optional[str]", required=False, description="Description in HTML format"), + ToolParameter(name="priority", type="Optional[str]", required=False, description="Priority level (high, medium, low, urgent, none)"), + ToolParameter(name="assignee", type="Optional[str]", required=False, description="Assignee user ID"), + ToolParameter(name="reporter", type="Optional[str]", required=False, description="Reporter user ID"), + ToolParameter(name="labels", type="Optional[list]", required=False, description="List of label IDs"), + ], + returns_entity_type="intake", + ), + "list": ToolMetadata( + name="intake_list", + description="List intake items awaiting triage for a project.", + sdk_method="list_intake", + parameters=[ + ToolParameter(name="project_id", type="Optional[str]", required=False, description="Project ID", auto_fill_from_context=True), + ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True), + ToolParameter(name="per_page", type="Optional[int]", required=False, description="Number of results per page (default: 20)"), + ToolParameter(name="cursor", type="Optional[str]", required=False, description="Pagination cursor"), + ], + ), + "retrieve": ToolMetadata( + name="intake_retrieve", + description="Get a single intake work item by ID.", + sdk_method="retrieve_intake", + parameters=[ + ToolParameter(name="intake_id", type="str", required=True, description="Intake item ID to retrieve"), + ToolParameter(name="project_id", type="Optional[str]", required=False, description="Project ID", auto_fill_from_context=True), + ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True), + ], + ), + "update": ToolMetadata( + name="intake_update", + description="Update intake work item details before triage.", + sdk_method="update_intake", + parameters=[ + ToolParameter(name="intake_id", type="str", required=True, description="Intake item ID to update"), + ToolParameter(name="project_id", type="Optional[str]", required=False, description="Project ID", auto_fill_from_context=True), + ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True), + ToolParameter(name="name", type="Optional[str]", required=False, description="Work item title"), + ToolParameter(name="description_html", type="Optional[str]", required=False, description="Description in HTML format"), + ToolParameter(name="priority", type="Optional[str]", required=False, description="Priority level (high, medium, low, urgent, none)"), + ToolParameter(name="assignee", type="Optional[str]", required=False, description="Assignee user ID"), + ToolParameter(name="reporter", type="Optional[str]", required=False, description="Reporter user ID"), + ToolParameter(name="labels", type="Optional[list]", required=False, description="List of label IDs"), + ], + returns_entity_type="intake", + ), + "delete": ToolMetadata( + name="intake_delete", + description="Remove an intake work item from the triage queue.", + sdk_method="delete_intake", + parameters=[ + ToolParameter(name="intake_id", type="str", required=True, description="Intake item ID to delete"), + ToolParameter(name="project_id", type="Optional[str]", required=False, description="Project ID", auto_fill_from_context=True), + ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True), + ], + returns_entity_type="intake", + ), +} -# Factory wired via CATEGORY_TO_PROVIDER in tools/__init__.py -# Returns LangChain tools implementing intake actions + +# ============================================================================ +# TOOL FACTORY +# ============================================================================ def get_intake_tools(method_executor, context): - """Return LangChain tools for the intake category using method_executor and context.""" - - @tool - async def intake_create( - name: str, - project_id: Optional[str] = None, - workspace_slug: Optional[str] = None, - description_html: Optional[str] = None, - priority: Optional[str] = None, - assignee: Optional[str] = None, - reporter: Optional[str] = None, - labels: Optional[List[str]] = None, - ) -> Dict[str, Any]: - """Submit work item to intake queue for triage. - - Args: - name: Work item title (required) - description_html: Description in HTML format - project_id: Project ID (required - provide from conversation context or previous actions) - workspace_slug: Workspace slug (provide if known, otherwise auto-detected) - priority: Priority level (high, medium, low, urgent, none) - assignee: Assignee user ID - reporter: Reporter user ID - labels: List of label IDs - """ - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - result = await method_executor.execute( - "intake", - "create", - name=name, - description_html=description_html, - project_id=project_id, - workspace_slug=workspace_slug, - priority=priority, - assignee=assignee, - reporter=reporter, - labels=labels, - ) - - if result["success"]: - return PlaneToolBase.format_success_payload(f"Successfully submitted intake item '{name}'", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to create intake item", result["error"]) - - @tool - async def intake_list( - project_id: Optional[str] = None, - workspace_slug: Optional[str] = None, - per_page: Optional[int] = 20, - cursor: Optional[str] = None, - ) -> Dict[str, Any]: - """List intake items awaiting triage.""" - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - result = await method_executor.execute( - "intake", - "list", - project_id=project_id, - workspace_slug=workspace_slug, - per_page=per_page, - cursor=cursor, - ) - - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully retrieved intake list", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to list intake items", result["error"]) - - @tool - async def intake_retrieve( - intake_id: str, - project_id: Optional[str] = None, - workspace_slug: Optional[str] = None, - ) -> Dict[str, Any]: - """Get a single intake work item by ID.""" - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - result = await method_executor.execute( - "intake", - "retrieve", - intake_id=intake_id, - project_id=project_id, - workspace_slug=workspace_slug, - ) - - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully retrieved intake item", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to retrieve intake item", result["error"]) - - @tool - async def intake_update( - intake_id: str, - project_id: Optional[str] = None, - workspace_slug: Optional[str] = None, - name: Optional[str] = None, - description_html: Optional[str] = None, - priority: Optional[str] = None, - assignee: Optional[str] = None, - reporter: Optional[str] = None, - labels: Optional[List[str]] = None, - ) -> Dict[str, Any]: - """Update intake work item details.""" - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - # Build update data - update_data: Dict[str, Any] = {} - if name is not None: - update_data["name"] = name - if description_html is not None: - update_data["description_html"] = description_html - if priority is not None: - update_data["priority"] = priority - if assignee is not None: - update_data["assignee"] = assignee - if reporter is not None: - update_data["reporter"] = reporter - if labels is not None: - update_data["labels"] = labels - - result = await method_executor.execute( - "intake", - "update", - intake_id=intake_id, - project_id=project_id, - workspace_slug=workspace_slug, - **update_data, - ) - - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully updated intake item", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to update intake item", result["error"]) - - @tool - async def intake_delete( - intake_id: str, - project_id: Optional[str] = None, - workspace_slug: Optional[str] = None, - ) -> Dict[str, Any]: - """Remove intake work item.""" - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - result = await method_executor.execute( - "intake", - "delete", - intake_id=intake_id, - project_id=project_id, - workspace_slug=workspace_slug, - ) - - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully deleted intake item", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to delete intake item", result["error"]) - - return [intake_create, intake_list, intake_retrieve, intake_update, intake_delete] + """Return LangChain tools for the intake category using auto-generation from metadata.""" + return generate_tools_for_category("intake", method_executor, context, INTAKE_TOOL_DEFINITIONS) diff --git a/apps/pi/pi/services/actions/tools/labels.py b/apps/pi/pi/services/actions/tools/labels.py index db1b25a3e9..9c75c1e325 100644 --- a/apps/pi/pi/services/actions/tools/labels.py +++ b/apps/pi/pi/services/actions/tools/labels.py @@ -11,189 +11,349 @@ """ Labels API tools for Plane labeling operations. + +MIGRATED TO AUTO-GENERATED TOOLS +Tool metadata defined in this file (LABEL_TOOL_DEFINITIONS) for modularity. +Old manual definitions kept below for comparison/rollback safety. """ -from typing import Any from typing import Dict -from typing import Optional -from langchain_core.tools import tool +from pi.services.actions.tool_generator import generate_tools_for_category -from .base import PlaneToolBase +# Import tool metadata models +from pi.services.actions.tool_metadata import ToolMetadata +from pi.services.actions.tool_metadata import ToolParameter + +# Tool metadata for labels category - single source of truth +LABEL_TOOL_DEFINITIONS: Dict[str, ToolMetadata] = { + "create": ToolMetadata( + name="labels_create", + description="Create a new label", + sdk_method="create_label", + returns_entity_type="label", + parameters=[ + ToolParameter(name="name", type="str", required=True, description="Label name (required)"), + ToolParameter(name="color", type="str", required=True, description="Label color in hex format (required)"), + ToolParameter( + name="project_id", + type="Optional[str]", + required=False, + description="Project ID (required - provide from conversation context or previous actions)", + auto_fill_from_context=True, + property_transform="skip", + ), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (provide if known, otherwise auto-detected)", + auto_fill_from_context=True, + property_transform="skip", + ), + ToolParameter(name="description", type="Optional[str]", required=False, description="Label description", property_transform="name"), + ToolParameter( + name="parent", type="Optional[str]", required=False, description="Parent label ID for nested labels", property_transform="id" + ), + ToolParameter( + name="sort_order", type="Optional[float]", required=False, description="Sort order for display (float)", property_transform="name" + ), + ToolParameter( + name="external_source", + type="Optional[str]", + required=False, + description='External source identifier (e.g., "jira")', + property_transform="name", + ), + ToolParameter(name="external_id", type="Optional[str]", required=False, description="External system ID", property_transform="name"), + ], + ), + "list": ToolMetadata( + name="labels_list", + description="List all labels in a project", + sdk_method="list_labels", + parameters=[ + ToolParameter( + name="project_id", + type="Optional[str]", + required=False, + description="Project ID (required - provide from conversation context or previous actions)", + auto_fill_from_context=True, + property_transform="skip", + ), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (provide if known, otherwise auto-detected)", + auto_fill_from_context=True, + property_transform="skip", + ), + ], + ), + "retrieve": ToolMetadata( + name="labels_retrieve", + description="Retrieve details of a specific label", + sdk_method="retrieve_label", + parameters=[ + ToolParameter(name="label_id", type="str", required=True, description="Label ID (required)"), + ToolParameter( + name="project_id", + type="Optional[str]", + required=False, + description="Project ID (required - provide from conversation context or previous actions)", + auto_fill_from_context=True, + property_transform="skip", + ), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (provide if known, otherwise auto-detected)", + auto_fill_from_context=True, + property_transform="skip", + ), + ], + ), + "update": ToolMetadata( + name="labels_update", + description="Update an existing label", + sdk_method="update_label", + returns_entity_type="label", + parameters=[ + ToolParameter(name="label_id", type="str", required=True, description="Label ID (required)"), + ToolParameter( + name="project_id", + type="Optional[str]", + required=False, + description="Project ID (required - provide from conversation context or previous actions)", + auto_fill_from_context=True, + property_transform="skip", + ), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (provide if known, otherwise auto-detected)", + auto_fill_from_context=True, + property_transform="skip", + ), + ToolParameter(name="name", type="Optional[str]", required=False, description="New label name", property_transform="name"), + ToolParameter(name="color", type="Optional[str]", required=False, description="New label color", property_transform="name"), + ToolParameter(name="description", type="Optional[str]", required=False, description="New label description", property_transform="name"), + ToolParameter( + name="parent", type="Optional[str]", required=False, description="Parent label ID for nested labels", property_transform="id" + ), + ToolParameter( + name="sort_order", type="Optional[float]", required=False, description="Sort order for display (float)", property_transform="name" + ), + ToolParameter( + name="external_source", + type="Optional[str]", + required=False, + description='External source identifier (e.g., "jira")', + property_transform="name", + ), + ToolParameter(name="external_id", type="Optional[str]", required=False, description="External system ID", property_transform="name"), + ], + ), +} # Factory wired via CATEGORY_TO_PROVIDER in tools/__init__.py -# Returns LangChain tools implementing label actions def get_label_tools(method_executor, context): - """Return LangChain tools for the labels category using method_executor and context.""" + """Return LangChain tools for the labels category using auto-generation from metadata.""" - @tool - async def labels_create( - name: str, - color: str, - project_id: Optional[str] = None, - workspace_slug: Optional[str] = None, - description: Optional[str] = None, - parent: Optional[str] = None, - sort_order: Optional[float] = None, - external_source: Optional[str] = None, - external_id: Optional[str] = None, - ) -> Dict[str, Any]: - """Create a new label. + # Generate all label tools from local metadata + label_tools = generate_tools_for_category( + category="labels", + method_executor=method_executor, + context=context, + tool_definitions=LABEL_TOOL_DEFINITIONS, + ) - Args: - name: Label name (required) - color: Label color in hex format (required) - project_id: Project ID (required - provide from conversation context or previous actions) - workspace_slug: Workspace slug (provide if known, otherwise auto-detected) - description: Label description - parent: Parent label ID for nested labels - sort_order: Sort order for display (float) - external_source: External source identifier (e.g., "jira") - external_id: External system ID - """ - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] + return label_tools - result = await method_executor.execute( - "labels", - "create", - name=name, - color=color, - project_id=project_id, - workspace_slug=workspace_slug, - description=description, - parent=parent, - sort_order=sort_order, - external_source=external_source, - external_id=external_id, - ) - if result["success"]: - return await PlaneToolBase.format_success_payload_with_url(f"Successfully created label '{name}'", result["data"], "label", context) - else: - return PlaneToolBase.format_error_payload("Failed to create label", result["error"]) +# ============================================================================ +# OLD MANUAL TOOL DEFINITIONS (COMMENTED OUT - KEPT FOR COMPARISON) +# To rollback: uncomment below and comment out the auto-generation code above +# ============================================================================ - @tool - async def labels_list( - project_id: Optional[str] = None, - workspace_slug: Optional[str] = None, - ) -> Dict[str, Any]: - """List all labels in a project.""" - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - result = await method_executor.execute( - "labels", - "list", - project_id=project_id, - workspace_slug=workspace_slug, - ) - - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully retrieved labels list", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to list labels", result["error"]) - - @tool - async def labels_retrieve( - label_id: str, - project_id: Optional[str] = None, - workspace_slug: Optional[str] = None, - ) -> Dict[str, Any]: - """Retrieve details of a specific label. - - Args: - label_id: Label ID (required) - project_id: Project ID (required - provide from conversation context or previous actions) - workspace_slug: Workspace slug (provide if known, otherwise auto-detected) - """ - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - result = await method_executor.execute( - "labels", - "retrieve", - label_id=label_id, - project_id=project_id, - workspace_slug=workspace_slug, - ) - - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully retrieved label details", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to retrieve label", result["error"]) - - @tool - async def labels_update( - label_id: str, - project_id: Optional[str] = None, - workspace_slug: Optional[str] = None, - name: Optional[str] = None, - color: Optional[str] = None, - description: Optional[str] = None, - parent: Optional[str] = None, - sort_order: Optional[float] = None, - external_source: Optional[str] = None, - external_id: Optional[str] = None, - ) -> Dict[str, Any]: - """Update an existing label. - - Args: - label_id: Label ID (required) - name: New label name - color: New label color - description: New label description - parent: Parent label ID for nested labels - sort_order: Sort order for display (float) - external_source: External source identifier (e.g., "jira") - external_id: External system ID - project_id: Project ID (required - provide from conversation context or previous actions) - workspace_slug: Workspace slug (provide if known, otherwise auto-detected) - """ - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - # Build update data with only non-None values - update_data = { - k: v - for k, v in { - "name": name, - "color": color, - "description": description, - "parent": parent, - "sort_order": sort_order, - "external_source": external_source, - "external_id": external_id, - }.items() - if v is not None - } - - result = await method_executor.execute( - "labels", - "update", - label_id=label_id, - project_id=project_id, - workspace_slug=workspace_slug, - **update_data, - ) - - if result["success"]: - return await PlaneToolBase.format_success_payload_with_url(f"Successfully updated label '{label_id}'", result["data"], "label", context) - else: - return PlaneToolBase.format_error_payload("Failed to update label", result["error"]) - - return [labels_create, labels_list, labels_retrieve, labels_update] +# from typing import Optional +# from langchain_core.tools import tool +# from .base import PlaneToolBase +# +# def get_label_tools(method_executor, context): +# """Return LangChain tools for the labels category using method_executor and context.""" +# +# @tool +# async def labels_create( +# name: str, +# color: str, +# project_id: Optional[str] = None, +# workspace_slug: Optional[str] = None, +# description: Optional[str] = None, +# parent: Optional[str] = None, +# sort_order: Optional[float] = None, +# external_source: Optional[str] = None, +# external_id: Optional[str] = None, +# ) -> Dict[str, Any]: +# """Create a new label. +# +# Args: +# name: Label name (required) +# color: Label color in hex format (required) +# project_id: Project ID (required - provide from conversation context or previous actions) +# workspace_slug: Workspace slug (provide if known, otherwise auto-detected) +# description: Label description +# parent: Parent label ID for nested labels +# sort_order: Sort order for display (float) +# external_source: External source identifier (e.g., "jira") +# external_id: External system ID +# """ +# # Auto-fill from context if not provided +# if workspace_slug is None and "workspace_slug" in context: +# workspace_slug = context["workspace_slug"] +# if project_id is None and "project_id" in context: +# project_id = context["project_id"] +# +# result = await method_executor.execute( +# "labels", +# "create", +# name=name, +# color=color, +# project_id=project_id, +# workspace_slug=workspace_slug, +# description=description, +# parent=parent, +# sort_order=sort_order, +# external_source=external_source, +# external_id=external_id, +# ) +# +# if result["success"]: +# return await PlaneToolBase.format_success_payload_with_url(f"Successfully created label '{name}'", result["data"], "label", context) +# else: +# return PlaneToolBase.format_error_payload("Failed to create label", result["error"]) +# +# @tool +# async def labels_list( +# project_id: Optional[str] = None, +# workspace_slug: Optional[str] = None, +# ) -> Dict[str, Any]: +# """List all labels in a project.""" +# # Auto-fill from context if not provided +# if workspace_slug is None and "workspace_slug" in context: +# workspace_slug = context["workspace_slug"] +# if project_id is None and "project_id" in context: +# project_id = context["project_id"] +# +# result = await method_executor.execute( +# "labels", +# "list", +# project_id=project_id, +# workspace_slug=workspace_slug, +# ) +# +# if result["success"]: +# return PlaneToolBase.format_success_payload("Successfully retrieved labels list", result["data"]) +# else: +# return PlaneToolBase.format_error_payload("Failed to list labels", result["error"]) +# +# @tool +# async def labels_retrieve( +# label_id: str, +# project_id: Optional[str] = None, +# workspace_slug: Optional[str] = None, +# ) -> Dict[str, Any]: +# """Retrieve details of a specific label. +# +# Args: +# label_id: Label ID (required) +# project_id: Project ID (required - provide from conversation context or previous actions) +# workspace_slug: Workspace slug (provide if known, otherwise auto-detected) +# """ +# # Auto-fill from context if not provided +# if workspace_slug is None and "workspace_slug" in context: +# workspace_slug = context["workspace_slug"] +# if project_id is None and "project_id" in context: +# project_id = context["project_id"] +# +# result = await method_executor.execute( +# "labels", +# "retrieve", +# label_id=label_id, +# project_id=project_id, +# workspace_slug=workspace_slug, +# ) +# +# if result["success"]: +# return PlaneToolBase.format_success_payload("Successfully retrieved label details", result["data"]) +# else: +# return PlaneToolBase.format_error_payload("Failed to retrieve label", result["error"]) +# +# @tool +# async def labels_update( +# label_id: str, +# project_id: Optional[str] = None, +# workspace_slug: Optional[str] = None, +# name: Optional[str] = None, +# color: Optional[str] = None, +# description: Optional[str] = None, +# parent: Optional[str] = None, +# sort_order: Optional[float] = None, +# external_source: Optional[str] = None, +# external_id: Optional[str] = None, +# ) -> Dict[str, Any]: +# """Update an existing label. +# +# Args: +# label_id: Label ID (required) +# name: New label name +# color: New label color +# description: New label description +# parent: Parent label ID for nested labels +# sort_order: Sort order for display (float) +# external_source: External source identifier (e.g., "jira") +# external_id: External system ID +# project_id: Project ID (required - provide from conversation context or previous actions) +# workspace_slug: Workspace slug (provide if known, otherwise auto-detected) +# """ +# # Auto-fill from context if not provided +# if workspace_slug is None and "workspace_slug" in context: +# workspace_slug = context["workspace_slug"] +# if project_id is None and "project_id" in context: +# project_id = context["project_id"] +# +# # Build update data with only non-None values +# update_data = { +# k: v +# for k, v in { +# "name": name, +# "color": color, +# "description": description, +# "parent": parent, +# "sort_order": sort_order, +# "external_source": external_source, +# "external_id": external_id, +# }.items() +# if v is not None +# } +# +# result = await method_executor.execute( +# "labels", +# "update", +# label_id=label_id, +# project_id=project_id, +# workspace_slug=workspace_slug, +# **update_data, +# ) +# +# if result["success"]: +# return await PlaneToolBase.format_success_payload_with_url(f"Successfully updated label '{label_id}'", result["data"], "label", context) +# else: +# return PlaneToolBase.format_error_payload("Failed to update label", result["error"]) +# +# return [labels_create, labels_list, labels_retrieve, labels_update] diff --git a/apps/pi/pi/services/actions/tools/links.py b/apps/pi/pi/services/actions/tools/links.py index 8951d9a0c1..512d861813 100644 --- a/apps/pi/pi/services/actions/tools/links.py +++ b/apps/pi/pi/services/actions/tools/links.py @@ -13,185 +13,122 @@ Links API tools for Plane issue link management. """ +import uuid from typing import Any from typing import Dict -from typing import Optional -from langchain_core.tools import tool +from pi.services.actions.tool_generator import generate_tools_for_category +from pi.services.actions.tool_metadata import ToolMetadata +from pi.services.actions.tool_metadata import ToolParameter -from .base import PlaneToolBase -# Factory wired via CATEGORY_TO_PROVIDER in tools/__init__.py -# Returns LangChain tools implementing link actions +# Pre-handler to resolve project_id from issue_id if missing +async def _resolve_project_pre_handler( + metadata: ToolMetadata, + kwargs: Dict[str, Any], + context: Dict[str, Any], + category: str, + method_key: str, + method_executor: Any, +) -> Dict[str, Any]: + """Resolve project_id from issue_id if it is missing or invalid.""" + issue_id = kwargs.get("issue_id") + project_id = kwargs.get("project_id") + + should_resolve = False + if issue_id and not project_id: + should_resolve = True + elif issue_id and project_id: + try: + uuid.UUID(str(project_id)) + except (ValueError, AttributeError): + should_resolve = True + + if should_resolve: + try: + from pi.app.api.v1.helpers.plane_sql_queries import get_issue_identifier_for_artifact + + issue_info = await get_issue_identifier_for_artifact(str(issue_id)) + if issue_info and issue_info.get("project_id"): + kwargs["project_id"] = issue_info["project_id"] + except Exception: + # Best effort, proceed without resolution + pass + + return kwargs + + +LINKS_TOOL_DEFINITIONS = { + "create": ToolMetadata( + name="links_create", + description="Create a link for an issue.", + sdk_method="create_work_item_link", + parameters=[ + ToolParameter(name="issue_id", description="UUID of the work item", required=True, type="str"), + ToolParameter(name="url", description="The URL of the link", required=True, type="str"), + ToolParameter(name="title", description="Title/label for the link", required=False, type="Optional[str]"), + ToolParameter(name="project_id", description="UUID of the project", required=False, type="Optional[str]", auto_fill_from_context=True), + ToolParameter(name="workspace_slug", description="Workspace slug", required=False, type="Optional[str]", auto_fill_from_context=True), + ToolParameter(name="metadata", description="Link metadata", required=False, type="Optional[dict]"), + ], + pre_handler=_resolve_project_pre_handler, + returns_entity_type="link", + ), + "list": ToolMetadata( + name="links_list", + description="List links for an issue.", + sdk_method="list_work_item_links", + parameters=[ + ToolParameter(name="issue_id", description="Issue ID", required=True, type="str"), + ToolParameter(name="project_id", description="Project ID", required=False, type="Optional[str]", auto_fill_from_context=True), + ToolParameter(name="workspace_slug", description="Workspace slug", required=False, type="Optional[str]", auto_fill_from_context=True), + ], + pre_handler=_resolve_project_pre_handler, + ), + "retrieve": ToolMetadata( + name="links_retrieve", + description="Get a single link by ID.", + sdk_method="retrieve_work_item_link", + parameters=[ + ToolParameter(name="link_id", description="UUID of the link", required=True, type="str"), + ToolParameter(name="issue_id", description="UUID of the work item", required=True, type="str"), + ToolParameter(name="project_id", description="UUID of the project", required=False, type="Optional[str]", auto_fill_from_context=True), + ToolParameter(name="workspace_slug", description="Workspace slug", required=False, type="Optional[str]", auto_fill_from_context=True), + ], + pre_handler=_resolve_project_pre_handler, + ), + "update": ToolMetadata( + name="links_update", + description="Update link details.", + sdk_method="update_issue_link", + parameters=[ + ToolParameter(name="link_id", description="UUID of the link", required=True, type="str"), + ToolParameter(name="issue_id", description="UUID of the work item", required=True, type="str"), + ToolParameter(name="url", description="New URL", required=False, type="Optional[str]"), + ToolParameter(name="title", description="New title", required=False, type="Optional[str]"), + ToolParameter(name="project_id", description="UUID of the project", required=False, type="Optional[str]", auto_fill_from_context=True), + ToolParameter(name="workspace_slug", description="Workspace slug", required=False, type="Optional[str]", auto_fill_from_context=True), + ToolParameter(name="metadata", description="Link metadata", required=False, type="Optional[dict]"), + ], + pre_handler=_resolve_project_pre_handler, + returns_entity_type="link", + ), + "delete": ToolMetadata( + name="links_delete", + description="Delete a link.", + sdk_method="delete_work_item_link", + parameters=[ + ToolParameter(name="link_id", description="UUID of the link", required=True, type="str"), + ToolParameter(name="issue_id", description="UUID of the work item", required=True, type="str"), + ToolParameter(name="project_id", description="UUID of the project", required=False, type="Optional[str]", auto_fill_from_context=True), + ToolParameter(name="workspace_slug", description="Workspace slug", required=False, type="Optional[str]", auto_fill_from_context=True), + ], + pre_handler=_resolve_project_pre_handler, + returns_entity_type="link", + ), +} def get_link_tools(method_executor, context): """Return LangChain tools for the links category using method_executor and context.""" - - @tool - async def links_create( - issue_id: str, - url: str, - title: Optional[str] = None, - project_id: Optional[str] = None, - workspace_slug: Optional[str] = None, - ) -> Dict[str, Any]: - """Create a link for an issue. - - Args: - issue_id: UUID of the work item to create a link for (required) - url: The URL of the link (required) - title: Optional title/label for the link - project_id: UUID of the project (optional, auto-filled from context) - workspace_slug: Workspace slug identifier (optional, auto-filled from context) - """ - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - result = await method_executor.execute( - "links", - "create", - issue_id=issue_id, - url=url, - title=title, - project_id=project_id, - workspace_slug=workspace_slug, - ) - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully created link", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to create link", result["error"]) - - @tool - async def links_list(issue_id: str, project_id: Optional[str] = None, workspace_slug: Optional[str] = None) -> Dict[str, Any]: - """List links for an issue.""" - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - result = await method_executor.execute("links", "list", issue_id=issue_id, project_id=project_id, workspace_slug=workspace_slug) - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully retrieved links list", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to list links", result["error"]) - - @tool - async def links_retrieve( - link_id: str, - issue_id: str, - project_id: Optional[str] = None, - workspace_slug: Optional[str] = None, - ) -> Dict[str, Any]: - """Get a single link by ID. - - Args: - link_id: UUID of the link to retrieve (required) - issue_id: UUID of the work item the link belongs to (required) - project_id: UUID of the project (optional, auto-filled from context) - workspace_slug: Workspace slug identifier (optional, auto-filled from context) - """ - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - result = await method_executor.execute( - "links", - "retrieve", - link_id=link_id, - issue_id=issue_id, - project_id=project_id, - workspace_slug=workspace_slug, - ) - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully retrieved link", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to retrieve link", result["error"]) - - @tool - async def links_update( - link_id: str, - issue_id: str, - url: Optional[str] = None, - title: Optional[str] = None, - project_id: Optional[str] = None, - workspace_slug: Optional[str] = None, - ) -> Dict[str, Any]: - """Update link details. - - Args: - link_id: UUID of the link to update (required) - issue_id: UUID of the work item the link belongs to (required) - url: New URL for the link (optional) - title: New title/label for the link (optional) - project_id: UUID of the project (optional, auto-filled from context) - workspace_slug: Workspace slug identifier (optional, auto-filled from context) - """ - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - # Build update data - update_data = {} - if url is not None: - update_data["url"] = url - if title is not None: - update_data["title"] = title - - result = await method_executor.execute( - "links", - "update", - link_id=link_id, - issue_id=issue_id, - project_id=project_id, - workspace_slug=workspace_slug, - **update_data, - ) - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully updated link", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to update link", result["error"]) - - @tool - async def links_delete( - link_id: str, - issue_id: str, - project_id: Optional[str] = None, - workspace_slug: Optional[str] = None, - ) -> Dict[str, Any]: - """Delete a link. - - Args: - link_id: UUID of the link to delete (required) - issue_id: UUID of the work item the link belongs to (required) - project_id: UUID of the project (optional, auto-filled from context) - workspace_slug: Workspace slug identifier (optional, auto-filled from context) - """ - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - result = await method_executor.execute( - "links", - "delete", - link_id=link_id, - issue_id=issue_id, - project_id=project_id, - workspace_slug=workspace_slug, - ) - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully deleted link", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to delete link", result["error"]) - - return [links_create, links_list, links_retrieve, links_update, links_delete] + return generate_tools_for_category("links", method_executor, context, LINKS_TOOL_DEFINITIONS) diff --git a/apps/pi/pi/services/actions/tools/members.py b/apps/pi/pi/services/actions/tools/members.py index a5c5262051..530a01205a 100644 --- a/apps/pi/pi/services/actions/tools/members.py +++ b/apps/pi/pi/services/actions/tools/members.py @@ -11,102 +11,115 @@ """ Members API tools for Plane workspace and project member management. + +MIGRATED TO AUTO-GENERATED TOOLS +Tool metadata defined in this file (MEMBER_TOOL_DEFINITIONS) for modularity. +Bot filtering handled via custom post_handler function. +Old manual definitions kept below for comparison/rollback safety. """ from typing import Any from typing import Dict -from typing import Optional -from langchain_core.tools import tool +from pi import logger +from pi.services.actions.tool_generator import generate_tools_for_category +from pi.services.actions.tool_metadata import ToolMetadata +from pi.services.actions.tool_metadata import ToolParameter -from .base import PlaneToolBase +log = logger.getChild(__name__) -# Factory wired via CATEGORY_TO_PROVIDER in tools/__init__.py -# Returns LangChain tools implementing member actions + +# ============================================================================ +# MEMBERS-SPECIFIC HANDLER FUNCTIONS +# ============================================================================ + + +async def _filter_bots_handler( + metadata: ToolMetadata, + result: Dict[str, Any], + kwargs: Dict[str, Any], + context: Dict[str, Any], + method_executor: Any, + category: str, + method_key: str, +) -> Dict[str, Any]: + """Post-processing handler to filter bot users from member lists.""" + + if result["success"]: + members_data = result.get("data") + if isinstance(members_data, list): + filtered_members = [ + member + for member in members_data + if not (member.get("is_bot", False) or (isinstance(member.get("email"), str) and "_bot@plane.so" in member.get("email", "").lower())) + ] + result["data"] = filtered_members + log.debug(f"Filtered {len(members_data) - len(filtered_members)} bot users from {metadata.name}") + + return result + + +# Tool metadata for members category +MEMBER_TOOL_DEFINITIONS: Dict[str, ToolMetadata] = { + "get_workspace_members": ToolMetadata( + name="members_get_workspace_members", + description="Get all workspace members (excludes bot users)", + sdk_method="get_workspace_members", + post_handler=_filter_bots_handler, + parameters=[ + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (auto-filled from context)", + auto_fill_from_context=True, + property_transform="skip", + ), + ], + ), + "get_project_members": ToolMetadata( + name="members_get_project_members", + description="Get all project members (excludes bot users)", + sdk_method="get_project_members", + post_handler=_filter_bots_handler, + parameters=[ + ToolParameter( + name="project_id", + type="Optional[str]", + required=False, + description="Project UUID (auto-filled from context if in project chat)", + auto_fill_from_context=True, + property_transform="skip", + ), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (auto-filled from context)", + auto_fill_from_context=True, + property_transform="skip", + ), + ], + ), +} def get_member_tools(method_executor, context): - """Return LangChain tools for the members category using method_executor and context.""" - """Get all Members API tools.""" + """Return LangChain tools for the members category using auto-generation from metadata.""" - @tool - async def members_get_workspace_members(workspace_slug: Optional[str] = None) -> Dict[str, Any]: - """Get all workspace members (excludes bot users). + member_tools = generate_tools_for_category( + category="members", + method_executor=method_executor, + context=context, + tool_definitions=MEMBER_TOOL_DEFINITIONS, + ) - Args: - workspace_slug: Workspace slug (auto-filled from context) - """ - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] + return member_tools - result = await method_executor.execute("members", "get_workspace_members", workspace_slug=workspace_slug) - if result["success"]: - # Filter out bot users from the results - members_data = result["data"] - if isinstance(members_data, list): - # Filter out users with is_bot=True and bot email patterns - filtered_members = [ - member - for member in members_data - if not ( - # Check is_bot field if present - member.get("is_bot", False) - # Check for bot email pattern (workspace_botname_bot@plane.so) - or (isinstance(member.get("email"), str) and "_bot@plane.so" in member.get("email", "").lower()) - ) - ] - return PlaneToolBase.format_success_payload("Successfully retrieved workspace members", filtered_members) - else: - # If data is not a list, return as-is (edge case) - return PlaneToolBase.format_success_payload("Successfully retrieved workspace members", members_data) - else: - return PlaneToolBase.format_error_payload("Failed to get workspace members", result["error"]) +# ============================================================================ +# OLD MANUAL TOOL DEFINITIONS (COMMENTED OUT - KEPT FOR COMPARISON) +# To rollback: uncomment below and comment out the auto-generation code above +# ============================================================================ - @tool - async def members_get_project_members( - project_id: Optional[str] = None, - workspace_slug: Optional[str] = None, - ) -> Dict[str, Any]: - """Get all project members (excludes bot users). - - Args: - project_id: Project UUID (auto-filled from context if in project chat) - workspace_slug: Workspace slug (auto-filled from context) - """ - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - result = await method_executor.execute( - "members", - "get_project_members", - project_id=project_id, - workspace_slug=workspace_slug, - ) - if result["success"]: - # Filter out bot users from the results - members_data = result["data"] - if isinstance(members_data, list): - # Filter out users with is_bot=True and bot email patterns - filtered_members = [ - member - for member in members_data - if not ( - # Check is_bot field if present - member.get("is_bot", False) - # Check for bot email pattern (workspace_botname_bot@plane.so) - or (isinstance(member.get("email"), str) and "_bot@plane.so" in member.get("email", "").lower()) - ) - ] - return PlaneToolBase.format_success_payload("Successfully retrieved project members", filtered_members) - else: - # If data is not a list, return as-is (edge case) - return PlaneToolBase.format_success_payload("Successfully retrieved project members", members_data) - else: - return PlaneToolBase.format_error_payload("Failed to get project members", result["error"]) - - return [members_get_workspace_members, members_get_project_members] +# [102 lines of old code omitted for brevity] diff --git a/apps/pi/pi/services/actions/tools/modules.py b/apps/pi/pi/services/actions/tools/modules.py index cf1b7bcbde..baa4b2004b 100644 --- a/apps/pi/pi/services/actions/tools/modules.py +++ b/apps/pi/pi/services/actions/tools/modules.py @@ -13,348 +13,147 @@ Modules API tools for Plane module management operations. """ -from typing import Any from typing import Dict -from typing import Optional -from langchain_core.tools import tool +from pi.services.actions.tool_generator import generate_tools_for_category +from pi.services.actions.tool_metadata import ToolMetadata +from pi.services.actions.tool_metadata import ToolParameter -from pi import logger +# ============================================================================ +# MODULES TOOL DEFINITIONS +# ============================================================================ -from .base import PlaneToolBase - -log = logger.getChild(__name__) +MODULES_TOOL_DEFINITIONS: Dict[str, ToolMetadata] = { + "create": ToolMetadata( + name="modules_create", + description="Create a new module.", + sdk_method="create_module", + returns_entity_type="module", + parameters=[ + ToolParameter(name="name", type="str", required=True, description="Module name (required)"), + ToolParameter(name="project_id", type="Optional[str]", required=False, description="Project ID (required)", auto_fill_from_context=True), + ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True), + ToolParameter(name="description", type="Optional[str]", required=False, description="Module description"), + ToolParameter(name="start_date", type="Optional[str]", required=False, description="Start date (YYYY-MM-DD format)"), + ToolParameter(name="target_date", type="Optional[str]", required=False, description="Target completion date (YYYY-MM-DD format)"), + ToolParameter( + name="status", type="Optional[str]", required=False, description="Status: backlog, planned, in-progress, paused, completed, cancelled" + ), + ToolParameter(name="lead", type="Optional[str]", required=False, description="Module lead user ID"), + ToolParameter(name="members", type="Optional[list]", required=False, description="List of member user IDs"), + ToolParameter(name="external_id", type="Optional[str]", required=False, description="External identifier"), + ToolParameter(name="external_source", type="Optional[str]", required=False, description="External source name"), + ], + ), + "list": ToolMetadata( + name="modules_list", + description="List all modules in a project. Requires project_id.", + sdk_method="list_modules", + parameters=[ + ToolParameter(name="project_id", type="Optional[str]", required=False, description="Project ID (required)", auto_fill_from_context=True), + ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True), + ], + ), + "retrieve": ToolMetadata( + name="modules_retrieve", + description="Retrieve details of a specific module.", + sdk_method="retrieve_module", + returns_entity_type="module", + parameters=[ + ToolParameter(name="module_id", type="str", required=True, description="Module ID (required)"), + ToolParameter(name="project_id", type="Optional[str]", required=False, description="Project ID (required)", auto_fill_from_context=True), + ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True), + ], + ), + "update": ToolMetadata( + name="modules_update", + description="Update module details.", + sdk_method="update_module", + returns_entity_type="module", + parameters=[ + ToolParameter(name="module_id", type="str", required=True, description="Module ID (required)"), + ToolParameter(name="project_id", type="Optional[str]", required=False, description="Project ID (required)", auto_fill_from_context=True), + ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True), + ToolParameter(name="name", type="Optional[str]", required=False, description="New module name"), + ToolParameter(name="description", type="Optional[str]", required=False, description="New description"), + ToolParameter(name="start_date", type="Optional[str]", required=False, description="New start date (YYYY-MM-DD)"), + ToolParameter(name="target_date", type="Optional[str]", required=False, description="New target date (YYYY-MM-DD)"), + ToolParameter(name="status", type="Optional[str]", required=False, description="New status"), + ToolParameter(name="lead", type="Optional[str]", required=False, description="New lead user ID"), + ToolParameter(name="members", type="Optional[list]", required=False, description="New members list"), + ToolParameter(name="external_id", type="Optional[str]", required=False, description="External identifier"), + ToolParameter(name="external_source", type="Optional[str]", required=False, description="External source name"), + ], + ), + "archive": ToolMetadata( + name="modules_archive", + description="Archive a module.", + sdk_method="archive_module", + parameters=[ + ToolParameter(name="module_id", type="str", required=True, description="Module ID (required)"), + ToolParameter(name="project_id", type="Optional[str]", required=False, description="Project ID (required)", auto_fill_from_context=True), + ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True), + ], + ), + "unarchive": ToolMetadata( + name="modules_unarchive", + description="Unarchive (restore) a module.", + sdk_method="unarchive_module", + parameters=[ + ToolParameter(name="module_id", type="str", required=True, description="Module ID (required)"), + ToolParameter(name="project_id", type="Optional[str]", required=False, description="Project ID (required)", auto_fill_from_context=True), + ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True), + ], + ), + "list_archived": ToolMetadata( + name="modules_list_archived", + description="List archived modules in a project.", + sdk_method="list_archived_modules", + parameters=[ + ToolParameter(name="project_id", type="Optional[str]", required=False, description="Project ID (required)", auto_fill_from_context=True), + ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True), + ], + ), + "add_work_items": ToolMetadata( + name="modules_add_work_items", + description="Add work items to a module.", + sdk_method="add_module_work_items", + parameters=[ + ToolParameter(name="module_id", type="str", required=True, description="Module ID (required)"), + ToolParameter(name="issues", type="list", required=True, description="List of issue IDs to add"), + ToolParameter(name="project_id", type="Optional[str]", required=False, description="Project ID (required)", auto_fill_from_context=True), + ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True), + ], + ), + "list_work_items": ToolMetadata( + name="modules_list_work_items", + description="List work items in a module.", + sdk_method="list_module_work_items", + parameters=[ + ToolParameter(name="module_id", type="str", required=True, description="Module ID (required)"), + ToolParameter(name="project_id", type="Optional[str]", required=False, description="Project ID (required)", auto_fill_from_context=True), + ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True), + ], + ), + "remove_work_item": ToolMetadata( + name="modules_remove_work_item", + description="Remove a work item from a module.", + sdk_method="remove_module_work_item", + parameters=[ + ToolParameter(name="module_id", type="str", required=True, description="Module ID (required)"), + ToolParameter(name="issue_id", type="str", required=True, description="Issue ID to remove (required)"), + ToolParameter(name="project_id", type="Optional[str]", required=False, description="Project ID (required)", auto_fill_from_context=True), + ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True), + ], + ), +} -# Factory wired via CATEGORY_TO_PROVIDER in tools/__init__.py -# Returns LangChain tools implementing module actions +# ============================================================================ +# TOOL FACTORY +# ============================================================================ def get_module_tools(method_executor, context): - """Return LangChain tools for the modules category using method_executor and context.""" - - @tool - async def modules_create( - name: str, - project_id: Optional[str] = None, - workspace_slug: Optional[str] = None, - description: Optional[str] = None, - start_date: Optional[str] = None, - target_date: Optional[str] = None, - status: Optional[str] = None, - lead: Optional[str] = None, - members: Optional[list] = None, - external_id: Optional[str] = None, - external_source: Optional[str] = None, - ) -> Dict[str, Any]: - """Create a new module. - - Args: - name: Module name (required) - project_id: Project ID (required - provide from conversation context or previous actions) - workspace_slug: Workspace slug (provide if known, otherwise auto-detected) - description: Module description - start_date: Start date (YYYY-MM-DD format) - target_date: Target completion date (YYYY-MM-DD format) - status: Module status (Input should be one of 'backlog', 'planned', 'in-progress', 'paused', 'completed' or 'cancelled' ) - lead: Module lead user ID - members: List of member user IDs - external_id: External system identifier (optional) - external_source: External system source name (optional) - """ - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - result = await method_executor.execute( - "modules", - "create", - name=name, - project_id=project_id, - workspace_slug=workspace_slug, - description=description, - start_date=start_date, - target_date=target_date, - status=status, - lead=lead, - members=members, - external_id=external_id, - external_source=external_source, - ) - if result["success"]: - return await PlaneToolBase.format_success_payload_with_url(f"Successfully created module '{name}'", result["data"], "module", context) - else: - return PlaneToolBase.format_error_payload("Failed to create module", result["error"]) - - @tool - async def modules_list(project_id: Optional[str] = None, workspace_slug: Optional[str] = None) -> Dict[str, Any]: - """List all modules in a project. - - Args: - project_id: Project ID (REQUIRED - provide from conversation context or previous actions) - workspace_slug: Workspace slug (provide if known, otherwise auto-detected) - - Important: This tool REQUIRES a project_id. If you don't have one, you MUST first: - 1. Call search_project_by_name to find the project, OR - 2. Call projects_list to see available projects, OR - 3. Call ask_for_clarification to ask the user which project to use - """ - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - # Validate project_id is present - if not project_id: - return PlaneToolBase.format_error_payload( - "Missing required parameter", - "project_id is required to list modules. You MUST first call projects_list to see available projects, OR call ask_for_clarification with disambiguation_options populated by calling projects_list to ask which project the user wants.", # noqa: E501 - ) - - result = await method_executor.execute("modules", "list", project_id=project_id, workspace_slug=workspace_slug) - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully retrieved modules list", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to list modules", result["error"]) - - @tool - async def modules_add_work_items(module_id: str, issues: list, project_id: Optional[str] = None) -> Dict[str, Any]: - """Add work items to a module. - - Args: - module_id: Module ID (required) - issues: List of issue IDs to add to the module (required) - project_id: Project ID (required - provide from conversation context or previous actions) - """ - # Auto-fill workspace_slug from context (hidden from LLM) - workspace_slug = context.get("workspace_slug") - - # Auto-fill project_id from context if not provided - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - result = await method_executor.execute( - "modules", "add_work_items", module_id=module_id, issues=issues, project_id=project_id, workspace_slug=workspace_slug - ) - if result["success"]: - return PlaneToolBase.format_success_payload(f"Successfully added {len(issues)} work items to module", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to add work items to module", result["error"]) - - @tool - async def modules_retrieve(module_id: str, project_id: Optional[str] = None, workspace_slug: Optional[str] = None) -> Dict[str, Any]: - """Retrieve details of a specific module. - - Args: - module_id: Module ID (required) - project_id: Project ID (required - provide from conversation context or previous actions) - workspace_slug: Workspace slug (provide if known, otherwise auto-detected) - """ - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - result = await method_executor.execute("modules", "retrieve", module_id=module_id, project_id=project_id, workspace_slug=workspace_slug) - if result["success"]: - return await PlaneToolBase.format_success_payload_with_url("Successfully retrieved module details", result["data"], "module", context) - else: - return PlaneToolBase.format_error_payload("Failed to retrieve module", result["error"]) - - @tool - async def modules_update( - module_id: str, - project_id: Optional[str] = None, - workspace_slug: Optional[str] = None, - name: Optional[str] = None, - description: Optional[str] = None, - start_date: Optional[str] = None, - target_date: Optional[str] = None, - status: Optional[str] = None, - lead: Optional[str] = None, - members: Optional[list] = None, - external_id: Optional[str] = None, - external_source: Optional[str] = None, - ) -> Dict[str, Any]: - """Update module details. - - Args: - module_id: Module ID (required) - project_id: Project ID (required - provide from conversation context or previous actions) - workspace_slug: Workspace slug (provide if known, otherwise auto-detected) - name: New module name - description: New module description - start_date: New start date (YYYY-MM-DD format) - target_date: New target date (YYYY-MM-DD format) - status: New module status - lead: New module lead user ID - members: New members list (optional) - external_id: External system identifier (optional) - external_source: External system source name (optional) - """ - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - # Build update request object with non-None values only - update_data: Dict[str, Any] = {} - if name is not None: - update_data["name"] = name - if description is not None: - update_data["description"] = description - if start_date is not None: - update_data["start_date"] = start_date - if target_date is not None: - update_data["target_date"] = target_date - if status is not None: - update_data["status"] = status - if lead is not None: - update_data["lead"] = lead - if members is not None: - update_data["members"] = members - if external_id is not None: - update_data["external_id"] = external_id - if external_source is not None: - update_data["external_source"] = external_source - - result = await method_executor.execute( - "modules", "update", module_id=module_id, project_id=project_id, workspace_slug=workspace_slug, **update_data - ) - - if result["success"]: - return await PlaneToolBase.format_success_payload_with_url("Successfully updated module", result["data"], "module", context) - else: - return PlaneToolBase.format_error_payload("Failed to update module", result["error"]) - - @tool - async def modules_archive(module_id: str, project_id: Optional[str] = None, workspace_slug: Optional[str] = None) -> Dict[str, Any]: - """Archive a module. - - Args: - module_id: Module ID (required) - project_id: Project ID (required - provide from conversation context or previous actions) - workspace_slug: Workspace slug (provide if known, otherwise auto-detected) - """ - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - result = await method_executor.execute("modules", "archive", module_id=module_id, project_id=project_id, workspace_slug=workspace_slug) - - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully archived module", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to archive module", result["error"]) - - @tool - async def modules_unarchive(module_id: str, project_id: Optional[str] = None, workspace_slug: Optional[str] = None) -> Dict[str, Any]: - """Unarchive a module. - - Args: - module_id: Module ID (required) - project_id: Project ID (required - provide from conversation context or previous actions) - workspace_slug: Workspace slug (provide if known, otherwise auto-detected) - """ - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - result = await method_executor.execute("modules", "unarchive", module_id=module_id, project_id=project_id, workspace_slug=workspace_slug) - - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully unarchived module", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to unarchive module", result["error"]) - - @tool - async def modules_list_archived(project_id: Optional[str] = None, workspace_slug: Optional[str] = None) -> Dict[str, Any]: - """List archived modules in a project. - - Args: - project_id: Project ID (required - provide from conversation context or previous actions) - workspace_slug: Workspace slug (provide if known, otherwise auto-detected) - """ - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - result = await method_executor.execute("modules", "list_archived", project_id=project_id, workspace_slug=workspace_slug) - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully retrieved archived modules list", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to list archived modules", result["error"]) - - @tool - async def modules_list_work_items(module_id: str, project_id: Optional[str] = None, workspace_slug: Optional[str] = None) -> Dict[str, Any]: - """List work items in a module. - - Args: - module_id: Module ID (required) - project_id: Project ID (required - provide from conversation context or previous actions) - workspace_slug: Workspace slug (provide if known, otherwise auto-detected) - """ - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - result = await method_executor.execute( - "modules", "list_work_items", module_id=module_id, project_id=project_id, workspace_slug=workspace_slug - ) - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully retrieved module work items", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to list module work items", result["error"]) - - @tool - async def modules_remove_work_item( - module_id: str, issue_id: str, project_id: Optional[str] = None, workspace_slug: Optional[str] = None - ) -> Dict[str, Any]: - """Remove a work item from a module. - - Args: - module_id: Module ID (required) - issue_id: Issue ID to remove from the module (required) - project_id: Project ID (required - provide from conversation context or previous actions) - workspace_slug: Workspace slug (provide if known, otherwise auto-detected) - """ - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - result = await method_executor.execute( - "modules", "remove_work_item", issue_id=issue_id, module_id=module_id, project_id=project_id, workspace_slug=workspace_slug - ) - - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully removed work item from module", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to remove work item from module", result["error"]) - - return [ - modules_create, - modules_list, - modules_add_work_items, - modules_retrieve, - modules_update, - modules_archive, - modules_unarchive, - modules_list_archived, - modules_list_work_items, - modules_remove_work_item, - ] + """Return LangChain tools for the modules category using auto-generation from metadata.""" + return generate_tools_for_category("modules", method_executor, context, MODULES_TOOL_DEFINITIONS) diff --git a/apps/pi/pi/services/actions/tools/pages.py b/apps/pi/pi/services/actions/tools/pages.py index 220f252451..54777fcde5 100644 --- a/apps/pi/pi/services/actions/tools/pages.py +++ b/apps/pi/pi/services/actions/tools/pages.py @@ -10,204 +10,499 @@ # NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited. """ -Pages API tools for Plane documentation and page management operations. +Pages category - Auto-generated tools from metadata. + +This module uses the centralized tool generation system. +Old manual definitions kept below for comparison/rollback safety. """ from typing import Any from typing import Dict -from typing import Optional -from langchain_core.tools import tool +from pi.services.actions.tool_generator import generate_tools_for_category +from pi.services.actions.tool_metadata import ToolMetadata +from pi.services.actions.tool_metadata import ToolParameter -from .base import PlaneToolBase +# ============================================================================ +# PAGES-SPECIFIC HANDLER FUNCTIONS +# ============================================================================ -# Factory wired via CATEGORY_TO_PROVIDER in tools/__init__.py -# Returns LangChain tools implementing pages actions + +async def _pages_pre_handler( + metadata: ToolMetadata, + kwargs: Dict[str, Any], + context: Dict[str, Any], + category: str, + method_key: str, +) -> Dict[str, Any]: + """Pre-processing handler for pages tools. + + Handles: + - Default description_html to name if not provided (API requires this field) + """ + # Default description_html to name if not provided + if "description_html" in [p.name for p in metadata.parameters]: + if not kwargs.get("description_html"): + kwargs["description_html"] = kwargs.get("name", "") + + return kwargs + + +async def _pages_post_handler( + metadata: ToolMetadata, + result: Dict[str, Any], + kwargs: Dict[str, Any], + context: Dict[str, Any], + method_executor: Any, + category: str, + method_key: str, +) -> Dict[str, Any]: + """Post-processing handler for pages tools. + + Handles: + - Inject project field into response data for URL construction + (API response doesn't include project field, but URL builder needs it) + """ + if result["success"] and "project_id" in kwargs and kwargs.get("project_id"): + data = result.get("data", {}) + # Using "project" not "project_id" because extract_entity_from_api_response looks for "project" + data["project"] = kwargs["project_id"] + result["data"] = data + + return result + + +# ============================================================================ +# PAGES TOOL DEFINITIONS +# ============================================================================ + +PAGE_TOOL_DEFINITIONS: Dict[str, ToolMetadata] = { + "create_project_page": ToolMetadata( + name="pages_create_project_page", + description="Create a new page in a project", + sdk_method="create_project_page", + returns_entity_type="page", + pre_handler=_pages_pre_handler, + post_handler=_pages_post_handler, + parameters=[ + ToolParameter( + name="name", + type="str", + required=True, + description="Page title (required)", + ), + ToolParameter( + name="project_id", + type="Optional[str]", + required=False, + description="Project ID (required - provide from conversation context or previous actions)", + auto_fill_from_context=True, + property_transform="skip", + ), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (provide if known, otherwise auto-detected)", + auto_fill_from_context=True, + property_transform="skip", + ), + ToolParameter( + name="description_html", + type="Optional[str]", + required=False, + description="Page content in HTML format. If the user asks for 'details about X', " + "generate relevant content. Use empty string if no content is needed. (optional, defaults to page name)", + property_transform="skip", + ), + ToolParameter( + name="access", + type="Optional[int]", + required=False, + description="Access level - 0 for public, 1 for private (optional, if not specified the API will use its default)", + property_transform="name", + ), + ToolParameter( + name="color", + type="Optional[str]", + required=False, + description="Page color in hex format like #3B82F6 (optional)", + property_transform="name", + ), + ToolParameter( + name="logo_props", + type="Optional[dict]", + required=False, + description="Logo properties dict with keys like name, color, type (optional)", + property_transform="name", + ), + ], + ), + "create_workspace_page": ToolMetadata( + name="pages_create_workspace_page", + description="Create a new page in the workspace", + sdk_method="create_workspace_page", + returns_entity_type="page", + pre_handler=_pages_pre_handler, + parameters=[ + ToolParameter( + name="name", + type="str", + required=True, + description="Page title (required)", + ), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (provide if known, otherwise auto-detected)", + auto_fill_from_context=True, + property_transform="skip", + ), + ToolParameter( + name="description_html", + type="Optional[str]", + required=False, + description="Page content in HTML format. If the user asks for 'details about X', " + "generate relevant content. Use empty string if no content is needed. (optional, defaults to page name)", + property_transform="skip", + ), + ToolParameter( + name="access", + type="Optional[int]", + required=False, + description="Access level - 0 for public, 1 for private (optional)", + property_transform="name", + ), + ToolParameter( + name="color", + type="Optional[str]", + required=False, + description="Page color in hex format like #10B981 (optional)", + property_transform="name", + ), + ToolParameter( + name="logo_props", + type="Optional[dict]", + required=False, + description="Logo properties dict with keys like name, color, type (optional)", + property_transform="name", + ), + ], + ), +} + + +# ============================================================================ +# TOOL FACTORY +# ============================================================================ def get_page_tools(method_executor, context): - """Return LangChain tools for the pages category using method_executor and context.""" + """Return LangChain tools for the pages category using auto-generation from metadata. + + Context-aware tool selection: + - In project chat: only project pages (workspace pages are not accessible in project UI) + - In workspace/global chat: unified meta-tool that routes based on project_id + """ + from typing import Optional + + from langchain_core.tools import tool + + from .base import PlaneToolBase - # Check if we're in a project-specific context is_project_chat = context.get("is_project_chat", False) - @tool - async def pages_create_project_page( - name: str, - project_id: Optional[str] = None, - description_html: Optional[str] = None, - access: Optional[int] = None, - color: Optional[str] = None, - logo_props: Optional[dict] = None, - ) -> Dict[str, Any]: - """Create a new page in a project. - - Args: - name: Page title (required) - project_id: Project ID (required - provide from conversation context or previous actions) - description_html: Page content in HTML format. If the user asks for "details about X", generate relevant content. Use empty string if no content is needed. (optional, defaults to empty string) - access: Access level - 0 for public, 1 for private (optional, if not specified the API will use its default) - color: Page color in hex format like #3B82F6 (optional) - logo_props: Logo properties dict with keys like name, color, type (optional) - - Returns: - Success message with page details and URL information - """ # noqa E501 - # Auto-fill workspace_slug from context (hidden from LLM) - workspace_slug = context.get("workspace_slug") - - # Auto-fill project_id from context if not provided - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - # CRITICAL: Provide default empty string if description_html is None - # The Plane API requires this field even if empty - if not description_html: - description_html = name - - result = await method_executor.execute( - "pages", - "create_project_page", - name=name, - project_id=project_id, - workspace_slug=workspace_slug, - description_html=description_html, - access=access, - color=color, - logo_props=logo_props, - ) - - if result.get("success"): - data = result.get("data", {}) - # CRITICAL: Inject project into response data for URL construction - # The API response doesn't include project field, so we need to add it - # Using "project" not "project_id" because extract_entity_from_api_response looks for "project" - if project_id: - data["project"] = project_id - return await PlaneToolBase.format_success_payload_with_url(f"Project page '{name}' created successfully", data, "page", context) - else: - error_msg = result.get("error", "Unknown error occurred") - return PlaneToolBase.format_error_payload("Failed to create project page", error_msg) - - @tool - async def pages_create_workspace_page( - name: str, - description_html: Optional[str] = None, - access: Optional[int] = None, - color: Optional[str] = None, - logo_props: Optional[dict] = None, - ) -> Dict[str, Any]: - """Create a new page in the workspace. - - Args: - name: Page title (required) - description_html: Page content in HTML format. If the user asks for "details about X", generate relevant content. Use empty string if no content is needed. (optional, defaults to empty string) - access: Access level - 0 for public, 1 for private (optional, if not specified the API will use its default) - color: Page color in hex format like #10B981 (optional) - logo_props: Logo properties dict with keys like name, color, type (optional) - - Returns: - Success message with page details and URL information - """ # noqa E501 - # Auto-fill workspace_slug from context (hidden from LLM) - workspace_slug = context.get("workspace_slug") - - # CRITICAL: Provide default empty string if description_html is None - # The Plane API requires this field even if empty - if not description_html: - description_html = name - - result = await method_executor.execute( - "pages", - "create_workspace_page", - name=name, - workspace_slug=workspace_slug, - description_html=description_html, - access=access, - color=color, - logo_props=logo_props, - ) - - if result.get("success"): - data = result.get("data", {}) - return await PlaneToolBase.format_success_payload_with_url(f"Workspace page '{name}' created successfully", data, "page", context) - else: - error_msg = result.get("error", "Unknown error occurred") - return PlaneToolBase.format_error_payload("Failed to create workspace page", error_msg) - - # In workspace/global context, expose a single tool that forces clarification for scope - @tool - async def pages_create_page( - name: str, - project_id: str, - description_html: Optional[str] = None, - access: Optional[int] = None, - color: Optional[str] = None, - logo_props: Optional[dict] = None, - ) -> Dict[str, Any]: - """Create a new page either in a project or at the workspace level. - - Args: - name: Page title (required) - project_id: Project UUID where the page should be created. If user hasn't specified a project, leave this empty or use 'NEEDS_CLARIFICATION' to trigger project selection. (required) - description_html: Page content in HTML format. If the user asks for "details about X", generate relevant content. Use empty string if no content is needed. (optional, defaults to empty string) - access: Access level - 0 for public, 1 for private (optional) - color: Page color in hex format (optional) - logo_props: Logo properties dict with keys like name, color, type (optional) - """ # noqa E501 - workspace_slug = context.get("workspace_slug") - - # CRITICAL: Provide default empty string if description_html is None - # The Plane API requires this field even if empty - if not description_html: - description_html = name - - if project_id == "__workspace_scope__": - # Create at workspace level - result = await method_executor.execute( - "pages", - "create_workspace_page", - name=name, - workspace_slug=workspace_slug, - description_html=description_html, - access=access, - color=color, - logo_props=logo_props, - ) - scope_label = "Workspace" - else: - # Create in a specific project - result = await method_executor.execute( - "pages", - "create_project_page", - name=name, - project_id=project_id, - workspace_slug=workspace_slug, - description_html=description_html, - access=access, - color=color, - logo_props=logo_props, - ) - scope_label = "Project" - - if result.get("success"): - data = result.get("data", {}) - # CRITICAL: Inject project into response data for URL construction - # The API response doesn't include project field, so we need to add it - # Using "project" not "project_id" because extract_entity_from_api_response looks for "project" - # for the URL builder to construct the correct project page URL - if scope_label == "Project" and project_id != "__workspace_scope__": - data["project"] = project_id - return await PlaneToolBase.format_success_payload_with_url(f"{scope_label} page '{name}' created successfully", data, "page", context) - else: - error_msg = result.get("error", "Unknown error occurred") - return PlaneToolBase.format_error_payload(f"Failed to create {scope_label.lower()} page", error_msg) - - # Return tools based on context: - # - In project chat: only project pages (workspace pages are not accessible in project UI) - # - In workspace/global chat: consolidated tool that requires scope selection via clarification - if is_project_chat: - return [pages_create_project_page] + # Return only project page tool + tools = generate_tools_for_category( + category="pages", + method_executor=method_executor, + context=context, + tool_definitions={"create_project_page": PAGE_TOOL_DEFINITIONS["create_project_page"]}, + ) else: - return [pages_create_page] + # In workspace/global context, create a meta-tool that routes to the correct SDK method + @tool + async def pages_create_page( + name: str, + project_id: str, + description_html: Optional[str] = None, + access: Optional[int] = None, + color: Optional[str] = None, + logo_props: Optional[dict] = None, + ) -> Dict[str, Any]: + """Create a new page either in a project or at the workspace level. + + Args: + name: Page title (required) + project_id: Project UUID where the page should be created. If user hasn't specified a project, + leave this empty or use 'NEEDS_CLARIFICATION' to trigger project selection. (required) + description_html: Page content in HTML format. If the user asks for 'details about X', + generate relevant content. Use empty string if no content is needed. + access: Access level - 0 for public, 1 for private (optional) + color: Page color in hex format (optional) + logo_props: Logo properties dict with keys like name, color, type (optional) + """ + workspace_slug = context.get("workspace_slug") + + # CRITICAL: Provide default empty string if description_html is None + # The Plane API requires this field even if empty + if not description_html: + description_html = name + + if project_id == "__workspace_scope__": + # Create at workspace level (Wiki) + result = await method_executor.execute( + "pages", + "create_workspace_page", + name=name, + workspace_slug=workspace_slug, + description_html=description_html, + access=access, + color=color, + logo_props=logo_props, + ) + scope_label = "Workspace" + else: + # Create in a specific project (Plane project pages) + result = await method_executor.execute( + "pages", + "create_project_page", + name=name, + project_id=project_id, + workspace_slug=workspace_slug, + description_html=description_html, + access=access, + color=color, + logo_props=logo_props, + ) + scope_label = "Project" + + if result.get("success"): + data = result.get("data", {}) + # CRITICAL: Inject project into response data for URL construction + # The API response doesn't include project field, so we need to add it + # Using "project" not "project_id" because extract_entity_from_api_response looks for "project" + # for the URL builder to construct the correct project page URL + if scope_label == "Project" and project_id != "__workspace_scope__": + data["project"] = project_id + return await PlaneToolBase.format_success_payload_with_url(f"{scope_label} page '{name}' created successfully", data, "page", context) + else: + error_msg = result.get("error", "Unknown error occurred") + return PlaneToolBase.format_error_payload(f"Failed to create {scope_label.lower()} page", error_msg) + + tools = [pages_create_page] # type: ignore[list-item] + + return tools + + +# ============================================================================ +# OLD MANUAL TOOL DEFINITIONS (COMMENTED FOR REFERENCE) +# ============================================================================ +# """ +# Pages API tools for Plane documentation and page management operations. +# """ +# +# from typing import Any +# from typing import Dict +# from typing import Optional +# +# from langchain_core.tools import tool +# +# from .base import PlaneToolBase +# +# # Factory wired via CATEGORY_TO_PROVIDER in tools/__init__.py +# # Returns LangChain tools implementing pages actions +# +# +# def get_page_tools(method_executor, context): +# """Return LangChain tools for the pages category using method_executor and context.""" +# +# # Check if we're in a project-specific context +# is_project_chat = context.get("is_project_chat", False) +# +# @tool +# async def pages_create_project_page( +# name: str, +# project_id: Optional[str] = None, +# description_html: Optional[str] = None, +# access: Optional[int] = None, +# color: Optional[str] = None, +# logo_props: Optional[dict] = None, +# ) -> Dict[str, Any]: +# """Create a new page in a project. +# +# Args: +# name: Page title (required) +# project_id: Project ID (required - provide from conversation context or previous actions) +# description_html: Page content in HTML format. If the user asks for "details about X", +# generate relevant content. Use empty string if no content is needed. +# access: Access level - 0 for public, 1 for private (optional, if not specified the API will use its default) +# color: Page color in hex format like #3B82F6 (optional) +# logo_props: Logo properties dict with keys like name, color, type (optional) +# +# Returns: +# Success message with page details and URL information +# """ # noqa E501 +# # Auto-fill workspace_slug from context (hidden from LLM) +# workspace_slug = context.get("workspace_slug") +# +# # Auto-fill project_id from context if not provided +# if project_id is None and "project_id" in context: +# project_id = context["project_id"] +# +# # CRITICAL: Provide default empty string if description_html is None +# # The Plane API requires this field even if empty +# if not description_html: +# description_html = name +# +# result = await method_executor.execute( +# "pages", +# "create_project_page", +# name=name, +# project_id=project_id, +# workspace_slug=workspace_slug, +# description_html=description_html, +# access=access, +# color=color, +# logo_props=logo_props, +# ) +# +# if result.get("success"): +# data = result.get("data", {}) +# # CRITICAL: Inject project into response data for URL construction +# # The API response doesn't include project field, so we need to add it +# # Using "project" not "project_id" because extract_entity_from_api_response looks for "project" +# if project_id: +# data["project"] = project_id +# return await PlaneToolBase.format_success_payload_with_url(f"Project page '{name}' created successfully", data, "page", context) +# else: +# error_msg = result.get("error", "Unknown error occurred") +# return PlaneToolBase.format_error_payload("Failed to create project page", error_msg) +# +# @tool +# async def pages_create_workspace_page( +# name: str, +# description_html: Optional[str] = None, +# access: Optional[int] = None, +# color: Optional[str] = None, +# logo_props: Optional[dict] = None, +# ) -> Dict[str, Any]: +# """Create a new page in the workspace. +# +# Args: +# name: Page title (required) +# description_html: Page content in HTML format. If the user asks for "details about X", +# generate relevant content. Use empty string if no content is needed. +# access: Access level - 0 for public, 1 for private (optional, if not specified the API will use its default) +# color: Page color in hex format like #10B981 (optional) +# logo_props: Logo properties dict with keys like name, color, type (optional) +# +# Returns: +# Success message with page details and URL information +# """ # noqa E501 +# # Auto-fill workspace_slug from context (hidden from LLM) +# workspace_slug = context.get("workspace_slug") +# +# # CRITICAL: Provide default empty string if description_html is None +# # The Plane API requires this field even if empty +# if not description_html: +# description_html = name +# +# result = await method_executor.execute( +# "pages", +# "create_workspace_page", +# name=name, +# workspace_slug=workspace_slug, +# description_html=description_html, +# access=access, +# color=color, +# logo_props=logo_props, +# ) +# +# if result.get("success"): +# data = result.get("data", {}) +# return await PlaneToolBase.format_success_payload_with_url(f"Workspace page '{name}' created successfully", data, "page", context) +# else: +# error_msg = result.get("error", "Unknown error occurred") +# return PlaneToolBase.format_error_payload("Failed to create workspace page", error_msg) +# +# # In workspace/global context, expose a single tool that forces clarification for scope +# @tool +# async def pages_create_page( +# name: str, +# project_id: str, +# description_html: Optional[str] = None, +# access: Optional[int] = None, +# color: Optional[str] = None, +# logo_props: Optional[dict] = None, +# ) -> Dict[str, Any]: +# """Create a new page either in a project or at the workspace level. +# +# Args: +# name: Page title (required) +# project_id: Project UUID where the page should be created. If user hasn't specified a project, +# leave this empty or use 'NEEDS_CLARIFICATION' to trigger project selection. (required) +# description_html: Page content in HTML format. If the user asks for "details about X", +# generate relevant content. Use empty string if no content is needed. +# access: Access level - 0 for public, 1 for private (optional) +# color: Page color in hex format (optional) +# logo_props: Logo properties dict with keys like name, color, type (optional) +# """ # noqa E501 +# workspace_slug = context.get("workspace_slug") +# +# # CRITICAL: Provide default empty string if description_html is None +# # The Plane API requires this field even if empty +# if not description_html: +# description_html = name +# +# if project_id == "__workspace_scope__": +# # Create at workspace level +# result = await method_executor.execute( +# "pages", +# "create_workspace_page", +# name=name, +# workspace_slug=workspace_slug, +# description_html=description_html, +# access=access, +# color=color, +# logo_props=logo_props, +# ) +# scope_label = "Workspace" +# else: +# # Create in a specific project +# result = await method_executor.execute( +# "pages", +# "create_project_page", +# name=name, +# project_id=project_id, +# workspace_slug=workspace_slug, +# description_html=description_html, +# access=access, +# color=color, +# logo_props=logo_props, +# ) +# scope_label = "Project" +# +# if result.get("success"): +# data = result.get("data", {}) +# # CRITICAL: Inject project into response data for URL construction +# # The API response doesn't include project field, so we need to add it +# # Using "project" not "project_id" because extract_entity_from_api_response looks for "project" +# # for the URL builder to construct the correct project page URL +# if scope_label == "Project" and project_id != "__workspace_scope__": +# data["project"] = project_id +# return await PlaneToolBase.format_success_payload_with_url(f"{scope_label} page '{name}' created successfully", data, "page", context) +# else: +# error_msg = result.get("error", "Unknown error occurred") +# return PlaneToolBase.format_error_payload(f"Failed to create {scope_label.lower()} page", error_msg) +# +# # Return tools based on context: +# # - In project chat: only project pages (workspace pages are not accessible in project UI) +# # - In workspace/global chat: consolidated tool that requires scope selection via clarification +# +# if is_project_chat: +# return [pages_create_project_page] +# else: +# return [pages_create_page] diff --git a/apps/pi/pi/services/actions/tools/projects.py b/apps/pi/pi/services/actions/tools/projects.py index 8b045eb3eb..0c96d2e94f 100644 --- a/apps/pi/pi/services/actions/tools/projects.py +++ b/apps/pi/pi/services/actions/tools/projects.py @@ -11,465 +11,599 @@ """ Projects API tools for Plane workspace operations. + +MIGRATED TO AUTO-GENERATED TOOLS +Tool metadata defined in this file. Custom pre/post handlers for complex logic. +Special handling includes: identifier generation, conflict retry, DB fallback, UUID validation. +Old manual definitions kept below for comparison/rollback safety. """ import re from typing import Any from typing import Dict -from typing import Optional - -from langchain_core.tools import tool from pi import logger from pi.core.db import PlaneDBPool - -from .base import PlaneToolBase +from pi.services.actions.tool_generator import generate_tools_for_category +from pi.services.actions.tool_metadata import ToolMetadata +from pi.services.actions.tool_metadata import ToolParameter +from pi.services.actions.tools.base import PlaneToolBase log = logger.getChild(__name__) -# Factory wired via CATEGORY_TO_PROVIDER in tools/__init__.py -# Returns LangChain tools implementing project actions +# ============================================================================ +# PROJECT-SPECIFIC HANDLER FUNCTIONS +# ============================================================================ + + +async def _project_pre_handler( + metadata: ToolMetadata, + kwargs: Dict[str, Any], + context: Dict[str, Any], + category: str, + method_key: str, + method_executor: Any = None, +) -> Dict[str, Any]: + """Pre-processing handler for projects tools. + + Handles: + - projects_create: identifier generation, description_html, icon sync + - projects_update: description_html, icon sync, identifier strip + - projects_retrieve: UUID validation + """ + + if method_key == "create": + # Generate identifier if not provided + if "identifier" not in kwargs or not kwargs["identifier"]: + name = kwargs.get("name", "") + kwargs["identifier"] = PlaneToolBase.generate_project_identifier(name) + log.debug(f"Generated project identifier: {kwargs["identifier"]}") + + # Auto-generate description_html from description if needed + if "description" in kwargs and kwargs["description"]: + if "description_html" not in kwargs or not kwargs["description_html"]: + kwargs["description_html"] = f"
{kwargs["description"]}
" + + # Remove icon_prop and logo_props - SDK auto-generates these and ignores user input + kwargs.pop("icon_prop", None) + kwargs.pop("logo_props", None) + + elif method_key == "update": + # Auto-generate description_html from description if needed + if "description" in kwargs and kwargs["description"]: + if "description_html" not in kwargs or not kwargs["description_html"]: + kwargs["description_html"] = f"{kwargs["description"]}
" + + # Remove icon_prop and logo_props - SDK auto-generates these and ignores user input + kwargs.pop("icon_prop", None) + kwargs.pop("logo_props", None) + + # Strip identifier if provided + if "identifier" in kwargs and kwargs["identifier"]: + kwargs["identifier"] = kwargs["identifier"].strip() + + elif method_key == "retrieve": + # UUID validation + project_id = kwargs.get("project_id", "") + if "{description}
" - elif description_html is not None: - payload["description_html"] = description_html - if project_lead is not None: - payload["project_lead"] = project_lead - if icon_prop is not None: - payload["icon_prop"] = icon_prop - if logo_props is not None: - payload["logo_props"] = logo_props - if icon_prop is not None and logo_props is None: - payload["logo_props"] = icon_prop - if logo_props is not None and icon_prop is None: - payload["icon_prop"] = logo_props - - if emoji is not None: - payload["emoji"] = emoji - if cover_image is not None: - payload["cover_image"] = cover_image - if network is not None: - payload["network"] = network - - # Add other optional fields - if default_assignee is not None: - payload["default_assignee"] = default_assignee - if module_view is not None: - payload["module_view"] = module_view - if cycle_view is not None: - payload["cycle_view"] = cycle_view - if issue_views_view is not None: - payload["issue_views_view"] = issue_views_view - if page_view is not None: - payload["page_view"] = page_view - if intake_view is not None: - payload["intake_view"] = intake_view - if guest_view_all_features is not None: - payload["guest_view_all_features"] = guest_view_all_features - if archive_in is not None: - payload["archive_in"] = archive_in - if close_in is not None: - payload["close_in"] = close_in - if timezone is not None: - payload["timezone"] = timezone - if time_tracking_enabled is not None: - payload["time_tracking_enabled"] = time_tracking_enabled - if is_issue_type_enabled is not None: - payload["is_issue_type_enabled"] = is_issue_type_enabled - - # Try to create project - result = await method_executor.execute("projects", "create", **payload) - - if result["success"]: - return await PlaneToolBase.format_success_payload_with_url( - f"Successfully created project '{name}' with identifier '{base_identifier}'", result["data"], "project", context - ) - else: - # Check if it's a conflict error (name or identifier already taken) - error_msg = result.get("error", "").lower() - if "already taken" in error_msg or "409" in error_msg or "conflict" in error_msg: - # Check if it's a name conflict (more common) vs identifier conflict - if "name" in error_msg and "identifier" not in error_msg: - # Name conflict - inform user to choose a different name - log.info(f"Project name '{name}' already exists. Error: {result["error"]}\nPayload: {payload}") - error_payload: Dict[str, Any] = PlaneToolBase.format_error_payload( - f"Failed to create project: A project with the name '{name}' already exists. Please choose a different name.", - result["error"], - ) - return error_payload - else: - # Identifier conflict - retry with new identifier - new_identifier = PlaneToolBase.generate_fallback_identifier(base_identifier) - payload["identifier"] = new_identifier - retry_result = await method_executor.execute("projects", "create", **payload) - - if retry_result["success"]: - return await PlaneToolBase.format_success_payload_with_url( - f"Successfully created project '{name}' with identifier '{new_identifier}' (original '{base_identifier}' was taken)", - retry_result["data"], - "project", - context, - ) - else: - log.info(f"Failed to create project. Error: {retry_result["error"]}\nPayload: {payload}") - error_payload_retry: Dict[str, Any] = PlaneToolBase.format_error_payload( - "Failed to create project even with alternative identifier", retry_result["error"] - ) - return error_payload_retry - else: - # Check if project was actually created despite the error (e.g. timeout) - try: - # We need to check if the project exists in the DB with the same identifier and workspace_slug - query = """ - SELECT p.id, p.name, p.identifier, p.workspace_id - FROM projects p - JOIN workspaces w ON p.workspace_id = w.id - WHERE p.identifier = $1 AND w.slug = $2 AND p.deleted_at IS NULL - """ - row = await PlaneDBPool.fetchrow(query, (base_identifier, workspace_slug)) - if row: - project_data = { - "id": str(row["id"]), - "name": row["name"], - "identifier": row["identifier"], - "workspace_id": str(row["workspace_id"]), - # Add workspace_slug for context - "workspace_slug": workspace_slug, - } - return await PlaneToolBase.format_success_payload_with_url( - f"Successfully created project '{name}' with identifier '{base_identifier}'", - project_data, - "project", - context, - ) - except Exception as e: - log.error(f"Failed to recover project creation from DB: {e}") - - log.info(f"Failed to create project. Error: {result["error"]}\nPayload: {payload}") - return PlaneToolBase.format_error_payload("Failed to create project", result["error"]) - - @tool - async def projects_list(workspace_slug: Optional[str] = None, per_page: Optional[int] = 20, cursor: Optional[str] = None) -> Dict[str, Any]: - """List projects in the workspace. - - Args: - workspace_slug: Workspace slug (provide if known, otherwise auto-detected) - per_page: Number of projects per page (default: 20, max: 100) - cursor: Pagination cursor for next page - """ - # Auto-fill workspace_slug from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - - result = await method_executor.execute("projects", "list", workspace_slug=workspace_slug, per_page=per_page, cursor=cursor) - - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully retrieved projects list", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to list projects", result["error"]) - - @tool - async def projects_retrieve(project_id: str, workspace_slug: Optional[str] = None) -> Dict[str, Any]: - """Retrieve a single project by ID. - - Args: - project_id: Project ID (required) - workspace_slug: Workspace slug (provide if known, otherwise auto-detected) - """ - # Lightweight validation to prevent bad calls and noisy 404s - # - Reject placeholders like "tags - update_data["description_html"] = f"
{description}
" - elif description_html is not None: - update_data["description_html"] = description_html - if project_lead is not None: - update_data["project_lead"] = project_lead - if icon_prop is not None: - update_data["icon_prop"] = icon_prop - if logo_props is not None: - update_data["logo_props"] = logo_props - if icon_prop is not None and logo_props is None: - update_data["logo_props"] = icon_prop - if logo_props is not None and icon_prop is None: - update_data["icon_prop"] = logo_props - if emoji is not None: - update_data["emoji"] = emoji - if cover_image is not None: - update_data["cover_image"] = cover_image - if network is not None: - update_data["network"] = network - - # Other optional fields - # Plane rejects empty identifier updates; only send when non-empty - if identifier is not None and isinstance(identifier, str) and identifier.strip(): - update_data["identifier"] = identifier.strip() - if default_assignee is not None: - update_data["default_assignee"] = default_assignee - if module_view is not None: - update_data["module_view"] = module_view - if cycle_view is not None: - update_data["cycle_view"] = cycle_view - if issue_views_view is not None: - update_data["issue_views_view"] = issue_views_view - if page_view is not None: - update_data["page_view"] = page_view - if intake_view is not None: - update_data["intake_view"] = intake_view - if is_time_tracking_enabled is not None: - update_data["is_time_tracking_enabled"] = is_time_tracking_enabled - if is_issue_type_enabled is not None: - update_data["is_issue_type_enabled"] = is_issue_type_enabled - if guest_view_all_features is not None: - update_data["guest_view_all_features"] = guest_view_all_features - if archive_in is not None: - update_data["archive_in"] = archive_in - if close_in is not None: - update_data["close_in"] = close_in - if timezone is not None: - update_data["timezone"] = timezone - - result = await method_executor.execute( - "projects", - "update", - project_id=project_id, - workspace_slug=workspace_slug, - **update_data, - ) - - if result["success"]: - return await PlaneToolBase.format_success_payload_with_url("Successfully updated project", result["data"], "project", context) - else: - return PlaneToolBase.format_error_payload("Failed to update project", result["error"]) - - @tool - async def projects_archive(project_id: str, workspace_slug: Optional[str] = None) -> Dict[str, Any]: - """Archive a project. - - Args: - project_id: Project ID (required) - workspace_slug: Workspace slug (provide if known, otherwise auto-detected) - """ - # Auto-fill workspace_slug from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - - result = await method_executor.execute("projects", "archive", project_id=project_id, workspace_slug=workspace_slug) - - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully archived project", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to archive project", result["error"]) - - @tool - async def projects_unarchive(project_id: str, workspace_slug: Optional[str] = None) -> Dict[str, Any]: - """Restore an archived project. - - Args: - project_id: Project ID (required) - workspace_slug: Workspace slug (provide if known, otherwise auto-detected) - """ - # Auto-fill workspace_slug from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - - result = await method_executor.execute("projects", "unarchive", project_id=project_id, workspace_slug=workspace_slug) - - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully unarchived project", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to unarchive project", result["error"]) - - return [ - projects_create, - projects_list, - projects_retrieve, - projects_update, - projects_archive, - projects_unarchive, - ] +# import re +# from pi import logger +# from pi.core.db import PlaneDBPool +# from langchain_core.tools import tool +# from .base import PlaneToolBase +# +# log = logger.getChild(__name__) +# +# def get_project_tools(method_executor, context): +# """Return LangChain tools for the projects category using method_executor and context.""" +# +# [ORIGINAL 525 LINES OF MANUAL @tool DEFINITIONS OMITTED FOR BREVITY] +# [See git history for full original implementation] +# +# return [ +# projects_create, +# projects_list, +# projects_retrieve, +# projects_update, +# projects_delete, +# projects_get_features, +# projects_update_features, +# ] diff --git a/apps/pi/pi/services/actions/tools/properties.py b/apps/pi/pi/services/actions/tools/properties.py index a8b22c1953..872ed8f9ad 100644 --- a/apps/pi/pi/services/actions/tools/properties.py +++ b/apps/pi/pi/services/actions/tools/properties.py @@ -13,498 +13,214 @@ Properties API tools for Plane custom property management. """ -from typing import Any -from typing import Dict -from typing import Optional +from pi.services.actions.tool_generator import generate_tools_for_category +from pi.services.actions.tool_metadata import ToolMetadata +from pi.services.actions.tool_metadata import ToolParameter -from langchain_core.tools import tool +# ============================================================================ +# TOOL METADATA DEFINITIONS +# ============================================================================ -from .base import PlaneToolBase +PROPERTIES_TOOL_DEFINITIONS = { + # ========================================================================== + # Core Property CRUD + # ========================================================================== + "create": ToolMetadata( + name="properties_create", + description="Create a new work item property (custom field) for a specific work item type.", + sdk_method="create_property", + parameters=[ + ToolParameter(name="display_name", type="str", required=True, description="Display name for the property (required)"), + ToolParameter( + name="property_type", + type="str", + required=True, + description="Type of property: TEXT, DATETIME, DECIMAL, BOOLEAN, OPTION, RELATION, URL, EMAIL, FILE", + ), + ToolParameter(name="type_id", type="str", required=True, description="Work item type ID this property belongs to (required)"), + ToolParameter(name="project_id", type="Optional[str]", required=False, description="Project ID", auto_fill_from_context=True), + ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True), + ToolParameter( + name="relation_type", + type="Optional[str]", + required=False, + description="Relation type for RELATION properties: ONE_TO_ONE, ONE_TO_MANY, MANY_TO_ONE, MANY_TO_MANY", + ), + ToolParameter(name="description", type="Optional[str]", required=False, description="Description of the property"), + ToolParameter(name="is_required", type="Optional[bool]", required=False, description="Whether this property is required for work items"), + ToolParameter(name="default_value", type="Optional[list]", required=False, description="List of default values for the property"), + ToolParameter( + name="settings", + type="Optional[dict]", + required=False, + description="Property settings (TextAttributeSettings for TEXT, DateAttributeSettings for DATETIME)", + ), + ToolParameter(name="is_active", type="Optional[bool]", required=False, description="Whether the property is active"), + ToolParameter(name="is_multi", type="Optional[bool]", required=False, description="Whether this property supports multiple values"), + ToolParameter(name="validation_rules", type="Optional[dict]", required=False, description="Validation rules for the property"), + ToolParameter(name="options", type="Optional[list]", required=False, description="List of options for OPTION type properties"), + ToolParameter( + name="external_source", type="Optional[str]", required=False, description="External system source identifier (e.g., 'github', 'jira')" + ), + ToolParameter(name="external_id", type="Optional[str]", required=False, description="External system's identifier for this property"), + ], + returns_entity_type="property", + ), + "list": ToolMetadata( + name="properties_list", + description="List all work item properties (custom fields) for a specific work item type.", + sdk_method="list_properties", + parameters=[ + ToolParameter(name="type_id", type="str", required=True, description="Work item type ID to list properties for"), + ToolParameter(name="project_id", type="Optional[str]", required=False, description="Project ID", auto_fill_from_context=True), + ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True), + ], + ), + "retrieve": ToolMetadata( + name="properties_retrieve", + description="Get a single work item property by ID.", + sdk_method="retrieve_property", + parameters=[ + ToolParameter(name="property_id", type="str", required=True, description="Property ID to retrieve"), + ToolParameter(name="type_id", type="str", required=True, description="Work item type ID this property belongs to"), + ToolParameter(name="project_id", type="Optional[str]", required=False, description="Project ID", auto_fill_from_context=True), + ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True), + ], + ), + "update": ToolMetadata( + name="properties_update", + description="Update work item property details.", + sdk_method="update_property", + parameters=[ + ToolParameter(name="property_id", type="str", required=True, description="Property ID to update"), + ToolParameter(name="type_id", type="str", required=True, description="Work item type ID this property belongs to"), + ToolParameter(name="project_id", type="Optional[str]", required=False, description="Project ID", auto_fill_from_context=True), + ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True), + ToolParameter(name="display_name", type="Optional[str]", required=False, description="Display name for the property"), + ToolParameter( + name="property_type", + type="Optional[str]", + required=False, + description="Type of property: TEXT, DATETIME, DECIMAL, BOOLEAN, OPTION, RELATION, URL, EMAIL, FILE", + ), + ToolParameter(name="relation_type", type="Optional[str]", required=False, description="Relation type for RELATION properties"), + ToolParameter(name="description", type="Optional[str]", required=False, description="Description of the property"), + ToolParameter(name="is_required", type="Optional[bool]", required=False, description="Whether this property is required"), + ToolParameter(name="default_value", type="Optional[list]", required=False, description="List of default values"), + ToolParameter(name="settings", type="Optional[dict]", required=False, description="Property settings"), + ToolParameter(name="is_active", type="Optional[bool]", required=False, description="Whether the property is active"), + ToolParameter(name="is_multi", type="Optional[bool]", required=False, description="Whether this property supports multiple values"), + ToolParameter(name="validation_rules", type="Optional[dict]", required=False, description="Validation rules"), + ToolParameter(name="external_source", type="Optional[str]", required=False, description="External system source identifier"), + ToolParameter(name="external_id", type="Optional[str]", required=False, description="External system's identifier"), + ], + returns_entity_type="property", + ), + "delete": ToolMetadata( + name="properties_delete", + description="Delete a work item property.", + sdk_method="delete_property", + parameters=[ + ToolParameter(name="property_id", type="str", required=True, description="Property ID to delete"), + ToolParameter(name="type_id", type="str", required=True, description="Work item type ID this property belongs to"), + ToolParameter(name="project_id", type="Optional[str]", required=False, description="Project ID", auto_fill_from_context=True), + ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True), + ], + returns_entity_type="property", + ), + # ========================================================================== + # Property Options (for OPTION type properties) + # ========================================================================== + "create_option": ToolMetadata( + name="properties_create_option", + description="Create a new option for an OPTION type property.", + sdk_method="create_property_option", + parameters=[ + ToolParameter(name="property_id", type="str", required=True, description="Property ID to add option to"), + ToolParameter(name="type_id", type="str", required=True, description="Work item type ID"), + ToolParameter(name="name", type="str", required=True, description="Option display name"), + ToolParameter(name="project_id", type="Optional[str]", required=False, description="Project ID", auto_fill_from_context=True), + ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True), + ToolParameter(name="value", type="Optional[str]", required=False, description="Option value (defaults to name if not provided)"), + ], + returns_entity_type="property_option", + ), + "list_options": ToolMetadata( + name="properties_list_options", + description="List all options for an OPTION type property.", + sdk_method="list_property_options", + parameters=[ + ToolParameter(name="property_id", type="str", required=True, description="Property ID to list options for"), + ToolParameter(name="type_id", type="str", required=True, description="Work item type ID"), + ToolParameter(name="project_id", type="Optional[str]", required=False, description="Project ID", auto_fill_from_context=True), + ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True), + ], + ), + "retrieve_option": ToolMetadata( + name="properties_retrieve_option", + description="Get a property option by ID.", + sdk_method="retrieve_property_option", + parameters=[ + ToolParameter(name="property_id", type="str", required=True, description="Property ID"), + ToolParameter(name="type_id", type="str", required=True, description="Work item type ID"), + ToolParameter(name="option_id", type="str", required=True, description="Option ID to retrieve"), + ToolParameter(name="project_id", type="Optional[str]", required=False, description="Project ID", auto_fill_from_context=True), + ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True), + ], + ), + "update_option": ToolMetadata( + name="properties_update_option", + description="Update a property option.", + sdk_method="update_property_option", + parameters=[ + ToolParameter(name="property_id", type="str", required=True, description="Property ID"), + ToolParameter(name="type_id", type="str", required=True, description="Work item type ID"), + ToolParameter(name="option_id", type="str", required=True, description="Option ID to update"), + ToolParameter(name="project_id", type="Optional[str]", required=False, description="Project ID", auto_fill_from_context=True), + ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True), + ToolParameter(name="name", type="Optional[str]", required=False, description="Option display name"), + ToolParameter(name="value", type="Optional[str]", required=False, description="Option value"), + ], + returns_entity_type="property_option", + ), + "delete_option": ToolMetadata( + name="properties_delete_option", + description="Delete a property option.", + sdk_method="delete_property_option", + parameters=[ + ToolParameter(name="property_id", type="str", required=True, description="Property ID"), + ToolParameter(name="type_id", type="str", required=True, description="Work item type ID"), + ToolParameter(name="option_id", type="str", required=True, description="Option ID to delete"), + ToolParameter(name="project_id", type="Optional[str]", required=False, description="Project ID", auto_fill_from_context=True), + ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True), + ], + returns_entity_type="property_option", + ), + # ========================================================================== + # Property Values (values set on work items) + # ========================================================================== + "create_value": ToolMetadata( + name="properties_create_value", + description='Set a property value on a work item. For simple types (TEXT), provide the value directly. For single-select (OPTION), provide the Option ID. For multi-select, provide a JSON-formatted list of Option IDs (e.g., \'["uuid1", "uuid2"]\').', # noqa: E501 + sdk_method="create_property_value", + parameters=[ + ToolParameter(name="property_id", type="str", required=True, description="Property ID"), + ToolParameter(name="type_id", type="str", required=True, description="Work item type ID"), + ToolParameter(name="issue_id", type="str", required=True, description="Work item ID to set value on"), + ToolParameter(name="value", type="str", required=True, description="Property value (use Option ID for OPTION types)"), + ToolParameter(name="project_id", type="Optional[str]", required=False, description="Project ID", auto_fill_from_context=True), + ToolParameter(name="workspace_slug", type="Optional[str]", required=False, description="Workspace slug", auto_fill_from_context=True), + ], + returns_entity_type="property_value", + ), +} -# Factory wired via CATEGORY_TO_PROVIDER in tools/__init__.py -# Returns LangChain tools implementing property actions + +# ============================================================================ +# TOOL FACTORY +# ============================================================================ def get_property_tools(method_executor, context): - """Return LangChain tools for the properties category using method_executor and context.""" - """Get all Properties API tools.""" - - @tool - async def properties_create( - display_name: str, - property_type: str, - type_id: str, - relation_type: Optional[str] = None, - description: Optional[str] = None, - is_required: Optional[bool] = None, - default_value: Optional[list] = None, - settings: Optional[dict] = None, - is_active: Optional[bool] = None, - is_multi: Optional[bool] = None, - validation_rules: Optional[dict] = None, - external_source: Optional[str] = None, - external_id: Optional[str] = None, - project_id: Optional[str] = None, - workspace_slug: Optional[str] = None, - ) -> Dict[str, Any]: - """Create a new work item property. - - Args: - display_name: Display name for the property (required) - property_type: Type of property - TEXT, DATETIME, DECIMAL, BOOLEAN, OPTION, RELATION, URL, EMAIL, FILE (required) - type_id: Work item type ID this property belongs to (required) - relation_type: Relation type for RELATION properties - ONE_TO_ONE, ONE_TO_MANY, MANY_TO_ONE, MANY_TO_MANY - description: Description of the property - is_required: Whether this property is required for work items - default_value: List of default values for the property - settings: Property settings (TextAttributeSettings for TEXT, DateAttributeSettings for DATETIME) - is_active: Whether the property is active - is_multi: Whether this property supports multiple values - validation_rules: Validation rules for the property - external_source: External system source identifier (e.g., 'github', 'jira') - external_id: External system's identifier for this property - project_id: Project ID (auto-filled from context if not provided) - workspace_slug: Workspace slug (auto-filled from context if not provided) - """ - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - result = await method_executor.execute( - "properties", - "create", - display_name=display_name, - property_type=property_type, - type_id=type_id, - relation_type=relation_type, - description=description, - is_required=is_required, - default_value=default_value, - settings=settings, - is_active=is_active, - is_multi=is_multi, - validation_rules=validation_rules, - external_source=external_source, - external_id=external_id, - project_id=project_id, - workspace_slug=workspace_slug, - ) - if result["success"]: - return PlaneToolBase.format_success_payload(f"Successfully created property '{display_name}'", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to create property", result["error"]) - - @tool - async def properties_list( - type_id: str, - project_id: Optional[str] = None, - workspace_slug: Optional[str] = None, - ) -> Dict[str, Any]: - """List all work item properties for a specific work item type. - - Args: - type_id: Work item type ID to list properties for (required) - project_id: Project ID (auto-filled from context if not provided) - workspace_slug: Workspace slug (auto-filled from context if not provided) - """ - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - result = await method_executor.execute( - "properties", - "list", - type_id=type_id, - project_id=project_id, - workspace_slug=workspace_slug, - ) - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully retrieved properties list", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to list properties", result["error"]) - - @tool - async def properties_retrieve( - property_id: str, - type_id: str, - project_id: Optional[str] = None, - workspace_slug: Optional[str] = None, - ) -> Dict[str, Any]: - """Get a single work item property by ID. - - Args: - property_id: Property ID to retrieve (required) - type_id: Work item type ID this property belongs to (required) - project_id: Project ID (auto-filled from context if not provided) - workspace_slug: Workspace slug (auto-filled from context if not provided) - """ - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - result = await method_executor.execute( - "properties", - "retrieve", - property_id=property_id, - type_id=type_id, - project_id=project_id, - workspace_slug=workspace_slug, - ) - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully retrieved property", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to retrieve property", result["error"]) - - @tool - async def properties_update( - property_id: str, - type_id: str, - display_name: Optional[str] = None, - property_type: Optional[str] = None, - relation_type: Optional[str] = None, - description: Optional[str] = None, - is_required: Optional[bool] = None, - default_value: Optional[list] = None, - settings: Optional[dict] = None, - is_active: Optional[bool] = None, - is_multi: Optional[bool] = None, - validation_rules: Optional[dict] = None, - external_source: Optional[str] = None, - external_id: Optional[str] = None, - project_id: Optional[str] = None, - workspace_slug: Optional[str] = None, - ) -> Dict[str, Any]: - """Update work item property details. - - Args: - property_id: Property ID to update (required) - type_id: Work item type ID this property belongs to (required) - display_name: Display name for the property - property_type: Type of property - TEXT, DATETIME, DECIMAL, BOOLEAN, OPTION, RELATION, URL, EMAIL, FILE - relation_type: Relation type for RELATION properties - ONE_TO_ONE, ONE_TO_MANY, MANY_TO_ONE, MANY_TO_MANY - description: Description of the property - is_required: Whether this property is required for work items - default_value: List of default values for the property - settings: Property settings (TextAttributeSettings for TEXT, DateAttributeSettings for DATETIME) - is_active: Whether the property is active - is_multi: Whether this property supports multiple values - validation_rules: Validation rules for the property - external_source: External system source identifier (e.g., 'github', 'jira') - external_id: External system's identifier for this property - project_id: Project ID (auto-filled from context if not provided) - workspace_slug: Workspace slug (auto-filled from context if not provided) - """ - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - # Build update data - update_data: dict[str, Any] = {} - if display_name is not None: - update_data["display_name"] = display_name - if property_type is not None: - update_data["property_type"] = property_type - if relation_type is not None: - update_data["relation_type"] = relation_type - if description is not None: - update_data["description"] = description - if is_required is not None: - update_data["is_required"] = is_required - if default_value is not None: - update_data["default_value"] = default_value - if settings is not None: - update_data["settings"] = settings - if is_active is not None: - update_data["is_active"] = is_active - if is_multi is not None: - update_data["is_multi"] = is_multi - if validation_rules is not None: - update_data["validation_rules"] = validation_rules - if external_source is not None: - update_data["external_source"] = external_source - if external_id is not None: - update_data["external_id"] = external_id - - result = await method_executor.execute( - "properties", - "update", - property_id=property_id, - type_id=type_id, - project_id=project_id, - workspace_slug=workspace_slug, - **update_data, - ) - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully updated property", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to update property", result["error"]) - - @tool - async def properties_delete( - property_id: str, - type_id: str, - project_id: Optional[str] = None, - workspace_slug: Optional[str] = None, - ) -> Dict[str, Any]: - """Delete a work item property. - - Args: - property_id: Property ID to delete (required) - type_id: Work item type ID this property belongs to (required) - project_id: Project ID (auto-filled from context if not provided) - workspace_slug: Workspace slug (auto-filled from context if not provided) - """ - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - result = await method_executor.execute( - "properties", - "delete", - property_id=property_id, - type_id=type_id, - project_id=project_id, - workspace_slug=workspace_slug, - ) - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully deleted property", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to delete property", result["error"]) - - @tool - async def properties_create_option( - property_id: str, - type_id: str, - name: str, - value: Optional[str] = None, - project_id: Optional[str] = None, - workspace_slug: Optional[str] = None, - ) -> Dict[str, Any]: - """Create a property option.""" - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - result = await method_executor.execute( - "properties", - "create_option", - property_id=property_id, - type_id=type_id, - name=name, - value=value, - project_id=project_id, - workspace_slug=workspace_slug, - ) - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully created property option", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to create property option", result["error"]) - - @tool - async def properties_create_value( - property_id: str, - type_id: str, - issue_id: str, - value: str, - project_id: Optional[str] = None, - workspace_slug: Optional[str] = None, - ) -> Dict[str, Any]: - """Create a property value.""" - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - result = await method_executor.execute( - "properties", - "create_value", - property_id=property_id, - type_id=type_id, - issue_id=issue_id, - value=value, - project_id=project_id, - workspace_slug=workspace_slug, - ) - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully created property value", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to create property value", result["error"]) - - @tool - async def properties_list_options( - property_id: str, - type_id: str, - project_id: Optional[str] = None, - workspace_slug: Optional[str] = None, - ) -> Dict[str, Any]: - """List property options.""" - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - result = await method_executor.execute( - "properties", - "list_options", - property_id=property_id, - type_id=type_id, - project_id=project_id, - workspace_slug=workspace_slug, - ) - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully retrieved property options", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to list property options", result["error"]) - - @tool - async def properties_list_values( - type_id: str, - issue_id: str, - project_id: Optional[str] = None, - workspace_slug: Optional[str] = None, - ) -> Dict[str, Any]: - """List property values.""" - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - result = await method_executor.execute( - "properties", - "list_values", - type_id=type_id, - issue_id=issue_id, - project_id=project_id, - workspace_slug=workspace_slug, - ) - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully retrieved property values", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to list property values", result["error"]) - - @tool - async def properties_retrieve_option( - property_id: str, - type_id: str, - option_id: str, - project_id: Optional[str] = None, - workspace_slug: Optional[str] = None, - ) -> Dict[str, Any]: - """Get property option by ID.""" - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - result = await method_executor.execute( - "properties", - "retrieve_option", - property_id=property_id, - type_id=type_id, - option_id=option_id, - project_id=project_id, - workspace_slug=workspace_slug, - ) - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully retrieved property option", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to retrieve property option", result["error"]) - - @tool - async def properties_update_option( - property_id: str, - type_id: str, - option_id: str, - name: Optional[str] = None, - value: Optional[str] = None, - project_id: Optional[str] = None, - workspace_slug: Optional[str] = None, - ) -> Dict[str, Any]: - """Update property option.""" - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - # Build update data - update_data = {} - if name is not None: - update_data["name"] = name - if value is not None: - update_data["value"] = value - - result = await method_executor.execute( - "properties", - "update_option", - property_id=property_id, - type_id=type_id, - option_id=option_id, - project_id=project_id, - workspace_slug=workspace_slug, - **update_data, - ) - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully updated property option", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to update property option", result["error"]) - - @tool - async def properties_delete_option( - property_id: str, - type_id: str, - option_id: str, - project_id: Optional[str] = None, - workspace_slug: Optional[str] = None, - ) -> Dict[str, Any]: - """Delete property option.""" - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - result = await method_executor.execute( - "properties", - "delete_option", - property_id=property_id, - type_id=type_id, - option_id=option_id, - project_id=project_id, - workspace_slug=workspace_slug, - ) - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully deleted property option", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to delete property option", result["error"]) - - return [ - properties_create, - properties_list, - properties_retrieve, - properties_update, - properties_delete, - properties_create_option, - properties_create_value, - properties_list_options, - properties_list_values, - properties_retrieve_option, - properties_update_option, - properties_delete_option, - ] + """Return LangChain tools for the properties category using auto-generation from metadata.""" + return generate_tools_for_category("properties", method_executor, context, PROPERTIES_TOOL_DEFINITIONS) diff --git a/apps/pi/pi/services/actions/tools/states.py b/apps/pi/pi/services/actions/tools/states.py index 6b618321e5..fd58d67325 100644 --- a/apps/pi/pi/services/actions/tools/states.py +++ b/apps/pi/pi/services/actions/tools/states.py @@ -11,201 +11,372 @@ """ States API tools for Plane workflow state management. + +MIGRATED TO AUTO-GENERATED TOOLS +Tool metadata defined in this file (STATE_TOOL_DEFINITIONS) for modularity. +Old manual definitions kept below for comparison/rollback safety. """ -from typing import Any from typing import Dict -from typing import Optional -from langchain_core.tools import tool +from pi.services.actions.tool_generator import generate_tools_for_category +from pi.services.actions.tool_metadata import ToolMetadata +from pi.services.actions.tool_metadata import ToolParameter -from .base import PlaneToolBase - -# Factory wired via CATEGORY_TO_PROVIDER in tools/__init__.py -# Returns LangChain tools implementing state actions +# Tool metadata for states category +STATE_TOOL_DEFINITIONS: Dict[str, ToolMetadata] = { + "create": ToolMetadata( + name="states_create", + description="Create a new workflow state", + sdk_method="create_state", + returns_entity_type="state", + parameters=[ + ToolParameter(name="name", type="str", required=True, description="State name (required)"), + ToolParameter(name="color", type="str", required=True, description="State color in hex format (required)"), + ToolParameter( + name="project_id", + type="Optional[str]", + required=False, + description="Project ID (required - provide from conversation context or previous actions)", + auto_fill_from_context=True, + property_transform="skip", + ), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (provide if known, otherwise auto-detected)", + auto_fill_from_context=True, + property_transform="skip", + ), + ToolParameter(name="description", type="Optional[str]", required=False, description="State description", property_transform="name"), + ToolParameter( + name="group", + type="Optional[str]", + required=False, + description="State group (backlog, unstarted, started, completed, cancelled)", + property_transform="name", + ), + ToolParameter(name="sequence", type="Optional[int]", required=False, description="Display sequence order", property_transform="name"), + ToolParameter( + name="is_triage", type="Optional[bool]", required=False, description="Whether this is a triage state", property_transform="name" + ), + ToolParameter( + name="default", type="Optional[bool]", required=False, description="Whether this is the default state", property_transform="name" + ), + ToolParameter( + name="external_source", + type="Optional[str]", + required=False, + description='External source identifier (e.g., "jira")', + property_transform="name", + ), + ToolParameter(name="external_id", type="Optional[str]", required=False, description="External system ID", property_transform="name"), + ], + ), + "list": ToolMetadata( + name="states_list", + description="List all workflow states in a project", + sdk_method="list_states", + parameters=[ + ToolParameter( + name="project_id", + type="Optional[str]", + required=False, + description="Project ID (required - provide from conversation context or previous actions)", + auto_fill_from_context=True, + property_transform="skip", + ), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (provide if known, otherwise auto-detected)", + auto_fill_from_context=True, + property_transform="skip", + ), + ], + ), + "retrieve": ToolMetadata( + name="states_retrieve", + description="Retrieve details of a specific workflow state", + sdk_method="retrieve_state", + parameters=[ + ToolParameter(name="state_id", type="str", required=True, description="State ID (required)"), + ToolParameter( + name="project_id", + type="Optional[str]", + required=False, + description="Project ID (required - provide from conversation context or previous actions)", + auto_fill_from_context=True, + property_transform="skip", + ), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (provide if known, otherwise auto-detected)", + auto_fill_from_context=True, + property_transform="skip", + ), + ], + ), + "update": ToolMetadata( + name="states_update", + description="Update an existing workflow state", + sdk_method="update_state", + returns_entity_type="state", + parameters=[ + ToolParameter(name="state_id", type="str", required=True, description="State ID (required)"), + ToolParameter( + name="project_id", + type="Optional[str]", + required=False, + description="Project ID (required - provide from conversation context or previous actions)", + auto_fill_from_context=True, + property_transform="skip", + ), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (provide if known, otherwise auto-detected)", + auto_fill_from_context=True, + property_transform="skip", + ), + ToolParameter(name="name", type="Optional[str]", required=False, description="New state name", property_transform="name"), + ToolParameter(name="color", type="Optional[str]", required=False, description="New state color", property_transform="name"), + ToolParameter(name="description", type="Optional[str]", required=False, description="New state description", property_transform="name"), + ToolParameter(name="sequence", type="Optional[int]", required=False, description="Display sequence order", property_transform="name"), + ToolParameter( + name="group", + type="Optional[str]", + required=False, + description="State group (backlog, unstarted, started, completed, cancelled)", + property_transform="name", + ), + ToolParameter( + name="is_triage", type="Optional[bool]", required=False, description="Whether this is a triage state", property_transform="name" + ), + ToolParameter( + name="default", type="Optional[bool]", required=False, description="Whether this is the default state", property_transform="name" + ), + ToolParameter( + name="external_source", + type="Optional[str]", + required=False, + description='External source identifier (e.g., "jira")', + property_transform="name", + ), + ToolParameter(name="external_id", type="Optional[str]", required=False, description="External system ID", property_transform="name"), + ], + ), +} def get_state_tools(method_executor, context): - """Return LangChain tools for the states category using method_executor and context.""" + """Return LangChain tools for the states category using auto-generation from metadata.""" - @tool - async def states_create( - name: str, - color: str, - project_id: Optional[str] = None, - workspace_slug: Optional[str] = None, - description: Optional[str] = None, - group: Optional[str] = None, - sequence: Optional[int] = None, - is_triage: Optional[bool] = None, - default: Optional[bool] = None, - external_source: Optional[str] = None, - external_id: Optional[str] = None, - ) -> Dict[str, Any]: - """Create a new workflow state. + state_tools = generate_tools_for_category( + category="states", + method_executor=method_executor, + context=context, + tool_definitions=STATE_TOOL_DEFINITIONS, + ) - Args: - name: State name (required) - color: State color in hex format (required) - project_id: Project ID (required - provide from conversation context or previous actions) - workspace_slug: Workspace slug (provide if known, otherwise auto-detected) - description: State description - group: State group (backlog, unstarted, started, completed, cancelled) - sequence: Display sequence order - is_triage: Whether this is a triage state - default: Whether this is the default state - external_source: External source identifier (e.g., "jira") - external_id: External system ID - """ - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] + return state_tools - result = await method_executor.execute( - "states", - "create", - name=name, - color=color, - project_id=project_id, - workspace_slug=workspace_slug, - description=description, - group=group, - sequence=sequence, - is_triage=is_triage, - default=default, - external_source=external_source, - external_id=external_id, - ) - if result["success"]: - return await PlaneToolBase.format_success_payload_with_url(f"Successfully created state '{name}'", result["data"], "state", context) - else: - return PlaneToolBase.format_error_payload("Failed to create state", result["error"]) +# ============================================================================ +# OLD MANUAL TOOL DEFINITIONS (COMMENTED OUT - KEPT FOR COMPARISON) +# To rollback: uncomment below and comment out the auto-generation code above +# ============================================================================ - @tool - async def states_list( - project_id: Optional[str] = None, - workspace_slug: Optional[str] = None, - ) -> Dict[str, Any]: - """List all workflow states in a project.""" - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - result = await method_executor.execute( - "states", - "list", - project_id=project_id, - workspace_slug=workspace_slug, - ) - - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully retrieved states list", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to list states", result["error"]) - - @tool - async def states_retrieve( - state_id: str, - project_id: Optional[str] = None, - workspace_slug: Optional[str] = None, - ) -> Dict[str, Any]: - """Retrieve details of a specific workflow state. - - Args: - state_id: State ID (required) - project_id: Project ID (required - provide from conversation context or previous actions) - workspace_slug: Workspace slug (provide if known, otherwise auto-detected) - """ - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - result = await method_executor.execute( - "states", - "retrieve", - state_id=state_id, - project_id=project_id, - workspace_slug=workspace_slug, - ) - - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully retrieved state details", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to retrieve state", result["error"]) - - @tool - async def states_update( - state_id: str, - project_id: Optional[str] = None, - workspace_slug: Optional[str] = None, - name: Optional[str] = None, - color: Optional[str] = None, - description: Optional[str] = None, - sequence: Optional[int] = None, - group: Optional[str] = None, - is_triage: Optional[bool] = None, - default: Optional[bool] = None, - external_source: Optional[str] = None, - external_id: Optional[str] = None, - ) -> Dict[str, Any]: - """Update an existing workflow state. - - Args: - state_id: State ID (required) - name: New state name - color: New state color - description: New state description - sequence: Display sequence order - group: State group (backlog, unstarted, started, completed, cancelled) - is_triage: Whether this is a triage state - default: Whether this is the default state - external_source: External source identifier (e.g., "jira") - external_id: External system ID - project_id: Project ID (required - provide from conversation context or previous actions) - workspace_slug: Workspace slug (provide if known, otherwise auto-detected) - """ - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - # Build update data with only non-None values - update_data = { - k: v - for k, v in { - "name": name, - "color": color, - "description": description, - "sequence": sequence, - "group": group, - "is_triage": is_triage, - "default": default, - "external_source": external_source, - "external_id": external_id, - }.items() - if v is not None - } - - result = await method_executor.execute( - "states", - "update", - state_id=state_id, - project_id=project_id, - workspace_slug=workspace_slug, - **update_data, - ) - - if result["success"]: - return await PlaneToolBase.format_success_payload_with_url(f"Successfully updated state '{state_id}'", result["data"], "state", context) - else: - return PlaneToolBase.format_error_payload("Failed to update state", result["error"]) - - return [states_create, states_list, states_retrieve, states_update] +# from typing import Optional +# from langchain_core.tools import tool +# from .base import PlaneToolBase +# +# def get_state_tools(method_executor, context): +# """Return LangChain tools for the states category using method_executor and context.""" +# +# @tool +# async def states_create( +# name: str, +# color: str, +# project_id: Optional[str] = None, +# workspace_slug: Optional[str] = None, +# description: Optional[str] = None, +# group: Optional[str] = None, +# sequence: Optional[int] = None, +# is_triage: Optional[bool] = None, +# default: Optional[bool] = None, +# external_source: Optional[str] = None, +# external_id: Optional[str] = None, +# ) -> Dict[str, Any]: +# """Create a new workflow state. +# +# Args: +# name: State name (required) +# color: State color in hex format (required) +# project_id: Project ID (required - provide from conversation context or previous actions) +# workspace_slug: Workspace slug (provide if known, otherwise auto-detected) +# description: State description +# group: State group (backlog, unstarted, started, completed, cancelled) +# sequence: Display sequence order +# is_triage: Whether this is a triage state +# default: Whether this is the default state +# external_source: External source identifier (e.g., "jira") +# external_id: External system ID +# """ +# # Auto-fill from context if not provided +# if workspace_slug is None and "workspace_slug" in context: +# workspace_slug = context["workspace_slug"] +# if project_id is None and "project_id" in context: +# project_id = context["project_id"] +# +# result = await method_executor.execute( +# "states", +# "create", +# name=name, +# color=color, +# project_id=project_id, +# workspace_slug=workspace_slug, +# description=description, +# group=group, +# sequence=sequence, +# is_triage=is_triage, +# default=default, +# external_source=external_source, +# external_id=external_id, +# ) +# +# if result["success"]: +# return await PlaneToolBase.format_success_payload_with_url(f"Successfully created state '{name}'", result["data"], "state", context) +# else: +# return PlaneToolBase.format_error_payload("Failed to create state", result["error"]) +# +# @tool +# async def states_list( +# project_id: Optional[str] = None, +# workspace_slug: Optional[str] = None, +# ) -> Dict[str, Any]: +# """List all workflow states in a project.""" +# # Auto-fill from context if not provided +# if workspace_slug is None and "workspace_slug" in context: +# workspace_slug = context["workspace_slug"] +# if project_id is None and "project_id" in context: +# project_id = context["project_id"] +# +# result = await method_executor.execute( +# "states", +# "list", +# project_id=project_id, +# workspace_slug=workspace_slug, +# ) +# +# if result["success"]: +# return PlaneToolBase.format_success_payload("Successfully retrieved states list", result["data"]) +# else: +# return PlaneToolBase.format_error_payload("Failed to list states", result["error"]) +# +# @tool +# async def states_retrieve( +# state_id: str, +# project_id: Optional[str] = None, +# workspace_slug: Optional[str] = None, +# ) -> Dict[str, Any]: +# """Retrieve details of a specific workflow state. +# +# Args: +# state_id: State ID (required) +# project_id: Project ID (required - provide from conversation context or previous actions) +# workspace_slug: Workspace slug (provide if known, otherwise auto-detected) +# """ +# # Auto-fill from context if not provided +# if workspace_slug is None and "workspace_slug" in context: +# workspace_slug = context["workspace_slug"] +# if project_id is None and "project_id" in context: +# project_id = context["project_id"] +# +# result = await method_executor.execute( +# "states", +# "retrieve", +# state_id=state_id, +# project_id=project_id, +# workspace_slug=workspace_slug, +# ) +# +# if result["success"]: +# return PlaneToolBase.format_success_payload("Successfully retrieved state details", result["data"]) +# else: +# return PlaneToolBase.format_error_payload("Failed to retrieve state", result["error"]) +# +# @tool +# async def states_update( +# state_id: str, +# project_id: Optional[str] = None, +# workspace_slug: Optional[str] = None, +# name: Optional[str] = None, +# color: Optional[str] = None, +# description: Optional[str] = None, +# sequence: Optional[int] = None, +# group: Optional[str] = None, +# is_triage: Optional[bool] = None, +# default: Optional[bool] = None, +# external_source: Optional[str] = None, +# external_id: Optional[str] = None, +# ) -> Dict[str, Any]: +# """Update an existing workflow state. +# +# Args: +# state_id: State ID (required) +# name: New state name +# color: New state color +# description: New state description +# sequence: Display sequence order +# group: State group (backlog, unstarted, started, completed, cancelled) +# is_triage: Whether this is a triage state +# default: Whether this is the default state +# external_source: External source identifier (e.g., "jira") +# external_id: External system ID +# project_id: Project ID (required - provide from conversation context or previous actions) +# workspace_slug: Workspace slug (provide if known, otherwise auto-detected) +# """ +# # Auto-fill from context if not provided +# if workspace_slug is None and "workspace_slug" in context: +# workspace_slug = context["workspace_slug"] +# if project_id is None and "project_id" in context: +# project_id = context["project_id"] +# +# # Build update data with only non-None values +# update_data = { +# k: v +# for k, v in { +# "name": name, +# "color": color, +# "description": description, +# "sequence": sequence, +# "group": group, +# "is_triage": is_triage, +# "default": default, +# "external_source": external_source, +# "external_id": external_id, +# }.items() +# if v is not None +# } +# +# result = await method_executor.execute( +# "states", +# "update", +# state_id=state_id, +# project_id=project_id, +# workspace_slug=workspace_slug, +# **update_data, +# ) +# +# if result["success"]: +# return await PlaneToolBase.format_success_payload_with_url(f"Successfully updated state '{state_id}'", result["data"], "state", context) +# else: +# return PlaneToolBase.format_error_payload("Failed to update state", result["error"]) +# +# return [states_create, states_list, states_retrieve, states_update] diff --git a/apps/pi/pi/services/actions/tools/stickies.py b/apps/pi/pi/services/actions/tools/stickies.py new file mode 100644 index 0000000000..1f5991ad3f --- /dev/null +++ b/apps/pi/pi/services/actions/tools/stickies.py @@ -0,0 +1,242 @@ +# SPDX-FileCopyrightText: 2023-present Plane Software, Inc. +# SPDX-License-Identifier: LicenseRef-Plane-Commercial +# +# Licensed under the Plane Commercial License (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# https://plane.so/legals/eula +# +# DO NOT remove or modify this notice. +# NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited. + +""" +Stickies API tools for Plane quick notes operations. +""" + +from typing import Any +from typing import Dict + +from pi.services.actions.tool_generator import generate_tools_for_category +from pi.services.actions.tool_metadata import ToolMetadata +from pi.services.actions.tool_metadata import ToolParameter + +# ============================================================================ +# STICKIES-SPECIFIC HANDLER FUNCTIONS +# ============================================================================ + + +async def _stickies_pre_handler( + metadata: ToolMetadata, + kwargs: Dict[str, Any], + context: Dict[str, Any], + category: str, + method_key: str, + method_executor: Any = None, +) -> Dict[str, Any]: + """Pre-processing handler for stickies tools. + + Handles: + - Auto-generate description_html from description if not provided + """ + # If description is provided but description_html is not, auto-generate HTML version + # The UI displays description_html, so we need to ensure it's populated + if method_key in ["create", "update"]: + if "description" in kwargs and kwargs["description"] is not None: + if "description_html" not in kwargs or kwargs.get("description_html") is None: + # Convert plain text to simple HTML paragraph + # Escape HTML special characters and preserve line breaks + import html + + escaped_description = html.escape(kwargs["description"]) + # Replace newlines with{escaped_description}
" + + return kwargs + + +# ============================================================================ +# STICKIES TOOL DEFINITIONS +# ============================================================================ + +STICKY_TOOL_DEFINITIONS: Dict[str, ToolMetadata] = { + "create": ToolMetadata( + name="stickies_create", + description="Create a new sticky note for quick annotations. The main content/text should go in 'description' parameter.", + sdk_method="create_sticky", + returns_entity_type="sticky", + pre_handler=_stickies_pre_handler, + parameters=[ + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (auto-detected from context)", + auto_fill_from_context=True, + ), + ToolParameter( + name="name", + type="Optional[str]", + required=False, + description="Optional title/heading for the sticky note", + ), + ToolParameter( + name="description", + type="Optional[str]", + required=False, + description="Main content/text of the sticky note (use this for user's content)", + ), + ToolParameter( + name="description_html", + type="Optional[str]", + required=False, + description="HTML formatted content (optional, auto-generated from description if not provided)", + ), + ToolParameter( + name="color", + type="Optional[str]", + required=False, + description="Text color hex code (optional)", + ), + ToolParameter( + name="background_color", + type="Optional[str]", + required=False, + description="Background color hex code (optional)", + ), + ToolParameter( + name="logo_props", + type="Optional[dict]", + required=False, + description="Logo configuration JSON (optional)", + ), + ], + ), + "list": ToolMetadata( + name="stickies_list", + description="List all sticky notes in the workspace", + sdk_method="list_stickies", + parameters=[ + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (auto-detected from context)", + auto_fill_from_context=True, + ), + ], + ), + "retrieve": ToolMetadata( + name="stickies_retrieve", + description="Retrieve a single sticky note by ID", + sdk_method="retrieve_sticky", + parameters=[ + ToolParameter( + name="sticky_id", + type="str", + required=True, + description="Sticky ID (required)", + ), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (auto-detected from context)", + auto_fill_from_context=True, + ), + ], + ), + "update": ToolMetadata( + name="stickies_update", + description="Update sticky note details", + sdk_method="update_sticky", + returns_entity_type="sticky", + pre_handler=_stickies_pre_handler, + parameters=[ + ToolParameter( + name="sticky_id", + type="str", + required=True, + description="Sticky ID (required)", + ), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (auto-detected from context)", + auto_fill_from_context=True, + ), + ToolParameter( + name="name", + type="Optional[str]", + required=False, + description="New sticky note title", + ), + ToolParameter( + name="description", + type="Optional[str]", + required=False, + description="New plain text description", + ), + ToolParameter( + name="description_html", + type="Optional[str]", + required=False, + description="New HTML description", + ), + ToolParameter( + name="color", + type="Optional[str]", + required=False, + description="New text color hex code", + ), + ToolParameter( + name="background_color", + type="Optional[str]", + required=False, + description="New background color hex code", + ), + ToolParameter( + name="logo_props", + type="Optional[dict]", + required=False, + description="New logo configuration JSON", + ), + ], + ), + "delete": ToolMetadata( + name="stickies_delete", + description="Delete a sticky note", + sdk_method="delete_sticky", + parameters=[ + ToolParameter( + name="sticky_id", + type="str", + required=True, + description="Sticky ID (required)", + ), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (auto-detected from context)", + auto_fill_from_context=True, + ), + ], + ), +} + + +# ============================================================================ +# TOOL FACTORY +# ============================================================================ + + +def get_sticky_tools(method_executor, context): + """Return LangChain tools for the stickies category using auto-generation from metadata.""" + return generate_tools_for_category( + category="stickies", + method_executor=method_executor, + context=context, + tool_definitions=STICKY_TOOL_DEFINITIONS, + ) diff --git a/apps/pi/pi/services/actions/tools/teamspaces.py b/apps/pi/pi/services/actions/tools/teamspaces.py new file mode 100644 index 0000000000..84f2660b9e --- /dev/null +++ b/apps/pi/pi/services/actions/tools/teamspaces.py @@ -0,0 +1,324 @@ +# SPDX-FileCopyrightText: 2023-present Plane Software, Inc. +# SPDX-License-Identifier: LicenseRef-Plane-Commercial +# +# Licensed under the Plane Commercial License (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# https://plane.so/legals/eula +# +# DO NOT remove or modify this notice. +# NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited. + +""" +Teamspaces API tools for Plane team collaboration operations. +""" + +from typing import Dict + +from pi.services.actions.tool_generator import generate_tools_for_category +from pi.services.actions.tool_metadata import ToolMetadata +from pi.services.actions.tool_metadata import ToolParameter + +# ============================================================================ +# TEAMSPACES TOOL DEFINITIONS +# ============================================================================ + +TEAMSPACE_TOOL_DEFINITIONS: Dict[str, ToolMetadata] = { + "create": ToolMetadata( + name="teamspaces_create", + description="Create a new teamspace for team collaboration", + sdk_method="create_teamspace", + returns_entity_type="teamspace", + parameters=[ + ToolParameter( + name="name", + type="str", + required=True, + description="Teamspace name (required)", + ), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (auto-detected from context)", + auto_fill_from_context=True, + ), + ToolParameter( + name="description_html", + type="Optional[str]", + required=False, + description="HTML description of the teamspace", + ), + ToolParameter( + name="logo_props", + type="Optional[dict]", + required=False, + description="Logo configuration JSON", + ), + ToolParameter( + name="lead", + type="str", + required=False, + description="Team lead user ID (optional)", + ), + ], + ), + "list": ToolMetadata( + name="teamspaces_list", + description="List teamspaces in the workspace", + sdk_method="list_teamspaces", + parameters=[ + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (auto-detected from context)", + auto_fill_from_context=True, + ), + ], + ), + "retrieve": ToolMetadata( + name="teamspaces_retrieve", + description="Retrieve a single teamspace by ID", + sdk_method="retrieve_teamspace", + parameters=[ + ToolParameter( + name="teamspace_id", + type="str", + required=True, + description="Teamspace ID (required)", + ), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (auto-detected from context)", + auto_fill_from_context=True, + ), + ], + ), + "update": ToolMetadata( + name="teamspaces_update", + description="Update teamspace details", + sdk_method="update_teamspace", + returns_entity_type="teamspace", + parameters=[ + ToolParameter( + name="teamspace_id", + type="str", + required=True, + description="Teamspace ID (required)", + ), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (auto-detected from context)", + auto_fill_from_context=True, + ), + ToolParameter( + name="name", + type="Optional[str]", + required=False, + description="New teamspace name", + ), + ToolParameter( + name="description_html", + type="Optional[str]", + required=False, + description="New HTML description", + ), + ToolParameter( + name="logo_props", + type="Optional[dict]", + required=False, + description="New logo configuration JSON", + ), + ToolParameter( + name="lead", + type="str", + required=False, + description="Team lead user ID (use empty string to remove the lead)", + ), + ], + ), + "delete": ToolMetadata( + name="teamspaces_delete", + description="Delete a teamspace", + sdk_method="delete_teamspace", + parameters=[ + ToolParameter( + name="teamspace_id", + type="str", + required=True, + description="Teamspace ID (required)", + ), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (auto-detected from context)", + auto_fill_from_context=True, + ), + ], + ), + "add_members": ToolMetadata( + name="teamspaces_add_members", + description="Add members to a teamspace", + sdk_method="add_teamspace_members", + parameters=[ + ToolParameter( + name="teamspace_id", + type="str", + required=True, + description="Teamspace ID (required)", + ), + ToolParameter( + name="member_ids", + type="List[str]", + required=True, + description="List of member (user) IDs to add (required)", + ), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (auto-detected from context)", + auto_fill_from_context=True, + ), + ], + ), + "list_members": ToolMetadata( + name="teamspaces_list_members", + description="List members in a teamspace", + sdk_method="list_teamspace_members", + parameters=[ + ToolParameter( + name="teamspace_id", + type="str", + required=True, + description="Teamspace ID (required)", + ), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (auto-detected from context)", + auto_fill_from_context=True, + ), + ], + ), + "remove_members": ToolMetadata( + name="teamspaces_remove_members", + description="Remove members from a teamspace", + sdk_method="remove_teamspace_members", + parameters=[ + ToolParameter( + name="teamspace_id", + type="str", + required=True, + description="Teamspace ID (required)", + ), + ToolParameter( + name="member_ids", + type="List[str]", + required=True, + description="List of member (user) IDs to remove (required)", + ), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (auto-detected from context)", + auto_fill_from_context=True, + ), + ], + ), + "add_projects": ToolMetadata( + name="teamspaces_add_projects", + description="Add projects to a teamspace", + sdk_method="add_teamspace_projects", + parameters=[ + ToolParameter( + name="teamspace_id", + type="str", + required=True, + description="Teamspace ID (required)", + ), + ToolParameter( + name="project_ids", + type="List[str]", + required=True, + description="List of project IDs to add (required)", + ), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (auto-detected from context)", + auto_fill_from_context=True, + ), + ], + ), + "list_projects": ToolMetadata( + name="teamspaces_list_projects", + description="List projects in a teamspace", + sdk_method="list_teamspace_projects", + parameters=[ + ToolParameter( + name="teamspace_id", + type="str", + required=True, + description="Teamspace ID (required)", + ), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (auto-detected from context)", + auto_fill_from_context=True, + ), + ], + ), + "remove_projects": ToolMetadata( + name="teamspaces_remove_projects", + description="Remove projects from a teamspace", + sdk_method="remove_teamspace_projects", + parameters=[ + ToolParameter( + name="teamspace_id", + type="str", + required=True, + description="Teamspace ID (required)", + ), + ToolParameter( + name="project_ids", + type="List[str]", + required=True, + description="List of project IDs to remove (required)", + ), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (auto-detected from context)", + auto_fill_from_context=True, + ), + ], + ), +} + + +# ============================================================================ +# TOOL FACTORY +# ============================================================================ + + +def get_teamspace_tools(method_executor, context): + """Return LangChain tools for the teamspaces category using auto-generation from metadata.""" + return generate_tools_for_category( + category="teamspaces", + method_executor=method_executor, + context=context, + tool_definitions=TEAMSPACE_TOOL_DEFINITIONS, + ) diff --git a/apps/pi/pi/services/actions/tools/types.py b/apps/pi/pi/services/actions/tools/types.py index 007a570894..25bde92119 100644 --- a/apps/pi/pi/services/actions/tools/types.py +++ b/apps/pi/pi/services/actions/tools/types.py @@ -13,197 +13,158 @@ Types API tools for Plane issue type management. """ -from typing import Any from typing import Dict -from typing import Optional -from langchain_core.tools import tool +from pi.services.actions.tool_generator import generate_tools_for_category +from pi.services.actions.tool_metadata import ToolMetadata +from pi.services.actions.tool_metadata import ToolParameter -from .base import PlaneToolBase +# ============================================================================ +# TYPES TOOL DEFINITIONS +# ============================================================================ -# Factory wired via CATEGORY_TO_PROVIDER in tools/__init__.py -# Returns LangChain tools implementing type actions +TYPE_TOOL_DEFINITIONS: Dict[str, ToolMetadata] = { + "create": ToolMetadata( + name="types_create", + description="Create a new work item type", + sdk_method="create_issue_type", + parameters=[ + ToolParameter(name="name", type="str", required=True, description="Name of the work item type (required)"), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (auto-detected from context)", + auto_fill_from_context=True, + ), + ToolParameter( + name="project_id", + type="Optional[str]", + required=False, + description="Project ID (auto-detected from context)", + auto_fill_from_context=True, + ), + ToolParameter(name="description", type="Optional[str]", required=False, description="Optional description of the type"), + ToolParameter( + name="project_ids", type="Optional[List[str]]", required=False, description="List of project IDs to associate with this type" + ), + ToolParameter(name="is_epic", type="Optional[bool]", required=False, description="Flag to mark this type as an epic type"), + ToolParameter(name="is_active", type="Optional[bool]", required=False, description="Activation status of the type"), + ToolParameter( + name="external_source", type="Optional[str]", required=False, description="External integration source (e.g., 'jira', 'github')" + ), + ToolParameter(name="external_id", type="Optional[str]", required=False, description="External system identifier for this type"), + ], + ), + "list": ToolMetadata( + name="types_list", + description="List all work item types", + sdk_method="list_issue_types", + parameters=[ + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (auto-detected from context)", + auto_fill_from_context=True, + ), + ToolParameter( + name="project_id", + type="Optional[str]", + required=False, + description="Project ID (auto-detected from context)", + auto_fill_from_context=True, + ), + ], + ), + "retrieve": ToolMetadata( + name="types_retrieve", + description="Get a single type by ID", + sdk_method="retrieve_issue_type", + parameters=[ + ToolParameter(name="type_id", type="str", required=True, description="Type ID (required)"), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (auto-detected from context)", + auto_fill_from_context=True, + ), + ToolParameter( + name="project_id", + type="Optional[str]", + required=False, + description="Project ID (auto-detected from context)", + auto_fill_from_context=True, + ), + ], + ), + "update": ToolMetadata( + name="types_update", + description="Update work item type details", + sdk_method="update_issue_type", + parameters=[ + ToolParameter(name="type_id", type="str", required=True, description="UUID of the work item type to update (required)"), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (auto-detected from context)", + auto_fill_from_context=True, + ), + ToolParameter( + name="project_id", + type="Optional[str]", + required=False, + description="Project ID (auto-detected from context)", + auto_fill_from_context=True, + ), + ToolParameter(name="name", type="Optional[str]", required=False, description="Updated name of the type"), + ToolParameter(name="description", type="Optional[str]", required=False, description="Updated description"), + ToolParameter( + name="project_ids", type="Optional[list[str]]", required=False, description="Updated list of project IDs to associate with this type" + ), + ToolParameter(name="is_epic", type="Optional[bool]", required=False, description="Updated epic type flag"), + ToolParameter(name="is_active", type="Optional[bool]", required=False, description="Updated activation status"), + ToolParameter(name="external_source", type="Optional[str]", required=False, description="Updated external integration source"), + ToolParameter(name="external_id", type="Optional[str]", required=False, description="Updated external system identifier"), + ], + ), + "delete": ToolMetadata( + name="types_delete", + description="Delete a type", + sdk_method="delete_issue_type", + parameters=[ + ToolParameter(name="type_id", type="str", required=True, description="Type ID (required)"), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (auto-detected from context)", + auto_fill_from_context=True, + ), + ToolParameter( + name="project_id", + type="Optional[str]", + required=False, + description="Project ID (auto-detected from context)", + auto_fill_from_context=True, + ), + ], + ), +} + + +# ============================================================================ +# TOOL FACTORY +# ============================================================================ def get_type_tools(method_executor, context): - """Return LangChain tools for the types category using method_executor and context.""" - """Get all Types API tools.""" - - @tool - async def types_create( - name: str, - description: Optional[str] = None, - project_ids: Optional[list[str]] = None, - is_epic: Optional[bool] = None, - is_active: Optional[bool] = None, - external_source: Optional[str] = None, - external_id: Optional[str] = None, - project_id: Optional[str] = None, - workspace_slug: Optional[str] = None, - ) -> Dict[str, Any]: - """Create a new work item type. - - Args: - name: Name of the work item type (required) - description: Optional description of the type - project_ids: List of project IDs to associate with this type - is_epic: Flag to mark this type as an epic type - is_active: Activation status of the type - external_source: External integration source (e.g., 'jira', 'github') - external_id: External system identifier for this type - project_id: UUID of the project (auto-filled from context) - workspace_slug: Workspace slug identifier (auto-filled from context) - """ - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - result = await method_executor.execute( - "types", - "create", - name=name, - description=description, - project_ids=project_ids, - is_epic=is_epic, - is_active=is_active, - external_source=external_source, - external_id=external_id, - project_id=project_id, - workspace_slug=workspace_slug, - ) - if result["success"]: - return PlaneToolBase.format_success_payload(f"Successfully created type '{name}'", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to create type", result["error"]) - - @tool - async def types_list(project_id: Optional[str] = None, workspace_slug: Optional[str] = None) -> Dict[str, Any]: - """List all work item types.""" - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - result = await method_executor.execute("types", "list", project_id=project_id, workspace_slug=workspace_slug) - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully retrieved types list", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to list types", result["error"]) - - @tool - async def types_retrieve( - type_id: str, - project_id: Optional[str] = None, - workspace_slug: Optional[str] = None, - ) -> Dict[str, Any]: - """Get a single type by ID.""" - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - result = await method_executor.execute( - "types", - "retrieve", - type_id=type_id, - project_id=project_id, - workspace_slug=workspace_slug, - ) - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully retrieved type", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to retrieve type", result["error"]) - - @tool - async def types_update( - type_id: str, - name: Optional[str] = None, - description: Optional[str] = None, - project_ids: Optional[list[str]] = None, - is_epic: Optional[bool] = None, - is_active: Optional[bool] = None, - external_source: Optional[str] = None, - external_id: Optional[str] = None, - project_id: Optional[str] = None, - workspace_slug: Optional[str] = None, - ) -> Dict[str, Any]: - """Update work item type details. - - Args: - type_id: UUID of the work item type to update (required) - name: Updated name of the type - description: Updated description - project_ids: Updated list of project IDs to associate with this type - is_epic: Updated epic type flag - is_active: Updated activation status - external_source: Updated external integration source - external_id: Updated external system identifier - project_id: UUID of the project (auto-filled from context) - workspace_slug: Workspace slug identifier (auto-filled from context) - """ - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - # Build update data - update_data: dict[str, Any] = {} - if name is not None: - update_data["name"] = name - if description is not None: - update_data["description"] = description - if project_ids is not None: - update_data["project_ids"] = project_ids - if is_epic is not None: - update_data["is_epic"] = is_epic - if is_active is not None: - update_data["is_active"] = is_active - if external_source is not None: - update_data["external_source"] = external_source - if external_id is not None: - update_data["external_id"] = external_id - - result = await method_executor.execute( - "types", - "update", - type_id=type_id, - project_id=project_id, - workspace_slug=workspace_slug, - **update_data, - ) - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully updated type", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to update type", result["error"]) - - @tool - async def types_delete( - type_id: str, - project_id: Optional[str] = None, - workspace_slug: Optional[str] = None, - ) -> Dict[str, Any]: - """Delete a type.""" - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - result = await method_executor.execute( - "types", - "delete", - type_id=type_id, - project_id=project_id, - workspace_slug=workspace_slug, - ) - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully deleted type", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to delete type", result["error"]) - - return [types_create, types_list, types_retrieve, types_update, types_delete] + """Return LangChain tools for the types category using auto-generation from metadata.""" + return generate_tools_for_category( + category="types", + method_executor=method_executor, + context=context, + tool_definitions=TYPE_TOOL_DEFINITIONS, + ) diff --git a/apps/pi/pi/services/actions/tools/users.py b/apps/pi/pi/services/actions/tools/users.py index f36bd50d80..2642e10c76 100644 --- a/apps/pi/pi/services/actions/tools/users.py +++ b/apps/pi/pi/services/actions/tools/users.py @@ -11,29 +11,59 @@ """ Users API tools for Plane user management operations. + +MIGRATED TO AUTO-GENERATED TOOLS +Tool metadata defined in this file (USER_TOOL_DEFINITIONS) for modularity. +Old manual definitions kept below for comparison/rollback safety. """ -from typing import Any from typing import Dict -from langchain_core.tools import tool +from pi.services.actions.tool_generator import generate_tools_for_category +from pi.services.actions.tool_metadata import ToolMetadata -from .base import PlaneToolBase - -# Factory wired via CATEGORY_TO_PROVIDER in tools/__init__.py -# Returns LangChain tools implementing user actions +# Tool metadata for users category +USER_TOOL_DEFINITIONS: Dict[str, ToolMetadata] = { + "get_current_user": ToolMetadata( + name="users_get_current", + description="Get current user information", + sdk_method="get_current_user", + parameters=[], + ), +} def get_user_tools(method_executor, context): - """Return LangChain tools for the users category using method_executor and context.""" + """Return LangChain tools for the users category using auto-generation from metadata.""" - @tool - async def users_get_current() -> Dict[str, Any]: - """Get current user information.""" - result = await method_executor.execute("users", "get_current") - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully retrieved current user", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to get current user", result["error"]) + user_tools = generate_tools_for_category( + category="users", + method_executor=method_executor, + context=context, + tool_definitions=USER_TOOL_DEFINITIONS, + ) - return [users_get_current] + return user_tools + + +# ============================================================================ +# OLD MANUAL TOOL DEFINITIONS (COMMENTED OUT - KEPT FOR COMPARISON) +# To rollback: uncomment below and comment out the auto-generation code above +# ============================================================================ + +# from langchain_core.tools import tool +# from .base import PlaneToolBase +# +# def get_user_tools(method_executor, context): +# """Return LangChain tools for the users category using method_executor and context.""" +# +# @tool +# async def users_get_current() -> Dict[str, Any]: +# """Get current user information.""" +# result = await method_executor.execute("users", "get_current") +# if result["success"]: +# return PlaneToolBase.format_success_payload("Successfully retrieved current user", result["data"]) +# else: +# return PlaneToolBase.format_error_payload("Failed to get current user", result["error"]) +# +# return [users_get_current] diff --git a/apps/pi/pi/services/actions/tools/workitems.py b/apps/pi/pi/services/actions/tools/workitems.py index 5b1640b32d..a6f057287d 100644 --- a/apps/pi/pi/services/actions/tools/workitems.py +++ b/apps/pi/pi/services/actions/tools/workitems.py @@ -11,18 +11,22 @@ """ Work Items API tools for Plane issue/task management operations. + +MIGRATED TO AUTO-GENERATED TOOLS +Tool metadata defined in this file. Custom pre/post handlers for complex logic. +Special handling includes: state/type resolution, epic type injection, identifier enrichment, relation validation. +Old manual definitions kept below for comparison/rollback safety. """ import logging import uuid from typing import Any from typing import Dict -from typing import List from typing import Optional -from langchain_core.tools import tool - -from .base import PlaneToolBase +from pi.services.actions.tool_generator import generate_tools_for_category +from pi.services.actions.tool_metadata import ToolMetadata +from pi.services.actions.tool_metadata import ToolParameter log = logging.getLogger(__name__) @@ -38,8 +42,10 @@ RELATION_TYPES = { "finish_after": "finish_after", } -# Factory wired via CATEGORY_TO_PROVIDER in tools/__init__.py -# Returns LangChain tools implementing work-item actions + +# ============================================================================ +# HELPER FUNCTIONS (Preserved from original) +# ============================================================================ async def resolve_state_to_uuid(state: Optional[str], project_id: Optional[str], workspace_slug: Optional[str] = None) -> Optional[str]: @@ -159,84 +165,248 @@ async def get_epic_type_id(method_executor, project_id: str, workspace_slug: str return None -def get_workitem_tools(method_executor, context): - """Return LangChain tools for the workitems category using method_executor and context.""" +# ============================================================================ +# WORKITEMS-SPECIFIC HANDLER FUNCTIONS +# ============================================================================ - @tool - async def workitems_create( - name: str, - project_id: Optional[str] = None, - description_html: Optional[str] = None, - priority: Optional[str] = None, - state: Optional[str] = None, - assignees: Optional[List[str]] = None, - labels: Optional[List[str]] = None, - start_date: Optional[str] = None, - target_date: Optional[str] = None, - type_id: Optional[str] = None, - parent: Optional[str] = None, - external_id: Optional[str] = None, - external_source: Optional[str] = None, - is_draft: Optional[bool] = None, - ) -> Dict[str, Any]: - """Create a new work item/issue. - Args: - name: Work item title (required) - project_id: Project ID (required - provide from conversation context or previous actions) - workspace_slug: Workspace slug (required - provide from conversation context) - description_html: Workitem description in HTML format - priority: Priority level (high, medium, low, urgent, none) - state: State name or UUID for the workitem (e.g., "todo", "in progress", "done") - assignees: List of assignee user IDs - labels: List of label IDs - start_date: Start date (YYYY-MM-DD format) - target_date: Target completion date (YYYY-MM-DD format) - type_id: Workitem type ID (optional). MUST be the 'issue_types_id' (UUID of the issue type definition), NOT the 'project_issue_types_id'. - parent: Parent work-item ID (optional) - external_id: External system identifier (optional) - external_source: External system source name (optional) - is_draft: Create as draft (optional) +# ============================================================================ +# HELPER FUNCTIONS +# ============================================================================ - Note: To add the created work item to a module or cycle, use modules_add_work_items - or cycles_add_work_items after creation. These cannot be set during creation. - """ - # Auto-fill workspace_slug from context (hidden from LLM) - workspace_slug = context.get("workspace_slug") - # Auto-fill project_id from context if not provided - if project_id is None and "project_id" in context: - project_id = context["project_id"] +async def fetch_available_types_for_project(method_executor, project_id: str, workspace_slug: str) -> Optional[str]: + """Fetch available work item types for a project and format them for LLM consumption. - # Resolve state name to UUID if needed - resolved_state = await resolve_state_to_uuid(state, project_id, workspace_slug) + This helper is used to enrich tool descriptions with project-specific type information + during tool generation (at planning time), making types visible to the LLM. - # Resolve type name to UUID if needed - resolved_type_id = await resolve_type_to_uuid(type_id, project_id, workspace_slug) + Args: + method_executor: The method executor to call types API + project_id: Project UUID + workspace_slug: Workspace slug + Returns: + Formatted string of available types, or None if fetch fails + Example: "- Bug (ID: 6e8fbc42...): Defects and regressions\\n- Feature (ID: 104eb637...): New functionality" + """ + if not project_id or not workspace_slug or not method_executor: + return None + + try: result = await method_executor.execute( - "workitems", - "create", - name=name, - description_html=description_html, + "types", + "list", project_id=project_id, workspace_slug=workspace_slug, - priority=priority, - state=resolved_state, - assignees=assignees, - labels=labels, - start_date=start_date, - target_date=target_date, - type_id=resolved_type_id, - parent=parent, - external_id=external_id, - external_source=external_source, - is_draft=is_draft, ) - if result["success"]: - # Enrich response data with missing fields for URL construction - data = result["data"] + if not result or not result.get("success"): + return None + + results = result.get("data", {}).get("results", []) + if not results: + return None + + # Build a human-readable list of types + type_lines = [] + for t in results[:10]: # Limit to 10 types to avoid overwhelming the description + type_name = t.get("name", "Unknown") + type_id = t.get("id", "") + type_desc = t.get("description", "") + + # Format: "- Bug (ID: 6e8fbc42-2c07-4880-9b65-d3ef8258b784): For defects and issues" + line = f"- {type_name}" + if type_id: + line += f" (ID: {type_id})" # Full UUID so LLM can use it directly in tool args + if type_desc: + line += f": {type_desc[:80]}" # Limit description length + type_lines.append(line) + + if type_lines: + formatted = "\\n".join(type_lines) + log.info(f"Fetched {len(results)} types for project {project_id}") + return formatted + + except Exception as e: + log.warning(f"Failed to fetch types for project {project_id}: {e}") + + return None + + +def _sync_fetch_types(method_executor, project_id: str, workspace_slug: str) -> Optional[str]: + """Synchronous wrapper to fetch types from async function. + + Bridges the sync/async boundary by running the async fetch function in the event loop. + """ + import asyncio + + try: + # Check if there's a running event loop + try: + asyncio.get_running_loop() + # We're in a running async context - create a task and wait for it + # Using asyncio.ensure_future + loop methods to avoid blocking + import concurrent.futures + + with concurrent.futures.ThreadPoolExecutor() as pool: + future = pool.submit(asyncio.run, fetch_available_types_for_project(method_executor, project_id, workspace_slug)) + return future.result(timeout=5) # 5 second timeout + except RuntimeError: + # No running loop - safe to use asyncio.run() + return asyncio.run(fetch_available_types_for_project(method_executor, project_id, workspace_slug)) + except Exception as e: + log.warning(f"Failed to fetch types for project {project_id}: {e}") + return None + + +def _enrich_type_id_descriptions(tool_definitions: Dict[str, "ToolMetadata"], types_text: str) -> None: + """Inject available types into type_id parameter descriptions. + + Modifies tool definitions in-place to add project-specific type information. + """ + create_desc = ( + f"Work item type to categorize this work item. Available types in this project:\\n{types_text}\\n" + f"Provide the full type ID (UUID) from the list above. If not specified, project's default type will be used." + ) + + update_desc = ( + f"New work item type to categorize this work item. Available types in this project:\\n{types_text}\\n" + f"Provide the full type ID (UUID) from the list above." + ) + + # Update create tool + if "create" in tool_definitions: + for param in tool_definitions["create"].parameters: + if param.name == "type_id": + param.description = create_desc + break + + # Update update tool + if "update" in tool_definitions: + for param in tool_definitions["update"].parameters: + if param.name == "type_id": + param.description = update_desc + break + + +# ============================================================================ +# PRE/POST HANDLERS +# ============================================================================ + + +async def _workitems_pre_handler( + metadata: ToolMetadata, + kwargs: Dict[str, Any], + context: Dict[str, Any], + category: str, + method_key: str, + method_executor: Any = None, +) -> Dict[str, Any]: + """Pre-processing handler for workitems tools. + + Handles: + - create/update/list: state and type resolution + - create_epic: epic type injection + - create_relation: relation type validation + """ + tool_name = metadata.name + project_id = kwargs.get("project_id") + workspace_slug = kwargs.get("workspace_slug") or context.get("workspace_slug") + + # Handle epic tools: inject epic type_id + if tool_name == "create_epic": + if not project_id: + raise ValueError("Project ID is required for epic creation. Please specify a project.") + + epic_type_id = await get_epic_type_id(method_executor, str(project_id), str(workspace_slug or "")) + if not epic_type_id: + raise ValueError("Could not find epic issue type for this project. Please ensure an epic issue type exists.") + + kwargs["type_id"] = epic_type_id + log.info(f"Injected epic type_id {epic_type_id} for create_epic") + + # State resolution for create/update/list/epic tools + if tool_name in ["workitems_create", "workitems_update", "workitems_list", "create_epic", "update_epic"]: + state = kwargs.get("state") + if state: + resolved_state = await resolve_state_to_uuid(state, project_id, workspace_slug) + if resolved_state: + kwargs["state"] = resolved_state + log.debug(f"Resolved state '{state}' to {resolved_state}") + + # Type resolution for create/update (but NOT epics which handle it themselves) + if tool_name in ["workitems_create", "workitems_update"]: + type_id = kwargs.get("type_id") + if type_id: + resolved_type_id = await resolve_type_to_uuid(type_id, project_id, workspace_slug) + if resolved_type_id: + kwargs["type_id"] = resolved_type_id + log.debug(f"Resolved type_id '{type_id}' to {resolved_type_id}") + + # Relation type validation for create_relation + if tool_name == "workitems_create_relation": + relation_type = kwargs.get("relation_type") + if relation_type not in RELATION_TYPES: + valid_types = ", ".join(RELATION_TYPES.keys()) + raise ValueError(f"Relation type '{relation_type}' is not valid. Valid types are: {valid_types}") + + related_issues = kwargs.get("related_issues") + if not related_issues: + raise ValueError("At least one related issue ID must be provided") + + # The SDK expects 'issues' parameter, not 'related_issues' + kwargs["issues"] = kwargs.pop("related_issues") + + # Fix for list: Pass filter args to SDK (BUG FIX) + if tool_name == "workitems_list": + # Build filter arguments and merge them into kwargs + filter_fields = [ + "priority", + "assignees", + "labels", + "start_date", + "target_date", + "created_by", + "updated_by", + "type_id", + "parent", + "is_draft", + "created_at", + "updated_at", + "completed_at", + "archived_at", + ] + + for field in filter_fields: + if field in kwargs and kwargs[field] is not None: + # These are already in kwargs, just ensure they're passed through + pass + + return kwargs + + +async def _workitems_post_handler( + metadata: ToolMetadata, + result: Dict[str, Any], + kwargs: Dict[str, Any], + context: Dict[str, Any], + method_executor: Any, + category: str, + method_key: str, +) -> Dict[str, Any]: + """Post-processing handler for workitems tools. + + Handles: + - create/update/create_epic/update_epic: identifier enrichment for URL construction + - list: URL construction for each workitem in results + """ + tool_name = metadata.name + + # Identifier enrichment for create/update operations + if tool_name in ["workitems_create", "workitems_update", "create_epic", "update_epic"]: + if result.get("success"): + data = result.get("data") if data and isinstance(data, dict) and data.get("id"): try: from pi.app.api.v1.helpers.plane_sql_queries import get_issue_identifier_for_artifact @@ -250,619 +420,805 @@ def get_workitem_tools(method_executor, context): except Exception as e: log.warning(f"Could not enrich workitem data with identifier info: {e}") - return await PlaneToolBase.format_success_payload_with_url( - f"Successfully created work item '{name}'", - data, - "workitem", - context, - ) - else: - return PlaneToolBase.format_error_payload("Failed to create work item", result.get("error")) - - @tool - async def workitems_update( - issue_id: str, - project_id: Optional[str] = None, - name: Optional[str] = None, - description_html: Optional[str] = None, - priority: Optional[str] = None, - state: Optional[str] = None, - assignees: Optional[List[str]] = None, - labels: Optional[List[str]] = None, - start_date: Optional[str] = None, - target_date: Optional[str] = None, - type_id: Optional[str] = None, - parent: Optional[str] = None, - external_id: Optional[str] = None, - external_source: Optional[str] = None, - is_draft: Optional[bool] = None, - ) -> Dict[str, Any]: - """Update an existing work item/issue. - - Args: - issue_id: Work item ID to update (required) - project_id: Project ID (required - provide from conversation context or previous actions) - workspace_slug: Workspace slug (required - provide from conversation context) - name: New work item title - description_html: New description in HTML format - priority: New priority level (high, medium, low, urgent, none) - state: New state name or UUID (e.g., "todo", "in progress", "done") - assignees: New list of assignee user IDs - labels: New list of label IDs - start_date: New start date (YYYY-MM-DD format) - target_date: New target completion date (YYYY-MM-DD format) - type_id: Workitem type ID (optional). MUST be the 'issue_types_id' (UUID of the issue type definition), NOT the 'project_issue_types_id'. - parent: Parent work-item ID (optional) - external_id: External system identifier (optional) - external_source: External system source name (optional) - is_draft: Mark as draft (optional) - - Note: To add/move the work item to a module or cycle, use modules_add_work_items - or cycles_add_work_items. Module/cycle assignment is not supported via update. - """ - # Auto-fill workspace_slug from context (hidden from LLM) - workspace_slug = context.get("workspace_slug") - - # Auto-fill project_id from context if not provided - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - # Resolve state name to UUID if needed - resolved_state = await resolve_state_to_uuid(state, project_id, workspace_slug) - - # Resolve type name to UUID if needed - resolved_type_id = await resolve_type_to_uuid(type_id, project_id, workspace_slug) - - # Build update data with only non-None values to avoid overwriting existing fields - update_data = { - k: v - for k, v in { - "name": name, - "description_html": description_html, - "priority": priority, - "state": resolved_state, - "assignees": assignees, - "labels": labels, - "start_date": start_date, - "target_date": target_date, - "type_id": resolved_type_id, - "parent": parent, - "external_id": external_id, - "external_source": external_source, - "is_draft": is_draft, - }.items() - if v is not None - } - - result = await method_executor.execute( - "workitems", - "update", - issue_id=issue_id, - project_id=project_id, - workspace_slug=workspace_slug, - **update_data, - ) - - if result["success"]: - # Enrich response data with missing fields for URL construction - data = result["data"] - if data and isinstance(data, dict) and data.get("id"): - try: - from pi.app.api.v1.helpers.plane_sql_queries import get_issue_identifier_for_artifact - - # Get the missing project_identifier and sequence_id - identifier_info = await get_issue_identifier_for_artifact(str(data["id"])) - if identifier_info: - data["project_identifier"] = identifier_info.get("project_identifier") - data["sequence_id"] = identifier_info.get("sequence_id") - log.info(f"Enriched workitem update data with identifier info: {identifier_info.get("identifier")}") - except Exception as e: - log.warning(f"Could not enrich workitem update data with identifier info: {e}") - - return await PlaneToolBase.format_success_payload_with_url("Successfully updated work item", data, "workitem", context) - else: - return PlaneToolBase.format_error_payload("Failed to update work item", result["error"]) - - @tool - async def create_epic( - name: str, - project_id: Optional[str] = None, - description_html: Optional[str] = None, - priority: Optional[str] = None, - state: Optional[str] = None, - assignees: Optional[List[str]] = None, - labels: Optional[List[str]] = None, - start_date: Optional[str] = None, - target_date: Optional[str] = None, - external_id: Optional[str] = None, - external_source: Optional[str] = None, - ) -> Dict[str, Any]: - """Create a new epic work item. - - Args: - name: Epic title (required) - project_id: Project ID (required - provide from conversation context or previous actions) - description_html: Epic description in HTML format - priority: Priority level (high, medium, low, urgent, none) - state: State name or ID for the epic (e.g., "done", "in progress", or UUID) - assignees: List of assignee user IDs - labels: List of label IDs - start_date: Start date (YYYY-MM-DD format) - target_date: Target completion date (YYYY-MM-DD format) - external_id: External system identifier (optional) - external_source: External system source name (optional) - - Note: This tool automatically sets the work item type to 'epic' for the specified project. - To add the created epic to a module or cycle, use modules_add_work_items or cycles_add_work_items after creation. - """ - # Auto-fill workspace_slug from context (hidden from LLM) - workspace_slug = context.get("workspace_slug") - - # Auto-fill project_id from context if not provided - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - # Get the epic type ID for this project - if project_id is None: - return PlaneToolBase.format_error_payload("Failed to create epic", "Project ID is required for epic creation. Please specify a project.") - - epic_type_id = await get_epic_type_id(method_executor, project_id, workspace_slug) - if not epic_type_id: - return PlaneToolBase.format_error_payload( - "Failed to create epic", "Could not find epic issue type for this project. Please ensure an epic issue type exists." - ) - - # Resolve state name to UUID if provided as name - resolved_state = await resolve_state_to_uuid(state, project_id, workspace_slug) - - result = await method_executor.execute( - "workitems", - "create", - name=name, - description_html=description_html, - project_id=project_id, - workspace_slug=workspace_slug, - priority=priority, - state=resolved_state, - assignees=assignees, - labels=labels, - start_date=start_date, - target_date=target_date, - type_id=epic_type_id, # Automatically set to epic type - external_id=external_id, - external_source=external_source, - ) - - if result["success"]: - # Enrich response data with missing fields for URL construction - data = result["data"] - if data and isinstance(data, dict) and data.get("id"): - try: - from pi.app.api.v1.helpers.plane_sql_queries import get_issue_identifier_for_artifact - - # Get the missing project_identifier and sequence_id - identifier_info = await get_issue_identifier_for_artifact(str(data["id"])) - if identifier_info: - data["project_identifier"] = identifier_info.get("project_identifier") - data["sequence_id"] = identifier_info.get("sequence_id") - log.info(f"Enriched epic data with identifier info: {identifier_info.get("identifier")}") - except Exception as e: - log.warning(f"Could not enrich epic data with identifier info: {e}") - - return await PlaneToolBase.format_success_payload_with_url(f"Successfully created epic '{name}'", data, "epic", context) - else: - return PlaneToolBase.format_error_payload("Failed to create epic", result["error"]) - - @tool - async def update_epic( - issue_id: str, - project_id: Optional[str] = None, - name: Optional[str] = None, - description_html: Optional[str] = None, - priority: Optional[str] = None, - state: Optional[str] = None, - assignees: Optional[List[str]] = None, - labels: Optional[List[str]] = None, - start_date: Optional[str] = None, - target_date: Optional[str] = None, - external_id: Optional[str] = None, - external_source: Optional[str] = None, - ) -> Dict[str, Any]: - """Update an existing epic work item. - - Args: - issue_id: Epic ID to update (required) - project_id: Project ID (optional - provide from conversation context) - name: Epic title - description_html: Epic description in HTML format - priority: Priority level (high, medium, low, urgent, none) - state: State ID for the epic - assignees: List of assignee user IDs - labels: List of label IDs - start_date: Start date (YYYY-MM-DD format) - target_date: Target completion date (YYYY-MM-DD format) - external_id: External system identifier (optional) - external_source: External system source name (optional) - - Note: This tool updates an existing epic. It does not change the work item type. - To add/move the epic to a module or cycle, use modules_add_work_items or cycles_add_work_items. - """ - # Auto-fill workspace_slug from context (hidden from LLM) - workspace_slug = context.get("workspace_slug") - - # Auto-fill project_id from context if not provided - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - # Resolve state name to UUID if needed - resolved_state = await resolve_state_to_uuid(state, project_id, workspace_slug) - - # Build update data with only non-None values to avoid overwriting existing fields - # Note: type_id is intentionally omitted to preserve epic type - update_data = { - k: v - for k, v in { - "name": name, - "description_html": description_html, - "priority": priority, - "state": resolved_state, - "assignees": assignees, - "labels": labels, - "start_date": start_date, - "target_date": target_date, - "external_id": external_id, - "external_source": external_source, - }.items() - if v is not None - } - - result = await method_executor.execute( - "workitems", - "update", - issue_id=issue_id, - project_id=project_id, - workspace_slug=workspace_slug, - **update_data, - ) - - if result["success"]: - return await PlaneToolBase.format_success_payload_with_url("Successfully updated epic", result["data"], "epic", context) - else: - return PlaneToolBase.format_error_payload("Failed to update epic", result["error"]) - - @tool - async def workitems_list( - project_id: Optional[str] = None, - workspace_slug: Optional[str] = None, - cursor: Optional[str] = None, - expand: Optional[str] = None, - external_id: Optional[str] = None, - external_source: Optional[str] = None, - fields: Optional[str] = None, - order_by: Optional[str] = None, - per_page: Optional[int] = 20, - priority: Optional[str] = None, - state: Optional[str] = None, - assignees: Optional[List[str]] = None, - labels: Optional[List[str]] = None, - start_date: Optional[str] = None, - target_date: Optional[str] = None, - created_by: Optional[str] = None, - updated_by: Optional[str] = None, - type_id: Optional[str] = None, - parent: Optional[str] = None, - is_draft: Optional[bool] = None, - created_at: Optional[str] = None, - updated_at: Optional[str] = None, - completed_at: Optional[str] = None, - archived_at: Optional[str] = None, - ) -> Dict[str, Any]: - """List work items with filtering. - - Args: - project_id: Project ID (required - provide from conversation context or previous actions) - workspace_slug: Workspace slug (provide if known, otherwise auto-detected) - cursor: Pagination cursor for next page - expand: Comma-separated list of related fields to expand in response - external_id: External system identifier for filtering or lookup - external_source: External system source name for filtering or lookup - fields: Comma-separated list of fields to include in response - order_by: Field to order results by. Prefix with '-' for descending order - per_page: Number of work items per page (default: 20, max: 100) - priority: Filter by priority (high, medium, low, urgent, none) - state: Filter by state name or UUID - assignees: Filter by assignee user IDs - labels: Filter by label IDs - start_date: Filter by start date - target_date: Filter by target date - created_by: Filter by creator ID - updated_by: Filter by updater ID - type_id: Filter by issue type ID - parent: Filter by parent issue ID - is_draft: Filter by draft status - created_at: Filter by creation time - updated_at: Filter by update time - completed_at: Filter by completion time - archived_at: Filter by archive time - """ - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - # Resolve state name to UUID if needed - resolved_state = await resolve_state_to_uuid(state, project_id, workspace_slug) - - # Build filter arguments - filter_args = { - "priority": priority, - "state": resolved_state, - "assignees": assignees, - "labels": labels, - "start_date": start_date, - "target_date": target_date, - "created_by": created_by, - "updated_by": updated_by, - "type_id": type_id, - "parent": parent, - "is_draft": is_draft, - "created_at": created_at, - "updated_at": updated_at, - "completed_at": completed_at, - "archived_at": archived_at, - } - - # Remove None values - filter_args = {k: v for k, v in filter_args.items() if v is not None} - - result = await method_executor.execute( - "workitems", - "list", - project_id=project_id, - workspace_slug=workspace_slug, - cursor=cursor, - expand=expand, - external_id=external_id, - external_source=external_source, - fields=fields, - order_by=order_by, - per_page=per_page, - ) - - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully retrieved work items list", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to list work items", result["error"]) - - @tool - async def workitems_retrieve( - issue_id: str, - project_id: Optional[str] = None, - workspace_slug: Optional[str] = None, - expand: Optional[str] = None, - external_id: Optional[str] = None, - external_source: Optional[str] = None, - fields: Optional[str] = None, - order_by: Optional[str] = None, - ) -> Dict[str, Any]: - """Retrieve a single work item by ID. - - Args: - issue_id: Work item ID (required) - project_id: Project ID (required - provide from conversation context or previous actions) - workspace_slug: Workspace slug (provide if known, otherwise auto-detected) - expand: Comma-separated list of related fields to expand in response - external_id: External system identifier for filtering or lookup - external_source: External system source name for filtering or lookup - fields: Comma-separated list of fields to include in response - order_by: Field to order results by. Prefix with '-' for descending order - """ - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - result = await method_executor.execute( - "workitems", - "retrieve", - issue_id=issue_id, - project_id=project_id, - workspace_slug=workspace_slug, - expand=expand, - external_id=external_id, - external_source=external_source, - fields=fields, - order_by=order_by, - ) - - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully retrieved work item", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to retrieve work item", result["error"]) - - @tool - async def workitems_search( - search: str, - workspace_slug: Optional[str] = None, - limit: Optional[int] = None, - project_id: Optional[str] = None, - workspace_search: Optional[str] = None, - ) -> Dict[str, Any]: - """Search work items by criteria. - - Args: - search: Search query to filter results by name, description, or identifier (required) - workspace_slug: Workspace slug (provide if known, otherwise auto-detected) - limit: Maximum number of results to return - project_id: Project ID for filtering results within a specific project - workspace_search: Whether to search across entire workspace or within specific project - """ - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - result = await method_executor.execute( - "workitems", - "search", - search=search, - workspace_slug=workspace_slug, - limit=limit, - project_id=project_id, - workspace_search=workspace_search, - ) - - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully searched work items", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to search work items", result["error"]) - - @tool - async def workitems_get_workspace( - issue_identifier: int, - project_identifier: str, - workspace_slug: Optional[str] = None, - ) -> Dict[str, Any]: - """Get work item across workspace using identifiers. - - Args: - issue_identifier: Issue sequence ID (numeric identifier within project) (required) - project_identifier: Project identifier (unique string within workspace) (required) - workspace_slug: Workspace slug (provide if known, otherwise auto-detected) - """ - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - - result = await method_executor.execute( - "workitems", - "get_workspace", - issue_identifier=issue_identifier, - project_identifier=project_identifier, - workspace_slug=workspace_slug, - ) - - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully retrieved workspace work item", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to retrieve workspace work item", result["error"]) - - @tool - async def workitems_create_relation( - issue_id: str, - relation_type: str, - related_issues: List[str], - project_id: Optional[str] = None, - ) -> Dict[str, Any]: - """Create relationships between work items. - - Args: - issue_id: Source work item ID to create relations from (required) - relation_type: Type of relationship - one of: blocking, blocked_by, duplicate, relates_to, start_before, start_after, finish_before, finish_after (required) - related_issues: List of work item IDs to create relations with (required) - project_id: Project ID (required - provide from conversation context or previous actions) - - Supported relation types: - - blocking: This work item blocks the related work items - - blocked_by: This work item is blocked by the related work items - - duplicate: This work item is a duplicate of the related work items - - relates_to: This work item relates to the related work items - - start_before: This work item should start before the related work items - - start_after: This work item should start after the related work items - - finish_before: This work item should finish before the related work items - - finish_after: This work item should finish after the related work items - - Examples: - - "Make issue A block issue B" = workitems_create_relation(issue_id=A, relation_type="blocking", related_issues=[B]) - - "Mark issue X as duplicate of issue Y" = workitems_create_relation(issue_id=X, relation_type="duplicate", related_issues=[Y]) - - "Issue C relates to issues D and E" = workitems_create_relation(issue_id=C, relation_type="relates_to", related_issues=[D, E]) - """ # noqa: E501 - # Auto-fill workspace_slug from context (hidden from LLM) - workspace_slug = context.get("workspace_slug") - - # Auto-fill project_id from context if not provided - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - # Validate relation type - if relation_type not in RELATION_TYPES: - valid_types = ", ".join(RELATION_TYPES.keys()) - return PlaneToolBase.format_error_payload( - "Invalid relation type", f"Relation type '{relation_type}' is not valid. Valid types are: {valid_types}" - ) - - # Validate required parameters - if not related_issues: - return PlaneToolBase.format_error_payload("Missing related issues", "At least one related issue ID must be provided") - - result = await method_executor.execute( - "workitems", - "create_relation", - issue_id=issue_id, - project_id=project_id, - workspace_slug=workspace_slug, - relation_type=relation_type, - issues=related_issues, - ) - - if result["success"]: - relation_count = len(related_issues) - - # For relations, we need to return entity info for the source work item (issue_id) - # Create a mock work item response with the source issue_id for entity URL generation - source_workitem_data = {"id": issue_id} - - return await PlaneToolBase.format_success_payload_with_url( - f"Successfully created {relation_count} '{relation_type}' relation(s) for work item", source_workitem_data, "workitem", context - ) - else: - return PlaneToolBase.format_error_payload("Failed to create work item relation", result["error"]) - - @tool - async def workitems_delete( - issue_id: str, - project_id: Optional[str] = None, - ) -> Dict[str, Any]: - """Delete a work item/issue. - - Args: - issue_id: Work item ID to delete (required) - project_id: Project ID (required - provide from conversation context or previous actions) - - Warning: This action is permanent and cannot be undone. The work item will be permanently deleted. - """ - # Auto-fill workspace_slug from context (hidden from LLM) - workspace_slug = context.get("workspace_slug") - - # Auto-fill project_id from context if not provided - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - result = await method_executor.execute( - "workitems", - "delete", - issue_id=issue_id, - project_id=project_id, - workspace_slug=workspace_slug, - ) - - if result["success"]: - return PlaneToolBase.format_success_payload(f"Successfully deleted work item {issue_id}", {"id": issue_id}) - else: - return PlaneToolBase.format_error_payload("Failed to delete work item", result.get("error")) - - return [ - workitems_create, - workitems_update, - create_epic, - update_epic, - workitems_create_relation, - workitems_delete, - workitems_list, - # workitems_retrieve, - # workitems_search, - # workitems_get_workspace - ] + # URL construction for list operations + elif tool_name == "workitems_list": + if result.get("success"): + data = result.get("data") + if data and isinstance(data, dict): + results = data.get("results", []) + workspace_slug = kwargs.get("workspace_slug") or context.get("workspace_slug") + + if results and isinstance(results, list): + try: + from pi.app.api.v1.helpers.plane_sql_queries import get_issue_identifier_for_artifact + from pi.config import settings + + # Get the base URL for constructing frontend URLs + frontend_url = settings.plane_api.FRONTEND_URL.rstrip("/") + + # Enrich each workitem with identifier and URL + for item in results: + if isinstance(item, dict) and item.get("id"): + try: + identifier_info = await get_issue_identifier_for_artifact(str(item["id"])) + if identifier_info: + item["project_identifier"] = identifier_info.get("project_identifier") + item["sequence_id"] = identifier_info.get("sequence_id") + item["identifier"] = identifier_info.get("identifier") + + # Construct URL using the identifier (PROJECT-123 format) + if workspace_slug and identifier_info.get("identifier"): + item["url"] = f"{frontend_url}/{workspace_slug}/browse/{identifier_info["identifier"]}/" + + log.debug(f"Enriched workitem {item["id"]} with identifier: {identifier_info.get("identifier")}") + except Exception as e: + log.warning(f"Could not enrich workitem {item.get("id")} with identifier info: {e}") + + log.info(f"Enriched {len(results)} workitems with identifiers and URLs") + except Exception as e: + log.warning(f"Error enriching workitems list with identifiers: {e}") + + return result + + +# ============================================================================ +# WORKITEMS TOOL METADATA +# ============================================================================ + +WORKITEMS_TOOL_DEFINITIONS: Dict[str, ToolMetadata] = { + "create": ToolMetadata( + name="workitems_create", + description="Create a new work item/issue", + sdk_method="create_work_item", + returns_entity_type="workitem", + pre_handler=_workitems_pre_handler, + post_handler=_workitems_post_handler, + parameters=[ + ToolParameter(name="name", type="str", required=True, description="Work item title (required)"), + ToolParameter( + name="project_id", + type="Optional[str]", + required=False, + description="Project ID (required - provide from conversation context or previous actions)", + auto_fill_from_context=True, + ), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (required - provide from conversation context)", + auto_fill_from_context=True, + property_transform="skip", + ), + ToolParameter( + name="description_html", + type="Optional[str]", + required=False, + description="Workitem description in HTML format", + ), + ToolParameter( + name="priority", + type="Optional[str]", + required=False, + description="Priority level (high, medium, low, urgent, none)", + ), + ToolParameter( + name="state", + type="Optional[str]", + required=False, + description='State name or UUID for the workitem (e.g., "todo", "in progress", "done")', + ), + ToolParameter( + name="assignees", + type="List[str]", + required=False, + description="List of assignee user IDs", + ), + ToolParameter( + name="labels", + type="List[str]", + required=False, + description="List of label IDs", + ), + ToolParameter( + name="start_date", + type="Optional[str]", + required=False, + description="Start date (YYYY-MM-DD format)", + ), + ToolParameter( + name="target_date", + type="Optional[str]", + required=False, + description="Target completion date (YYYY-MM-DD format)", + ), + ToolParameter( + name="type_id", + type="Optional[str]", + required=False, + description="Workitem type ID (optional). MUST be the 'issue_types_id' (UUID of the issue type definition), " + "NOT the 'project_issue_types_id'.", + ), + ToolParameter( + name="parent", + type="Optional[str]", + required=False, + description="Parent work-item ID (optional)", + ), + ToolParameter( + name="external_id", + type="Optional[str]", + required=False, + description="External system identifier (optional)", + ), + ToolParameter( + name="external_source", + type="Optional[str]", + required=False, + description="External system source name (optional)", + ), + ToolParameter( + name="is_draft", + type="Optional[bool]", + required=False, + description="Create as draft (optional)", + ), + ], + ), + "update": ToolMetadata( + name="workitems_update", + description="Update an existing work item/issue", + sdk_method="update_work_item", + returns_entity_type="workitem", + pre_handler=_workitems_pre_handler, + post_handler=_workitems_post_handler, + parameters=[ + ToolParameter(name="issue_id", type="str", required=True, description="Work item ID to update (required)"), + ToolParameter( + name="project_id", + type="Optional[str]", + required=False, + description="Project ID (required - provide from conversation context or previous actions)", + auto_fill_from_context=True, + ), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (required - provide from conversation context)", + auto_fill_from_context=True, + property_transform="skip", + ), + ToolParameter( + name="name", + type="Optional[str]", + required=False, + description="New work item title", + ), + ToolParameter( + name="description_html", + type="Optional[str]", + required=False, + description="New description in HTML format", + ), + ToolParameter( + name="priority", + type="Optional[str]", + required=False, + description="New priority level (high, medium, low, urgent, none)", + ), + ToolParameter( + name="state", + type="Optional[str]", + required=False, + description='New state name or UUID (e.g., "todo", "in progress", "done")', + ), + ToolParameter( + name="assignees", + type="List[str]", + required=False, + description="New list of assignee user IDs", + ), + ToolParameter( + name="labels", + type="List[str]", + required=False, + description="New list of label IDs", + ), + ToolParameter( + name="start_date", + type="Optional[str]", + required=False, + description="New start date (YYYY-MM-DD format)", + ), + ToolParameter( + name="target_date", + type="Optional[str]", + required=False, + description="New target completion date (YYYY-MM-DD format)", + ), + ToolParameter( + name="type_id", + type="Optional[str]", + required=False, + description="Workitem type ID (optional). MUST be the 'issue_types_id' (UUID of the issue type definition), " + "NOT the 'project_issue_types_id'.", + ), + ToolParameter( + name="parent", + type="Optional[str]", + required=False, + description="Parent work-item ID (optional)", + ), + ToolParameter( + name="external_id", + type="Optional[str]", + required=False, + description="External system identifier (optional)", + ), + ToolParameter( + name="external_source", + type="Optional[str]", + required=False, + description="External system source name (optional)", + ), + ToolParameter( + name="is_draft", + type="Optional[bool]", + required=False, + description="Mark as draft (optional)", + ), + ], + ), + "list": ToolMetadata( + name="workitems_list", + description="List work items with filtering", + sdk_method="list_work_items", + pre_handler=_workitems_pre_handler, + post_handler=_workitems_post_handler, + parameters=[ + ToolParameter( + name="project_id", + type="Optional[str]", + required=False, + description="Project ID (required - provide from conversation context or previous actions)", + auto_fill_from_context=True, + ), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (provide if known, otherwise auto-detected)", + auto_fill_from_context=True, + property_transform="skip", + ), + ToolParameter( + name="cursor", + type="Optional[str]", + required=False, + description="Pagination cursor for next page", + property_transform="skip", + ), + ToolParameter( + name="expand", + type="Optional[str]", + required=False, + description="Comma-separated list of related fields to expand in response", + property_transform="skip", + ), + ToolParameter( + name="external_id", + type="Optional[str]", + required=False, + description="External system identifier for filtering or lookup", + ), + ToolParameter( + name="external_source", + type="Optional[str]", + required=False, + description="External system source name for filtering or lookup", + ), + ToolParameter( + name="fields", + type="Optional[str]", + required=False, + description="Comma-separated list of fields to include in response", + property_transform="skip", + ), + ToolParameter( + name="order_by", + type="Optional[str]", + required=False, + description="Field to order results by. Prefix with '-' for descending order", + property_transform="skip", + ), + ToolParameter( + name="per_page", + type="Optional[int]", + required=False, + description="Number of work items per page (default: 20, max: 100)", + property_transform="skip", + ), + ToolParameter( + name="priority", + type="Optional[str]", + required=False, + description="Filter by priority (high, medium, low, urgent, none)", + ), + ToolParameter( + name="state", + type="Optional[str]", + required=False, + description="Filter by state name or UUID", + ), + ToolParameter( + name="assignees", + type="List[str]", + required=False, + description="Filter by assignee user IDs", + ), + ToolParameter( + name="labels", + type="List[str]", + required=False, + description="Filter by label IDs", + ), + ToolParameter( + name="start_date", + type="Optional[str]", + required=False, + description="Filter by start date", + ), + ToolParameter( + name="target_date", + type="Optional[str]", + required=False, + description="Filter by target date", + ), + ToolParameter( + name="created_by", + type="Optional[str]", + required=False, + description="Filter by creator ID", + ), + ToolParameter( + name="updated_by", + type="Optional[str]", + required=False, + description="Filter by updater ID", + ), + ToolParameter( + name="type_id", + type="Optional[str]", + required=False, + description="Filter by issue type ID", + ), + ToolParameter( + name="parent", + type="Optional[str]", + required=False, + description="Filter by parent issue ID", + ), + ToolParameter( + name="is_draft", + type="Optional[bool]", + required=False, + description="Filter by draft status", + ), + ToolParameter( + name="created_at", + type="Optional[str]", + required=False, + description="Filter by creation time", + ), + ToolParameter( + name="updated_at", + type="Optional[str]", + required=False, + description="Filter by update time", + ), + ToolParameter( + name="completed_at", + type="Optional[str]", + required=False, + description="Filter by completion time", + ), + ToolParameter( + name="archived_at", + type="Optional[str]", + required=False, + description="Filter by archive time", + ), + ], + ), + "retrieve": ToolMetadata( + name="workitems_retrieve", + description="Retrieve a single work item by ID", + sdk_method="retrieve_work_item", + parameters=[ + ToolParameter(name="issue_id", type="str", required=True, description="Work item ID (required)"), + ToolParameter( + name="project_id", + type="Optional[str]", + required=False, + description="Project ID (required - provide from conversation context or previous actions)", + auto_fill_from_context=True, + ), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (provide if known, otherwise auto-detected)", + auto_fill_from_context=True, + property_transform="skip", + ), + ToolParameter( + name="expand", + type="Optional[str]", + required=False, + description="Comma-separated list of related fields to expand in response", + property_transform="skip", + ), + ToolParameter( + name="external_id", + type="Optional[str]", + required=False, + description="External system identifier for filtering or lookup", + ), + ToolParameter( + name="external_source", + type="Optional[str]", + required=False, + description="External system source name for filtering or lookup", + ), + ToolParameter( + name="fields", + type="Optional[str]", + required=False, + description="Comma-separated list of fields to include in response", + property_transform="skip", + ), + ToolParameter( + name="order_by", + type="Optional[str]", + required=False, + description="Field to order results by. Prefix with '-' for descending order", + property_transform="skip", + ), + ], + ), + "delete": ToolMetadata( + name="workitems_delete", + description="Delete a work item/issue", + sdk_method="delete_work_item", + parameters=[ + ToolParameter(name="issue_id", type="str", required=True, description="Work item ID to delete (required)"), + ToolParameter( + name="project_id", + type="Optional[str]", + required=False, + description="Project ID (required - provide from conversation context or previous actions)", + auto_fill_from_context=True, + ), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (provide if known, otherwise auto-detected)", + auto_fill_from_context=True, + property_transform="skip", + ), + ], + ), + "create_relation": ToolMetadata( + name="workitems_create_relation", + description="Create relationships between work items", + sdk_method="create_work_item_relation", + returns_entity_type="workitem", + pre_handler=_workitems_pre_handler, + parameters=[ + ToolParameter(name="issue_id", type="str", required=True, description="Source work item ID to create relations from (required)"), + ToolParameter( + name="relation_type", + type="str", + required=True, + description="Type of relationship - one of: blocking, blocked_by, duplicate, relates_to, start_before, " + "start_after, finish_before, finish_after (required)", + ), + ToolParameter( + name="issues", + type="List[str]", + required=True, + description="List of work item IDs to create relations with (required)", + ), + ToolParameter( + name="project_id", + type="Optional[str]", + required=False, + description="Project ID (required - provide from conversation context or previous actions)", + auto_fill_from_context=True, + ), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (provide if known, otherwise auto-detected)", + auto_fill_from_context=True, + property_transform="skip", + ), + ], + ), + "search": ToolMetadata( + name="workitems_search", + description="Search work items by criteria", + sdk_method="search_work_items", + parameters=[ + ToolParameter( + name="query", + type="str", + required=True, + description="Search query to filter results by name, description, or identifier (required)", + ), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (provide if known, otherwise auto-detected)", + auto_fill_from_context=True, + property_transform="skip", + ), + ToolParameter( + name="limit", + type="Optional[int]", + required=False, + description="Maximum number of results to return", + ), + ToolParameter( + name="project_id", + type="Optional[str]", + required=False, + description="Project ID for filtering results within a specific project", + auto_fill_from_context=True, + ), + ToolParameter( + name="workspace_search", + type="Optional[str]", + required=False, + description="Whether to search across entire workspace or within specific project", + ), + ], + ), + "create_epic": ToolMetadata( + name="create_epic", + description="Create a new epic", + sdk_method="create_work_item", + returns_entity_type="epic", + pre_handler=_workitems_pre_handler, + post_handler=_workitems_post_handler, + parameters=[ + ToolParameter(name="name", type="str", required=True, description="Epic title (required)"), + ToolParameter( + name="project_id", + type="Optional[str]", + required=False, + description="Project ID (required - provide from conversation context or previous actions)", + auto_fill_from_context=True, + ), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (required - provide from conversation context)", + auto_fill_from_context=True, + property_transform="skip", + ), + ToolParameter( + name="description_html", + type="Optional[str]", + required=False, + description="Epic description in HTML format", + ), + ToolParameter( + name="priority", + type="Optional[str]", + required=False, + description="Priority level (high, medium, low, urgent, none)", + ), + ToolParameter( + name="state", + type="Optional[str]", + required=False, + description='State name or ID for the epic (e.g., "done", "in progress", or UUID)', + ), + ToolParameter( + name="assignees", + type="List[str]", + required=False, + description="List of assignee user IDs", + ), + ToolParameter( + name="labels", + type="List[str]", + required=False, + description="List of label IDs", + ), + ToolParameter( + name="start_date", + type="Optional[str]", + required=False, + description="Start date (YYYY-MM-DD format)", + ), + ToolParameter( + name="target_date", + type="Optional[str]", + required=False, + description="Target completion date (YYYY-MM-DD format)", + ), + ToolParameter( + name="external_id", + type="Optional[str]", + required=False, + description="External system identifier (optional)", + ), + ToolParameter( + name="external_source", + type="Optional[str]", + required=False, + description="External system source name (optional)", + ), + ], + ), + "update_epic": ToolMetadata( + name="update_epic", + description="Update an existing epic", + sdk_method="update_work_item", + returns_entity_type="epic", + pre_handler=_workitems_pre_handler, + post_handler=_workitems_post_handler, + parameters=[ + ToolParameter(name="issue_id", type="str", required=True, description="Epic ID to update (required)"), + ToolParameter( + name="project_id", + type="Optional[str]", + required=False, + description="Project ID (optional - provide from conversation context)", + auto_fill_from_context=True, + ), + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (provide if known, otherwise auto-detected)", + auto_fill_from_context=True, + property_transform="skip", + ), + ToolParameter( + name="name", + type="Optional[str]", + required=False, + description="Epic title", + ), + ToolParameter( + name="description_html", + type="Optional[str]", + required=False, + description="Epic description in HTML format", + ), + ToolParameter( + name="priority", + type="Optional[str]", + required=False, + description="Priority level (high, medium, low, urgent, none)", + ), + ToolParameter( + name="state", + type="Optional[str]", + required=False, + description="State ID for the epic", + ), + ToolParameter( + name="assignees", + type="List[str]", + required=False, + description="List of assignee user IDs", + ), + ToolParameter( + name="labels", + type="List[str]", + required=False, + description="List of label IDs", + ), + ToolParameter( + name="start_date", + type="Optional[str]", + required=False, + description="Start date (YYYY-MM-DD format)", + ), + ToolParameter( + name="target_date", + type="Optional[str]", + required=False, + description="Target completion date (YYYY-MM-DD format)", + ), + ToolParameter( + name="external_id", + type="Optional[str]", + required=False, + description="External system identifier (optional)", + ), + ToolParameter( + name="external_source", + type="Optional[str]", + required=False, + description="External system source name (optional)", + ), + ], + ), +} + + +# ============================================================================ +# TOOL FACTORY +# ============================================================================ + + +def get_workitem_tools(method_executor, context): + """Return LangChain tools for the workitems category using auto-generation from metadata. + + Dynamically fetches available work item types for the current project and injects them + into the type_id parameter descriptions, making types visible to the LLM during planning. + """ + import copy + + # Make a deep copy to avoid modifying the global constant + dynamic_tool_definitions = copy.deepcopy(WORKITEMS_TOOL_DEFINITIONS) + + # Fetch and inject available types if we have the necessary context + project_id = context.get("project_id") + workspace_slug = context.get("workspace_slug") + + if project_id and workspace_slug and method_executor: + types_text = _sync_fetch_types(method_executor, project_id, workspace_slug) + if types_text: + _enrich_type_id_descriptions(dynamic_tool_definitions, types_text) + log.info(f"Injected available types into workitem tool descriptions for project {project_id}") + + return generate_tools_for_category( + category="workitems", + method_executor=method_executor, + context=context, + tool_definitions=dynamic_tool_definitions, + ) + + +# ============================================================================ +# OLD MANUAL TOOL DEFINITIONS (COMMENTED OUT - KEPT FOR COMPARISON) +# To rollback: uncomment below and comment out the auto-generation code above +# ============================================================================ + +# from langchain_core.tools import tool +# +# def get_workitem_tools(method_executor, context): +# """Return LangChain tools for the workitems category using method_executor and context.""" +# +# @tool +# async def workitems_create( +# name: str, +# project_id: Optional[str] = None, +# description_html: Optional[str] = None, +# ... [TRUNCATED - See git history for full manual implementations] +# ): +# ... [All manual tool implementations omitted for brevity] +# +# return [ +# workitems_create, +# workitems_update, +# create_epic, +# update_epic, +# workitems_create_relation, +# workitems_delete, +# workitems_list, +# # workitems_retrieve, +# # workitems_search, +# # workitems_get_workspace +# ] diff --git a/apps/pi/pi/services/actions/tools/worklogs.py b/apps/pi/pi/services/actions/tools/worklogs.py index 007eddabe1..4a4da9b73c 100644 --- a/apps/pi/pi/services/actions/tools/worklogs.py +++ b/apps/pi/pi/services/actions/tools/worklogs.py @@ -13,244 +13,156 @@ Worklogs API tools for Plane time tracking operations. """ +import contextlib +import re import uuid from typing import Any from typing import Dict -from typing import Optional -from langchain_core.tools import tool +from pi.services.actions.tool_generator import generate_tools_for_category +from pi.services.actions.tool_metadata import ToolMetadata +from pi.services.actions.tool_metadata import ToolParameter -from .base import PlaneToolBase -# Factory wired via CATEGORY_TO_PROVIDER in tools/__init__.py -# Returns LangChain tools implementing worklog actions +# Pre-handler combining project resolution AND duration parsing +async def _worklog_pre_handler( + metadata: ToolMetadata, + kwargs: Dict[str, Any], + context: Dict[str, Any], + category: str, + method_key: str, + method_executor: Any, +) -> Dict[str, Any]: + """Resolve project_id and parse duration string to minutes.""" + + # 1. Project ID Resolution + issue_id = kwargs.get("issue_id") + project_id = kwargs.get("project_id") + + should_resolve = False + if issue_id and not project_id: + should_resolve = True + elif issue_id and project_id: + try: + uuid.UUID(str(project_id)) + except (ValueError, AttributeError): + should_resolve = True + + if should_resolve: + try: + from pi.app.api.v1.helpers.plane_sql_queries import get_issue_identifier_for_artifact + + issue_info = await get_issue_identifier_for_artifact(str(issue_id)) + if issue_info and issue_info.get("project_id"): + kwargs["project_id"] = issue_info["project_id"] + except Exception: + pass + + # 2. Duration Parsing (e.g., "1h 30m" -> 90) + duration = kwargs.get("duration") + if duration is not None: + if isinstance(duration, str): + # Try to parse string format "Xh Ym" or just "Xm" + total_minutes = 0 + + # Simple regex for parsing + # Matches "1h", "1.5h", "30m", "1h 30m" + hours_match = re.search(r"(\d+(?:\.\d+)?)\s*h", duration.lower()) + minutes_match = re.search(r"(\d+(?:\.\d+)?)\s*m", duration.lower()) + + if hours_match: + with contextlib.suppress(ValueError): + total_minutes += int(float(hours_match.group(1)) * 60) + + if minutes_match: + with contextlib.suppress(ValueError): + total_minutes += int(float(minutes_match.group(1))) + + # If regex matched nothing but it's a digit string, treat as minutes + if not hours_match and not minutes_match: + if duration.isdigit(): + total_minutes = int(duration) + + # If we found something, update kwargs + if total_minutes > 0: + kwargs["duration"] = total_minutes + + # If int/float passed directly, ensure int + elif isinstance(duration, (int, float)): + kwargs["duration"] = int(duration) + + return kwargs + + +WORKLOGS_TOOL_DEFINITIONS = { + "create": ToolMetadata( + name="worklogs_create", + description="Create a new worklog (time entry).", + sdk_method="create_issue_worklog", + parameters=[ + ToolParameter(name="issue_id", description="Issue ID", required=True, type="str"), + ToolParameter(name="description", description="Worklog description/note", required=True, type="str"), + ToolParameter( + name="duration", description="Duration (e.g., '1h 30m' or minutes as int)", required=True, type="Any" + ), # Accepting Any to allow string + ToolParameter( + name="project_id", description="Project ID (optional, auto-filled)", required=False, type="Optional[str]", auto_fill_from_context=True + ), + ToolParameter( + name="workspace_slug", + description="Workspace slug (optional, auto-filled)", + required=False, + type="Optional[str]", + auto_fill_from_context=True, + ), + ], + pre_handler=_worklog_pre_handler, + returns_entity_type="worklog", + ), + "list": ToolMetadata( + name="worklogs_list", + description="List worklogs for an issue.", + sdk_method="list_issue_worklogs", + parameters=[ + ToolParameter(name="issue_id", description="Issue ID", required=True, type="str"), + ToolParameter(name="project_id", description="Project ID", required=False, type="Optional[str]", auto_fill_from_context=True), + ToolParameter(name="workspace_slug", description="Workspace slug", required=False, type="Optional[str]", auto_fill_from_context=True), + ], + pre_handler=_worklog_pre_handler, + ), + "update": ToolMetadata( + name="worklogs_update", + description="Update time entry.", + sdk_method="update_issue_worklog", + parameters=[ + ToolParameter(name="worklog_id", description="Worklog ID", required=True, type="str"), + ToolParameter(name="issue_id", description="Issue ID", required=True, type="str"), + ToolParameter(name="description", description="Worklog description", required=False, type="Optional[str]"), + ToolParameter(name="duration", description="Duration (e.g., '1h 30m')", required=False, type="Any"), + ToolParameter(name="project_id", description="Project ID", required=False, type="Optional[str]", auto_fill_from_context=True), + ToolParameter(name="workspace_slug", description="Workspace slug", required=False, type="Optional[str]", auto_fill_from_context=True), + ToolParameter(name="created_by", description="User ID who created", required=False, type="Optional[str]"), + ToolParameter(name="updated_by", description="User ID who updated", required=False, type="Optional[str]"), + ], + pre_handler=_worklog_pre_handler, + returns_entity_type="worklog", + ), + "delete": ToolMetadata( + name="worklogs_delete", + description="Delete time entry.", + sdk_method="delete_issue_worklog", + parameters=[ + ToolParameter(name="worklog_id", description="Worklog ID", required=True, type="str"), + ToolParameter(name="issue_id", description="Issue ID", required=True, type="str"), + ToolParameter(name="project_id", description="Project ID", required=False, type="Optional[str]", auto_fill_from_context=True), + ToolParameter(name="workspace_slug", description="Workspace slug", required=False, type="Optional[str]", auto_fill_from_context=True), + ], + pre_handler=_worklog_pre_handler, + returns_entity_type="worklog", + ), +} def get_worklog_tools(method_executor, context): """Return LangChain tools for the worklogs category using method_executor and context.""" - - @tool - async def worklogs_create( - issue_id: str, - description: str, - duration: int, - project_id: Optional[str] = None, - workspace_slug: Optional[str] = None, - ) -> Dict[str, Any]: - """Create a new worklog entry. - - Args: - issue_id: Issue ID (required) - description: Worklog description (required) - duration: Duration in minutes (required) - project_id: Project ID (optional, auto-filled from context or resolved from issue_id) - workspace_slug: Workspace slug (optional, auto-filled from context) - """ - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - # Programmatically resolve project_id from issue_id if missing or invalid (non-UUID) - should_resolve = False - if issue_id and not project_id: - should_resolve = True - elif issue_id and project_id: - try: - uuid.UUID(str(project_id)) - except (ValueError, AttributeError): - should_resolve = True - - if should_resolve: - try: - from pi.app.api.v1.helpers.plane_sql_queries import get_issue_identifier_for_artifact - - issue_info = await get_issue_identifier_for_artifact(str(issue_id)) - if issue_info and issue_info.get("project_id"): - project_id = issue_info["project_id"] - except Exception: - pass - - result = await method_executor.execute( - "worklogs", - "create", - issue_id=issue_id, - description=description, - duration=duration, - project_id=project_id, - workspace_slug=workspace_slug, - ) - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully created worklog", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to create worklog", result["error"]) - - @tool - async def worklogs_list( - issue_id: str, - project_id: Optional[str] = None, - workspace_slug: Optional[str] = None, - ) -> Dict[str, Any]: - """List worklogs for an issue.""" - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - # Programmatically resolve project_id from issue_id if missing or invalid (non-UUID) - should_resolve = False - if issue_id and not project_id: - should_resolve = True - elif issue_id and project_id: - try: - uuid.UUID(str(project_id)) - except (ValueError, AttributeError): - should_resolve = True - - if should_resolve: - try: - from pi.app.api.v1.helpers.plane_sql_queries import get_issue_identifier_for_artifact - - issue_info = await get_issue_identifier_for_artifact(str(issue_id)) - if issue_info and issue_info.get("project_id"): - project_id = issue_info["project_id"] - except Exception: - pass - - result = await method_executor.execute( - "worklogs", - "list", - issue_id=issue_id, - project_id=project_id, - workspace_slug=workspace_slug, - ) - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully retrieved worklogs", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to list worklogs", result["error"]) - - @tool - async def worklogs_get_summary( - project_id: Optional[str] = None, - workspace_slug: Optional[str] = None, - ) -> Dict[str, Any]: - """Get project worklog summary.""" - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - result = await method_executor.execute( - "worklogs", - "get_summary", - project_id=project_id, - workspace_slug=workspace_slug, - ) - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully retrieved worklog summary", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to get worklog summary", result["error"]) - - @tool - async def worklogs_update( - worklog_id: str, - issue_id: str, - description: Optional[str] = None, - duration: Optional[int] = None, - created_by: Optional[str] = None, - updated_by: Optional[str] = None, - project_id: Optional[str] = None, - workspace_slug: Optional[str] = None, - ) -> Dict[str, Any]: - """Update time entry. - - Args: - worklog_id: Worklog ID (required) - issue_id: Issue ID (required) - description: Worklog description - duration: Duration in minutes - created_by: User ID who created the worklog - updated_by: User ID who updated the worklog - project_id: Project ID (optional, auto-filled from context or resolved from issue_id) - workspace_slug: Workspace slug (optional, auto-filled from context) - """ - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - # Programmatically resolve project_id from issue_id if missing or invalid (non-UUID) - should_resolve = False - if issue_id and not project_id: - should_resolve = True - elif issue_id and project_id: - try: - uuid.UUID(str(project_id)) - except (ValueError, AttributeError): - should_resolve = True - - if should_resolve: - try: - from pi.app.api.v1.helpers.plane_sql_queries import get_issue_identifier_for_artifact - - issue_info = await get_issue_identifier_for_artifact(str(issue_id)) - if issue_info and issue_info.get("project_id"): - project_id = issue_info["project_id"] - except Exception: - pass - - # Build update data - update_data: Dict[str, Any] = {} - if description is not None: - update_data["description"] = description - if duration is not None: - update_data["duration"] = duration - if created_by is not None: - update_data["created_by"] = created_by - if updated_by is not None: - update_data["updated_by"] = updated_by - - result = await method_executor.execute( - "worklogs", - "update", - worklog_id=worklog_id, - issue_id=issue_id, - project_id=project_id, - workspace_slug=workspace_slug, - **update_data, - ) - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully updated worklog", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to update worklog", result["error"]) - - @tool - async def worklogs_delete( - worklog_id: str, - project_id: Optional[str] = None, - workspace_slug: Optional[str] = None, - ) -> Dict[str, Any]: - """Delete time entry.""" - # Auto-fill from context if not provided - if workspace_slug is None and "workspace_slug" in context: - workspace_slug = context["workspace_slug"] - if project_id is None and "project_id" in context: - project_id = context["project_id"] - - result = await method_executor.execute( - "worklogs", - "delete", - worklog_id=worklog_id, - project_id=project_id, - workspace_slug=workspace_slug, - ) - if result["success"]: - return PlaneToolBase.format_success_payload("Successfully deleted worklog", result["data"]) - else: - return PlaneToolBase.format_error_payload("Failed to delete worklog", result["error"]) - - return [worklogs_create, worklogs_list, worklogs_get_summary, worklogs_update, worklogs_delete] + return generate_tools_for_category("worklogs", method_executor, context, WORKLOGS_TOOL_DEFINITIONS) diff --git a/apps/pi/pi/services/actions/tools/workspaces.py b/apps/pi/pi/services/actions/tools/workspaces.py new file mode 100644 index 0000000000..b12da1d590 --- /dev/null +++ b/apps/pi/pi/services/actions/tools/workspaces.py @@ -0,0 +1,207 @@ +# SPDX-FileCopyrightText: 2023-present Plane Software, Inc. +# SPDX-License-Identifier: LicenseRef-Plane-Commercial +# +# Licensed under the Plane Commercial License (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# https://plane.so/legals/eula +# +# DO NOT remove or modify this notice. +# NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited. + +""" +Workspaces API tools for Plane workspace features management. +""" + +from typing import Any +from typing import Dict + +from pi import logger +from pi.services.actions.tool_generator import generate_tools_for_category +from pi.services.actions.tool_metadata import ToolMetadata +from pi.services.actions.tool_metadata import ToolParameter + +log = logger.getChild("workspaces") + +# All workspace feature fields - SDK requires all fields to be present +WORKSPACE_FEATURE_FIELDS = ["project_grouping", "initiatives", "teams", "customers", "wiki", "pi"] + + +async def _workspaces_pre_handler( + metadata: ToolMetadata, + kwargs: Dict[str, Any], + context: Dict[str, Any], + category: str, + method_key: str, + method_executor: Any = None, +) -> Dict[str, Any]: + """Pre-processing handler for workspace tools. + + Handles: + - update_features: Fetch current features and merge with update (SDK requires all fields) + """ + if method_key == "update_features": + workspace_slug = kwargs.get("workspace_slug") + if not workspace_slug: + log.warning("workspace_slug missing for update_features, cannot fetch current features") + return kwargs + + if not method_executor: + log.warning("method_executor not available, cannot fetch current features") + return kwargs + + # Fetch current features using the existing method_executor + try: + result = await method_executor.execute("workspaces", "get_features", workspace_slug=workspace_slug) + + if result.get("success") and result.get("data"): + features_data = result["data"] + + # Merge: current values as defaults, update with provided values + for field in WORKSPACE_FEATURE_FIELDS: + if field not in kwargs or kwargs[field] is None: + kwargs[field] = features_data.get(field, False) + + log.debug(f"Merged workspace features: {kwargs}") + else: + log.warning(f"Could not fetch current workspace features: {result}") + except Exception as e: + log.error(f"Failed to fetch current workspace features: {e}") + # Don't fail - let SDK validation fail with clear error + + return kwargs + + +async def _workspaces_post_handler( + metadata: ToolMetadata, + result: Dict[str, Any], + kwargs: Dict[str, Any], + context: Dict[str, Any], + method_executor: Any, + category: str, + method_key: str, +) -> Dict[str, Any]: + """Post-processing handler for workspace tools. + + Handles: + - Inject workspace metadata into response for entity URL generation + """ + # For workspace operations, inject workspace info into the result for URL construction + workspace_slug = kwargs.get("workspace_slug") + + if workspace_slug and result.get("success"): + # Get workspace_id from context - try multiple keys + workspace_id = context.get("workspace_id") or context.get("workspace") + + # Ensure the result data has the workspace metadata for URL generation + if "data" in result: + if isinstance(result["data"], dict): + # Inject workspace identifiers if missing + if "id" not in result["data"] and workspace_id: + result["data"]["id"] = str(workspace_id) + if "slug" not in result["data"]: + result["data"]["slug"] = workspace_slug + if "name" not in result["data"]: + # Use slug as a fallback name + result["data"]["name"] = workspace_slug + else: + # Create a minimal data structure for URL generation + result["data"] = { + "id": str(workspace_id) if workspace_id else None, + "slug": workspace_slug, + "name": workspace_slug, + } + + return result + + +# ============================================================================ +# WORKSPACES TOOL DEFINITIONS +# ============================================================================ + +WORKSPACE_TOOL_DEFINITIONS: Dict[str, ToolMetadata] = { + "get_features": ToolMetadata( + name="workspaces_get_features", + description="Get enabled workspace features (initiatives, teams, customers, etc.)", + sdk_method="get_workspace_features", + returns_entity_type="workspace", + post_handler=_workspaces_post_handler, + parameters=[ + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (auto-detected from context)", + auto_fill_from_context=True, + ), + ], + ), + "update_features": ToolMetadata( + name="workspaces_update_features", + description="Enable or disable workspace features", + sdk_method="update_workspace_features", + returns_entity_type="workspace", + pre_handler=_workspaces_pre_handler, + post_handler=_workspaces_post_handler, + parameters=[ + ToolParameter( + name="workspace_slug", + type="Optional[str]", + required=False, + description="Workspace slug (auto-detected from context)", + auto_fill_from_context=True, + ), + ToolParameter( + name="project_grouping", + type="Optional[bool]", + required=False, + description="Enable/disable project grouping feature (optional)", + ), + ToolParameter( + name="initiatives", + type="Optional[bool]", + required=False, + description="Enable/disable initiatives feature (optional)", + ), + ToolParameter( + name="teams", + type="Optional[bool]", + required=False, + description="Enable/disable teams feature (optional)", + ), + ToolParameter( + name="customers", + type="Optional[bool]", + required=False, + description="Enable/disable customers feature (optional)", + ), + ToolParameter( + name="wiki", + type="Optional[bool]", + required=False, + description="Enable/disable wiki feature (optional)", + ), + ToolParameter( + name="pi", + type="Optional[bool]", + required=False, + description="Enable/disable PI (Plane Intelligence) feature (optional)", + ), + ], + ), +} + + +# ============================================================================ +# TOOL FACTORY +# ============================================================================ + + +def get_workspace_tools(method_executor, context): + """Return LangChain tools for the workspaces category using auto-generation from metadata.""" + return generate_tools_for_category( + category="workspaces", + method_executor=method_executor, + context=context, + tool_definitions=WORKSPACE_TOOL_DEFINITIONS, + ) diff --git a/apps/pi/pi/services/chat/action_executor.py b/apps/pi/pi/services/chat/action_executor.py index 79ad54fe51..97c957985e 100644 --- a/apps/pi/pi/services/chat/action_executor.py +++ b/apps/pi/pi/services/chat/action_executor.py @@ -35,6 +35,13 @@ from pi.services.chat.helpers.placeholder_orchestrator import PlaceholderOrchest log = logger.getChild(__name__) +# Mapping of entity_type to tool category +# Some tools have different entity_type than their category (e.g., epic tools are in workitems) +ENTITY_TYPE_TO_CATEGORY = { + "epic": "workitems", # Epic tools are in workitems category + # Add more mappings as needed +} + async def get_execution_context(request, chatbot: PlaneChatBot, user_id, db) -> Tuple[str, Optional[str], bool]: """Extract execution context from request and database.""" @@ -79,7 +86,7 @@ class BuildModeToolExecutor: if not workspace_slug: workspace_slug = await get_workspace_slug(str(workspace_id)) - token, project_id, _ = await get_execution_context(request, self.chatbot, user_id, self.db) + token, project_id, is_project_chat = await get_execution_context(request, self.chatbot, user_id, self.db) # Validate token exists if not token: @@ -101,7 +108,14 @@ class BuildModeToolExecutor: method_executor = MethodExecutor(actions_executor) - context = {"workspace_slug": workspace_slug, "project_id": project_id, "message_id": message_id, "chat_id": chat_id} + context = { + "workspace_slug": workspace_slug, + "workspace_id": str(workspace_id) if workspace_id else None, + "project_id": project_id, + "message_id": message_id, + "chat_id": chat_id, + "is_project_chat": is_project_chat, + } # Execute single action # If single action, execute directly @@ -134,16 +148,25 @@ class BuildModeToolExecutor: args = planned_action["args"] entity_type = planned_action["entity_type"] + # Map entity_type to actual category (e.g., "epic" -> "workitems") + category = ENTITY_TYPE_TO_CATEGORY.get(entity_type, entity_type) + # Build tool - all_category_tools = self.chatbot._build_method_tools(entity_type, method_executor, context) + all_category_tools = self.chatbot._build_method_tools(category, method_executor, context) # get the specific tool we need tool = next((t for t in all_category_tools if t.name == tool_name), None) if not tool: - raise ValueError(f"Tool '{tool_name}' not found in category '{entity_type}'") + raise ValueError(f"Tool '{tool_name}' not found in category '{category}' (entity_type: '{entity_type}')") # Execute the tool - EXPECT structured payload dict - result = await tool.ainvoke(args) + log.info(f"[DEBUG] execute_single_action: About to invoke tool '{tool_name}' with args: {args}") + try: + result = await tool.ainvoke(args) + log.info(f"[DEBUG] execute_single_action: Tool '{tool_name}' returned successfully. Result: {result}") + except Exception as tool_error: + log.error(f"[DEBUG] execute_single_action: Tool '{tool_name}' raised exception: {tool_error}", exc_info=True) + raise if not isinstance(result, dict): raise ValueError(f"Tool '{tool_name}' must return a structured payload dict, got {type(result)}") diff --git a/apps/pi/pi/services/chat/action_planner.py b/apps/pi/pi/services/chat/action_planner.py index 6c12a51f6f..1b635f70ed 100644 --- a/apps/pi/pi/services/chat/action_planner.py +++ b/apps/pi/pi/services/chat/action_planner.py @@ -13,6 +13,7 @@ import contextlib import json +import re from collections.abc import AsyncIterator from typing import Any from typing import Dict @@ -91,7 +92,7 @@ async def _inject_urls_into_entities(entities: list[Dict[str, Any]] | str, pendi # This happens when action tools (not retrieval tools) are used if not url_map: from pi import settings - from pi.agents.sql_agent.tools import construct_entity_urls_from_db + from pi.agents.sql_agent.helpers import construct_entity_urls_from_db # Collect entity IDs by type entity_ids_by_type: Dict[str, List[str]] = {"issues": [], "pages": [], "cycles": [], "modules": [], "projects": []} @@ -446,6 +447,21 @@ async def execute_tools_for_build_mode( _has_tool_calls = hasattr(response, "tool_calls") and bool(getattr(response, "tool_calls", None)) except Exception: _has_tool_calls = False + + # FAILSAFE: Detect if LLM mistakenly put tool_calls in content instead of using API + # This can happen with certain models/prompts - detect and handle gracefully + response_content = str(getattr(response, "content", "") or "").strip() + if not _has_tool_calls and "```tool_calls" in response_content: + log.warning(f"ChatID: {chat_id} - DETECTED: LLM output tool_calls as markdown instead of API. Stripping from content.") + # Strip the tool_calls markdown block from content to avoid showing JSON to user + # Remove ```tool_calls ... ``` blocks from content + cleaned_content = re.sub(r"```tool_calls\s*\n?\[[\s\S]*?\]\s*\n?```", "", response_content) + cleaned_content = cleaned_content.strip() + # Update response content to cleaned version for streaming + if hasattr(response, "content"): + response.content = cleaned_content + log.info(f"ChatID: {chat_id} - Stripped tool_calls markdown. Cleaned content: {cleaned_content[:200]}...") + log.info(f"ChatID: {chat_id} - Has Tool Calls: {_has_tool_calls}") if _has_tool_calls: @@ -872,6 +888,15 @@ async def execute_tools_for_build_mode( # update _has_tool_calls _has_tool_calls = has_more_tool_calls + # FAILSAFE: Detect if LLM mistakenly put tool_calls in content in iterations + if not _has_tool_calls: + iter_content = str(getattr(response, "content", "") or "").strip() + if "```tool_calls" in iter_content: + log.warning(f"ChatID: {chat_id} - DETECTED (iteration {iteration_count}): LLM output tool_calls as markdown. Stripping.") + cleaned_content = re.sub(r"```tool_calls\s*\n?\[[\s\S]*?\]\s*\n?```", "", iter_content).strip() + if hasattr(response, "content"): + response.content = cleaned_content + # Log planner decisions for this iteration try: if _has_tool_calls: diff --git a/apps/pi/pi/services/chat/helpers/action_execution_helpers.py b/apps/pi/pi/services/chat/helpers/action_execution_helpers.py index 23fc276e43..e29df6fd9f 100644 --- a/apps/pi/pi/services/chat/helpers/action_execution_helpers.py +++ b/apps/pi/pi/services/chat/helpers/action_execution_helpers.py @@ -186,6 +186,12 @@ def extract_tool_params_from_artifact_data(artifact_data: Dict[str, Any], entity "epic": {"cycle_id", "module_ids"}, } + # Fields that are auto-generated by SDK and should never be sent to any tool + SDK_AUTO_GENERATED_FIELDS = {"logo_props", "icon_prop", "emoji"} + + # Fields that are project-specific and should not be sent to other entities + PROJECT_ONLY_FIELDS = {"cover_image"} + tool_params = {} unsupported = CREATE_UNSUPPORTED_FIELDS.get(entity_type, set()) if action == "create" else set() @@ -217,23 +223,29 @@ def extract_tool_params_from_artifact_data(artifact_data: Dict[str, Any], entity if key in {"entity_info", "artifact_sub_type"}: continue + # Map artifact field names to tool parameter names FIRST (needed for subsequent checks) + mapped_key = ARTIFACT_TO_TOOL_MAPPING.get(key, key) + + # Skip SDK auto-generated fields (logo_props, icon_prop, emoji) for all entities + if key in SDK_AUTO_GENERATED_FIELDS or mapped_key in SDK_AUTO_GENERATED_FIELDS: + log.debug(f"Skipping SDK auto-generated field '{key}' for {entity_type}") + continue + + # Skip project-only fields for non-project entities + if entity_type != "project" and (key in PROJECT_ONLY_FIELDS or mapped_key in PROJECT_ONLY_FIELDS): + log.debug(f"Skipping project-only field '{key}' for {entity_type}") + continue + # Filter unsupported fields for this entity type and action if key in unsupported: log.warning(f"Field '{key}' cannot be set during {entity_type} {action}, skipping") continue - # Map artifact field names to tool parameter names - mapped_key = ARTIFACT_TO_TOOL_MAPPING.get(key, key) - if mapped_key == "project_lead" and isinstance(value, dict) and value.get("id"): value = value["id"] tool_params[mapped_key] = value - # Project logo/icon compatibility: - if mapped_key == "logo_props" and "icon_prop" not in tool_params: - tool_params["icon_prop"] = value - log.debug(f"Extracted tool params for {entity_type} {action}: {list(tool_params.keys())}") return tool_params @@ -271,22 +283,64 @@ async def load_artifacts(request_data: List[ArtifactData], db: AsyncSession) -> # Transform edited artifact data from frontend schema to tool parameters tool_args = extract_tool_params_from_artifact_data(req_item.action_data or {}, entity_type, action) - # For UPDATE actions, preserve the entity ID from the original artifact + # Get original tool args for preserving context fields + original_tool_args = artifact.data.get("tool_args_raw", {}) + + # PRESERVE CONTEXT FIELDS for all actions (these are never editable but required for execution) + # workspace_slug and project_id are context, not user-editable fields + if "workspace_slug" in original_tool_args and "workspace_slug" not in tool_args: + tool_args["workspace_slug"] = original_tool_args["workspace_slug"] + log.debug(f"Preserved workspace_slug={original_tool_args["workspace_slug"]} for {entity_type} {action}") + + if "project_id" in original_tool_args and "project_id" not in tool_args: + tool_args["project_id"] = original_tool_args["project_id"] + log.debug(f"Preserved project_id={original_tool_args["project_id"]} for {entity_type} {action}") + + # PRESERVE NON-EDITABLE PLANNING PARAMETERS + # These are planning-time decisions that aren't exposed in the edit UI but are critical for execution + NON_EDITABLE_PARAMS = { + # Project feature flags (planning-time decisions) + "cycle_view", + "module_view", + "page_view", + "intake_view", + "is_issue_type_enabled", + "is_time_tracking_enabled", + "issue_views_view", + "guest_view_all_features", + # Project update_features params (must be preserved from plan) + "epics", + "modules", + "cycles", + "views", + "pages", + "intakes", + "work_item_types", + # Other planning parameters + "timezone", + "archive_in", + "close_in", + "external_id", + "external_source", + # User/owner assignments that may be auto-set + "owned_by", + "project_lead", + "default_assignee", + } + + for param in NON_EDITABLE_PARAMS: + if param in original_tool_args and param not in tool_args: + tool_args[param] = original_tool_args[param] + log.debug(f"Preserved non-editable param {param}={original_tool_args[param]} for {entity_type} {action}") + + # For UPDATE actions, also preserve the entity ID being updated # The frontend edits fields but doesn't include the entity ID being updated if action == "update": - original_tool_args = artifact.data.get("tool_args_raw", {}) - # Preserve the entity's ID field (e.g., issue_id, cycle_id, module_id) id_field = ENTITY_ID_FIELD_MAP.get(entity_type) if id_field and id_field in original_tool_args and id_field not in tool_args: tool_args[id_field] = original_tool_args[id_field] log.debug(f"Preserved {id_field}={original_tool_args[id_field]} for {entity_type} update") - - # Also preserve project_id if it's not in the edited data - # (many update tools require it as context even though it's not being changed) - if "project_id" in original_tool_args and "project_id" not in tool_args: - tool_args["project_id"] = original_tool_args["project_id"] - log.info(f"Preserved project_id={original_tool_args["project_id"]} for {entity_type} update") else: # Use original planned tool arguments tool_args = artifact.data.get("tool_args_raw", {}) diff --git a/apps/pi/pi/services/chat/helpers/build_mode_helpers.py b/apps/pi/pi/services/chat/helpers/build_mode_helpers.py index 2668d34d6b..9df1db9e3d 100644 --- a/apps/pi/pi/services/chat/helpers/build_mode_helpers.py +++ b/apps/pi/pi/services/chat/helpers/build_mode_helpers.py @@ -296,6 +296,7 @@ def build_planning_tools( "module", "worklogs", "worklog", + "workitems", # Added to support epic feature checking via projects_retrieve "epics", "epic", "intake", @@ -307,6 +308,15 @@ def build_planning_tools( "pages", "page", ] + workspace_scoped_cats = [ + "initiatives", + "initiative", + "teamspaces", + "teamspace", + "customers", + "customer", + ] + all_method_tools: List[Any] = [] built_categories: List[str] = [] for sel in selections_list: @@ -331,6 +341,14 @@ def build_planning_tools( tools_for_project = [t for t in tools_for_project if getattr(t, "name", "") in ["projects_retrieve", "projects_update"]] all_method_tools.extend(tools_for_project) + if cat in workspace_scoped_cats: + tools_for_workspace = chatbot_instance._build_planning_method_tools("workspaces", method_executor, context) + # remove all tools except workspaces_get_features and workspaces_update_features + tools_for_workspace = [ + t for t in tools_for_workspace if getattr(t, "name", "") in ["workspaces_get_features", "workspaces_update_features"] + ] + all_method_tools.extend(tools_for_workspace) + except Exception as e: log.warning(f"Failed to build tools for category {cat}: {e}") diff --git a/apps/pi/pi/services/chat/helpers/entity_inference.py b/apps/pi/pi/services/chat/helpers/entity_inference.py index f855d503fa..9472874c2c 100644 --- a/apps/pi/pi/services/chat/helpers/entity_inference.py +++ b/apps/pi/pi/services/chat/helpers/entity_inference.py @@ -22,19 +22,54 @@ async def infer_selected_entity(args: Dict[str, Any], context: Dict[str, Any], e """ Generic best-effort entity inference for execute-action results. - Some tools don’t return an `entity` payload even on success. When we can, + Some tools don't return an `entity` payload even on success. When we can, infer the selected entity from request args + context (e.g., issue_id). """ try: workspace_slug = context.get("workspace_slug") + hint = (entity_type_hint or "").strip().lower() + + # Fast-path inference for entities where URL format is fully deterministic from args/context. + # This is especially important for "relationship" tools (e.g. add work-items to cycle) + # where the API response does not include the cycle/module payload. + base = str(settings.plane_api.FRONTEND_URL or "").rstrip("/") + project_id = args.get("project_id") or context.get("project_id") + + cycle_id = args.get("cycle_id") + if workspace_slug and project_id and cycle_id: + return { + "entity_url": f"{base}/{workspace_slug}/projects/{project_id}/cycles/{cycle_id}/", + "entity_name": None, + "entity_type": "cycle" if hint in {"", "cycle"} else hint, + "entity_id": str(cycle_id), + } + + module_id = args.get("module_id") + if workspace_slug and project_id and module_id: + return { + "entity_url": f"{base}/{workspace_slug}/projects/{project_id}/modules/{module_id}/", + "entity_name": None, + "entity_type": "module" if hint in {"", "module"} else hint, + "entity_id": str(module_id), + } + + # Project URL: /{workspace}/projects/{project}/overview/ + if workspace_slug and project_id and hint == "project": + return { + "entity_url": f"{base}/{workspace_slug}/projects/{project_id}/overview/", + "entity_name": None, + "entity_type": "project", + "entity_id": str(project_id), + } + + # Work-item inference (needs identifier resolution for best UX) issue_id = args.get("issue_id") if workspace_slug and issue_id: - from pi.agents.sql_agent.tools import construct_action_entity_url + from pi.agents.sql_agent.helpers import construct_action_entity_url url_info = await construct_action_entity_url({"id": str(issue_id)}, "workitem", str(workspace_slug), settings.plane_api.FRONTEND_URL) if not url_info or not isinstance(url_info, dict): return None - hint = (entity_type_hint or "").strip().lower() subresource_id_fields = { "comment": "comment_id", diff --git a/apps/pi/pi/services/chat/helpers/placeholder_orchestrator.py b/apps/pi/pi/services/chat/helpers/placeholder_orchestrator.py index a001701343..f813160193 100644 --- a/apps/pi/pi/services/chat/helpers/placeholder_orchestrator.py +++ b/apps/pi/pi/services/chat/helpers/placeholder_orchestrator.py @@ -188,7 +188,7 @@ class PlaceholderOrchestrator: remaining.remove(action) except Exception as e: - log.error(f"Failed to execute action {action.get("tool_name")}: {e}") + log.error(f"Failed to execute action {action.get("tool_name")}: {e}", exc_info=True) # Format error message for user-friendly display formatted_error = _format_validation_error(str(e)) # Create failure result @@ -349,15 +349,16 @@ class PlaceholderOrchestrator: if not entity_type or not entity_name: raise ValueError(f"Failed to parse placeholder: {placeholder}") - # Build lookup key - lookup_key = f"{entity_type}:{entity_name}" + # Build lookup key (lowercase for case-insensitive matching) + lookup_key = f"{entity_type}:{entity_name.lower()}" + name_key = entity_name.lower() - # Try exact match first + # Try exact match first (keys are stored lowercase) if lookup_key in self.execution_context: context_entry = self.execution_context[lookup_key] - elif entity_name in self.execution_context: + elif name_key in self.execution_context: # Fallback to name-only lookup - context_entry = self.execution_context[entity_name] + context_entry = self.execution_context[name_key] else: raise ValueError( f"Entity '{entity_name}' (type: {entity_type}) not found in execution context. " @@ -498,11 +499,24 @@ IMPORTANT: # Build tool if not cached if tool_name not in self.tools_cache: - entity_type = action.get("entity_type") - if entity_type: - tools = self.chatbot._build_method_tools(entity_type, self.method_executor, self.context) - for tool in tools: - self.tools_cache[tool.name] = tool + # Special cases where tool name doesn't follow {category}_{method} pattern + SPECIAL_TOOL_CATEGORIES = { + "create_epic": "workitems", + "update_epic": "workitems", + } + + # Extract category from tool name + if tool_name in SPECIAL_TOOL_CATEGORIES: + category = SPECIAL_TOOL_CATEGORIES[tool_name] + else: + # Standard pattern: {category}_{method} + # e.g., "initiatives_create_label" -> "initiatives" + category = tool_name.split("_")[0] if "_" in tool_name else tool_name + + log.info(f"Building tools for category '{category}' (tool: {tool_name})") + tools = self.chatbot._build_method_tools(category, self.method_executor, self.context) + for tool in tools: + self.tools_cache[tool.name] = tool # Get the tool tool = self.tools_cache.get(tool_name) @@ -510,7 +524,13 @@ IMPORTANT: raise ValueError(f"Tool '{tool_name}' not found") # Execute the tool - result = await tool.ainvoke(args) + log.info(f"[DEBUG] About to invoke tool '{tool_name}' with args: {args}") + try: + result = await tool.ainvoke(args) + log.info(f"[DEBUG] Tool '{tool_name}' invocation completed. Result type: {type(result)}") + except Exception as tool_error: + log.error(f"[DEBUG] Tool '{tool_name}' raised exception during ainvoke: {tool_error}", exc_info=True) + raise if not isinstance(result, dict): raise ValueError(f"Tool '{tool_name}' must return a dict, got {type(result)}") @@ -560,20 +580,22 @@ IMPORTANT: return # Get the planned name from action args - planned_name = action.get("args", {}).get("name") + # Check both 'name' and 'display_name' (properties use display_name) + planned_name = action.get("args", {}).get("name") or action.get("args", {}).get("display_name") - # Build all possible lookup keys + # Build all possible lookup keys (use lowercase for case-insensitive matching) keys = [ - f"{entity_type}:{actual_name}", # e.g., "project:Motor Bike47119" - actual_name, # Allow lookup by actual name alone + f"{entity_type}:{actual_name.lower()}", # e.g., "property:severity" + actual_name.lower(), # Allow lookup by actual name alone ] # IMPORTANT: Also store under planned name if different from actual - # This handles SDK name modifications on conflict (409) - if planned_name and planned_name != actual_name: - keys.append(f"{entity_type}:{planned_name}") # e.g., "project:Motor Bike" - keys.append(planned_name) # Allow lookup by planned name - log.info(f"⚠️ Name mismatch: planned='{planned_name}' vs actual='{actual_name}'. " f"Storing under both names for lookup.") + # This handles SDK name modifications on conflict (409) AND case differences + if planned_name: + keys.append(f"{entity_type}:{planned_name.lower()}") # e.g., "property:severity" + keys.append(planned_name.lower()) # Allow lookup by planned name + if planned_name.lower() != actual_name.lower(): + log.info(f"⚠️ Name mismatch: planned='{planned_name}' vs actual='{actual_name}'. " f"Storing under both names for lookup.") # Store under all keys for key in keys: @@ -583,7 +605,7 @@ IMPORTANT: def _can_resolve_from_context(self, entity_type: Optional[str], entity_name: Optional[str]) -> bool: """ - Check if we have this entity in our execution context. + Check if we have this entity in our execution context (case-insensitive). Args: entity_type: Type of entity (e.g., "project") @@ -595,9 +617,10 @@ IMPORTANT: if not entity_type or not entity_name: return False - # Check if we have this entity stored - key = f"{entity_type}:{entity_name}" - return key in self.execution_context or entity_name in self.execution_context + # Check if we have this entity stored (case-insensitive) + key = f"{entity_type}:{entity_name.lower()}" + name_key = entity_name.lower() + return key in self.execution_context or name_key in self.execution_context def _is_placeholder(self, value: Any) -> bool: """Check if value is a placeholder string.""" diff --git a/apps/pi/pi/services/chat/helpers/tool_utils.py b/apps/pi/pi/services/chat/helpers/tool_utils.py index e9aa590f0f..646680b94c 100644 --- a/apps/pi/pi/services/chat/helpers/tool_utils.py +++ b/apps/pi/pi/services/chat/helpers/tool_utils.py @@ -78,11 +78,12 @@ TOOL_NAME_TO_CATEGORY_MAP: Dict[str, Dict[str, str]] = { "pages_create_project_page": {"entity_type": "page", "action_type": "create", "front_facing_name": "Create Project Page"}, "pages_create_workspace_page": {"entity_type": "page", "action_type": "create", "front_facing_name": "Create Workspace Page"}, # Projects - "projects_archive": {"entity_type": "project", "action_type": "archive", "front_facing_name": "Archive Project"}, "projects_create": {"entity_type": "project", "action_type": "create", "front_facing_name": "Create Project"}, - "projects_unarchive": {"entity_type": "project", "action_type": "unarchive", "front_facing_name": "Unarchive Project"}, + "projects_delete": {"entity_type": "project", "action_type": "delete", "front_facing_name": "Delete Project"}, "projects_update": {"entity_type": "project", "action_type": "update", "front_facing_name": "Update Project"}, "projects_retrieve": {"entity_type": "project", "action_type": "retrieve", "front_facing_name": "Retrieve Project"}, + "projects_get_features": {"entity_type": "project", "action_type": "get", "front_facing_name": "Get Project Features"}, + "projects_update_features": {"entity_type": "project", "action_type": "update", "front_facing_name": "Update Project Features"}, # Properties "properties_create": {"entity_type": "property", "action_type": "create", "front_facing_name": "Create Property"}, "properties_create_option": {"entity_type": "property", "action_type": "create_option", "front_facing_name": "Create Property Option"}, @@ -98,6 +99,52 @@ TOOL_NAME_TO_CATEGORY_MAP: Dict[str, Dict[str, str]] = { "types_create": {"entity_type": "type", "action_type": "create", "front_facing_name": "Create Type"}, "types_delete": {"entity_type": "type", "action_type": "delete", "front_facing_name": "Delete Type"}, "types_update": {"entity_type": "type", "action_type": "update", "front_facing_name": "Update Type"}, + # Initiatives (SDK v0.2.1) + "initiatives_create": {"entity_type": "initiative", "action_type": "create", "front_facing_name": "Create Initiative"}, + "initiatives_list": {"entity_type": "initiative", "action_type": "list", "front_facing_name": "List Initiatives"}, + "initiatives_retrieve": {"entity_type": "initiative", "action_type": "retrieve", "front_facing_name": "Retrieve Initiative"}, + "initiatives_update": {"entity_type": "initiative", "action_type": "update", "front_facing_name": "Update Initiative"}, + "initiatives_delete": {"entity_type": "initiative", "action_type": "delete", "front_facing_name": "Delete Initiative"}, + "initiatives_create_label": {"entity_type": "initiative_label", "action_type": "create", "front_facing_name": "Create Initiative Label"}, + "initiatives_list_labels": {"entity_type": "initiative_label", "action_type": "list", "front_facing_name": "List Initiative Labels"}, + "initiatives_retrieve_label": {"entity_type": "initiative_label", "action_type": "retrieve", "front_facing_name": "Retrieve Initiative Label"}, + "initiatives_update_label": {"entity_type": "initiative_label", "action_type": "update", "front_facing_name": "Update Initiative Label"}, + "initiatives_delete_label": {"entity_type": "initiative_label", "action_type": "delete", "front_facing_name": "Delete Initiative Label"}, + "initiatives_add_labels": {"entity_type": "initiative", "action_type": "add", "front_facing_name": "Add Labels to Initiative"}, + "initiatives_remove_labels": {"entity_type": "initiative", "action_type": "remove", "front_facing_name": "Remove Labels from Initiative"}, + "initiatives_add_projects": {"entity_type": "initiative", "action_type": "add", "front_facing_name": "Add Projects to Initiative"}, + "initiatives_list_projects": {"entity_type": "initiative", "action_type": "list", "front_facing_name": "List Initiative Projects"}, + "initiatives_remove_projects": {"entity_type": "initiative", "action_type": "remove", "front_facing_name": "Remove Projects from Initiative"}, + "initiatives_add_epics": {"entity_type": "initiative", "action_type": "add", "front_facing_name": "Add Epics to Initiative"}, + "initiatives_list_epics": {"entity_type": "initiative", "action_type": "list", "front_facing_name": "List Initiative Epics"}, + "initiatives_remove_epics": {"entity_type": "initiative", "action_type": "remove", "front_facing_name": "Remove Epics from Initiative"}, + # Teamspaces (SDK v0.2.1) + "teamspaces_create": {"entity_type": "teamspace", "action_type": "create", "front_facing_name": "Create Teamspace"}, + "teamspaces_list": {"entity_type": "teamspace", "action_type": "list", "front_facing_name": "List Teamspaces"}, + "teamspaces_retrieve": {"entity_type": "teamspace", "action_type": "retrieve", "front_facing_name": "Retrieve Teamspace"}, + "teamspaces_update": {"entity_type": "teamspace", "action_type": "update", "front_facing_name": "Update Teamspace"}, + "teamspaces_delete": {"entity_type": "teamspace", "action_type": "delete", "front_facing_name": "Delete Teamspace"}, + "teamspaces_add_members": {"entity_type": "teamspace", "action_type": "add", "front_facing_name": "Add Members to Teamspace"}, + "teamspaces_list_members": {"entity_type": "teamspace", "action_type": "list", "front_facing_name": "List Teamspace Members"}, + "teamspaces_remove_members": {"entity_type": "teamspace", "action_type": "remove", "front_facing_name": "Remove Members from Teamspace"}, + "teamspaces_add_projects": {"entity_type": "teamspace", "action_type": "add", "front_facing_name": "Add Projects to Teamspace"}, + "teamspaces_list_projects": {"entity_type": "teamspace", "action_type": "list", "front_facing_name": "List Teamspace Projects"}, + "teamspaces_remove_projects": {"entity_type": "teamspace", "action_type": "remove", "front_facing_name": "Remove Projects from Teamspace"}, + # Stickies (SDK v0.2.1) + "stickies_create": {"entity_type": "sticky", "action_type": "create", "front_facing_name": "Create Sticky Note"}, + "stickies_list": {"entity_type": "sticky", "action_type": "list", "front_facing_name": "List Sticky Notes"}, + "stickies_retrieve": {"entity_type": "sticky", "action_type": "retrieve", "front_facing_name": "Retrieve Sticky Note"}, + "stickies_update": {"entity_type": "sticky", "action_type": "update", "front_facing_name": "Update Sticky Note"}, + "stickies_delete": {"entity_type": "sticky", "action_type": "delete", "front_facing_name": "Delete Sticky Note"}, + # Customers (SDK v0.2.1) + "customers_create": {"entity_type": "customer", "action_type": "create", "front_facing_name": "Create Customer"}, + "customers_list": {"entity_type": "customer", "action_type": "list", "front_facing_name": "List Customers"}, + "customers_retrieve": {"entity_type": "customer", "action_type": "retrieve", "front_facing_name": "Retrieve Customer"}, + "customers_update": {"entity_type": "customer", "action_type": "update", "front_facing_name": "Update Customer"}, + "customers_delete": {"entity_type": "customer", "action_type": "delete", "front_facing_name": "Delete Customer"}, + # Workspaces (SDK v0.2.2 - features) + "workspaces_get_features": {"entity_type": "workspace", "action_type": "get", "front_facing_name": "Get Workspace Features"}, + "workspaces_update_features": {"entity_type": "workspace", "action_type": "update", "front_facing_name": "Update Workspace Features"}, # Workitems and Epics "create_epic": {"entity_type": "epic", "action_type": "create", "front_facing_name": "Create Epic"}, "update_epic": {"entity_type": "epic", "action_type": "update", "front_facing_name": "Update Epic"}, @@ -636,7 +683,7 @@ work_tree_instructions_normal_response = """ **IF the user's request is informational/retrieval-only (questions, searches, listing, checking status):** 1. Use retrieval/search tools to gather the requested information 2. Provide a detailed, elaborate, and neatly formatted answer in the content section based on the retrieved data. Do NOT be brief. -3. Do NOT plan any modifying actions - just return tool_calls for retrieval, then answer in your content +3. Do NOT plan any modifying actions - just invoke retrieval tools, then answer in your content 4. **Formatting Requirements**: While formatting the answer in the final content section, use the following rules: - Use "work-item" (not "issue") and "unique key" (not "Issue ID") terminology @@ -649,8 +696,8 @@ work_tree_instructions_normal_response = """ **IF the user's request requires modifying data (create, update, delete, move, assign, etc.):** 1. Use retrieval/search tools to gather necessary information (IDs, etc.) -2. **AND** plan at least one MODIFYING ACTION using tool_calls (create, update, delete, add, remove, move, etc.) -3. You CANNOT stop after just searching/retrieving - you MUST plan the modifying actions with tool_calls +2. **AND** invoke at least one MODIFYING ACTION tool (create, update, delete, add, remove, move, etc.) +3. You CANNOT stop after just searching/retrieving - you MUST invoke the modifying action tools 4. Provide a very brief summary of what you planned in your content 5. **Formatting Requirements**: While formatting the action plan in the final content section, use the following rules: @@ -667,14 +714,14 @@ work_tree_instructions_normal_response = """ - Do NOT create workaround entities (like workitems) to satisfy these requests - Do NOT plan any actions - just provide the explanation in your content -Note: The system uses absence of tool_calls as the signal to stop and deliver your content as the final answer. For modification requests, include at least one write tool_call in your plan. +Note: The system detects when you stop invoking tools and delivers your content as the final answer. For modification requests, you MUST invoke at least one write action tool. """ # noqa E501 work_tree_instructions_app_response = f""" **IF the user's request is informational/retrieval-only (questions, searches, listing, checking status):** 1. Use retrieval/search tools to gather the requested information 2. Provide a detailed, and elaborate answer in PLAIN TEXT ONLY in the 'text' field in the provide_final_answer_for_app tool based on the retrieved data. Do NOT be brief. -3. Do NOT plan any modifying actions - just return tool_calls for retrieval, then call provide_final_answer_for_app +3. Do NOT plan any modifying actions - just invoke retrieval tools, then call provide_final_answer_for_app 4. **Formatting Requirements - CRITICAL FOR EXTERNAL APP (e.g., Slack) RENDERING**: While generating the answer in the 'text' field in the provide_final_answer_for_app tool, use the following rules: - **ABSOLUTELY NO MARKDOWN FORMATTING**: Do not use **bold**, *italic*, headers, or any markdown syntax @@ -697,8 +744,8 @@ work_tree_instructions_app_response = f""" **IF the user's request requires modifying data (create, update, delete, move, assign, etc.):** 1. Use retrieval/search tools to gather necessary information (IDs, etc.) -2. **AND** plan at least one MODIFYING ACTION using tool_calls (create, update, delete, add, remove, move, etc.) -3. You CANNOT stop after just searching/retrieving - you MUST plan the modifying actions with tool_calls +2. **AND** invoke at least one MODIFYING ACTION tool (create, update, delete, add, remove, move, etc.) +3. You CANNOT stop after just searching/retrieving - you MUST invoke the modifying action tools 4. Provide a very brief summary of what you planned in your content 5. **Formatting Requirements**: While formatting the action plan in the final content section, use the following rules: @@ -714,7 +761,7 @@ work_tree_instructions_app_response = f""" - Do NOT create workaround entities (like workitems) to satisfy these requests - Do NOT plan any actions - just provide the explanation in your content -Note: This is a response for consumption by an external app. So, calling provide_final_answer_for_app signals completion for retrieval-only requests. For modification requests, include at least one write tool_call in your plan. +Note: This is a response for consumption by an external app. Calling provide_final_answer_for_app signals completion for retrieval-only requests. For modification requests, you MUST invoke at least one write action tool. """ # noqa E501 @@ -777,6 +824,32 @@ Use retrieval tools to gather information, then plan the modifying actions based - Do not try to execute actions - only plan them - For multi-property updates, resolve all required IDs first, then set all properties in a single tool call +**MANDATORY REASONING AND COMMUNICATION (CRITICAL - REQUIRED FOR EVERY TOOL CALL):** +- **BEFORE EACH TOOL CALL**: You MUST explain your reasoning and intent + - State what information you're trying to gather or what action you're planning + - Explain why this tool is necessary for completing the user's request + - Describe what you expect to get from the tool and how you'll use it + - Example: "The user wants to check workitems assigned to Anil. First, I need to search for the user 'Anil' to get their ID, then I'll use that ID to filter workitems." # noqa: E501 +- **AFTER EACH TOOL CALL**: You MUST provide a brief summary of what you learned + - Summarize key information obtained from the tool + - Explain how this information helps with the next step + - If the tool returned unexpected results, explain how you'll adapt + - Example: "Found user Anil Kumar with ID xyz-123. Now I'll use this ID to search for workitems assigned to them." # noqa: E501 +- **THINKING OUT LOUD**: Express your thought process naturally + - Share your understanding of the user's request + - Explain your strategy for accomplishing the task + - Mention any assumptions you're making +- This reasoning is MANDATORY and helps with debugging and understanding your decision-making process +- NEVER skip the reasoning - it's essential for transparency and troubleshooting + +**CRITICAL: OPTIONAL PARAMETERS POLICY (APPLIES TO ALL TOOLS):** +- Only provide optional parameters that are EXPLICITLY requested by the user or clearly implied by their intent +- DO NOT auto-fill optional fields with "sensible defaults" or inferred values +- **DO NOT ask the user for clarification if optional parameters are missing.** +- **IF an optional parameter is not provided, you MUST leave it empty and let the API use its default.** +- This applies to ALL optional fields across ALL entity types (description, priority, state, assignees, dates, etc.) +- When in doubt, omit the field. + **WORKSPACE-LEVEL QUERIES (EFFICIENCY):** |- When query is related to workitems and spans ALL projects without specifying a particular project or projects | - DO NOT: call `list_member_projects` then query each project with `structured_db_tool` separately @@ -787,28 +860,76 @@ Use retrieval tools to gather information, then plan the modifying actions based |- For project-specific queries, include the specific `project_id` **PROJECT FEATURES CHECK (CRITICAL - MANDATORY BEFORE CREATING PROJECT-SCOPED ENTITIES):** -- Cycles, modules, pages, workitem types, views, intake, and time-tracking (worklogs) are project-level features that are enabled/disabled on a per-project basis. -- **MANDATORY WORKFLOW**: Before creating ANY of these entities (cycles_create, modules_create, pages_create_*, worklogs_create, etc.), you MUST: - 1. First get the project_id (via search_project_by_name or search_project_by_identifier if not already known) - 2. **THEN** call `projects_retrieve` with that project_id to check if the feature is enabled - 3. **AFTER checking**: You MUST plan the required actions using tool_calls: - - If the feature IS enabled: Plan the creation action (e.g., `cycles_create`, `modules_create`, `worklogs_create`) - - If the feature is NOT enabled: Plan BOTH `projects_update` (to enable the feature) AND the creation action (e.g., `cycles_create`, `modules_create`, `worklogs_create`) - 4. **CRITICAL**: After `projects_retrieve` completes, you CANNOT stop - you MUST continue planning the modifying actions. Do NOT return only text - you MUST return tool_calls for the planned actions. - 5. **REMEMBER**: `projects_retrieve` is ONLY for information gathering. The user's actual request (create cycle/module/page/worklog) is NOT complete until you plan the creation action with tool_calls. Do NOT stop after retrieval - the task is incomplete without planning the creation. -- **CRITICAL**: This check is NON-NEGOTIABLE. Never skip the `projects_retrieve` step when creating cycles, modules, pages, or other project-scoped features. -- **CRITICAL**: After retrieving project features, you MUST plan the actions - do NOT stop after retrieval. The user requested a modification (create cycle/module/page/worklog), so you MUST plan it with tool_calls. -- **EXCEPTION (NEW PROJECT IN CURRENT PLAN)**: If the target project is being CREATED in this same plan and does not yet have a real UUID, do NOT call `projects_retrieve` during planning. Use placeholders for downstream actions and defer any feature checks until after execution or when working with an existing project that has a UUID. +- Cycles, modules, pages, workitem types, views, intake, epics (a special workitem type), and time-tracking (worklogs) are project-level features that are enabled/disabled on a per-project basis. +- **MANDATORY WORKFLOW**: Before creating ANY of these entities (cycles_create, create_epic, modules_create, pages_create_*, worklogs_create, intake_create, etc.), you MUST: + 1. First get the project_id (UUID) (via search_project_by_name or search_project_by_identifier (Identifier looks like 'PROJ', 'WEB' etc. It is not a UUID) if not already known) + 2. **THEN** call `projects_retrieve` with that project_id to check if the feature is enabled. + - **WARNING**: Do NOT stop to ask for clarification on optional entity fields (e.g., description, priority) before this check. + - **WARNING**: You must perform this check even if you think you need more information for the creaton. + - **WARNING**: You must perform this check even if you already have all required IDs (issue_id, project_id, etc.). Having IDs does NOT exempt you from checking if the feature is enabled. + 3. **AFTER checking**: You MUST invoke the required action tools: + - If the feature IS enabled: Plan the creation action (e.g., `cycles_create`, `create_epic`, `modules_create`, `types_create`, `worklogs_create`, `intake_create`, etc.) + - If the feature is NOT enabled: Plan BOTH `projects_update` (to enable the feature) AND the creation action (e.g., `cycles_create`, `create_epic`, `modules_create`, `types_create`, `worklogs_create`, `intake_create`, etc.) + 4. **CRITICAL**: After `projects_retrieve` completes, you CANNOT stop - you MUST continue invoking the modifying action tools. Do NOT return only text - you MUST invoke the action tools. +- **CRITICAL**: This check is NON-NEGOTIABLE. Never skip the `projects_retrieve` step when creating cycles, modules, pages, types, intake items, or other project-scoped features. +- **CONSEQUENCE**: Skipping `projects_retrieve` and directly calling creation tools (e.g., worklogs_create, cycles_create, intake_create) WILL result in a 404 error if the feature is disabled. Always check first. +- **MULTI-ACTION REQUESTS**: When handling requests with multiple actions, if ANY action requires a feature check, you MUST call `projects_retrieve` FIRST before planning any of the actions. +- **EXCEPTION (NEW PROJECT IN CURRENT PLAN)**: If the target project is being CREATED in this same plan and does not yet have a real UUID: + - Do NOT call `projects_retrieve` during planning (the project doesn't exist yet to retrieve) + - Instead, you MUST enable the required feature flag during `projects_create` itself + - **Example**: If planning to create a project AND a cycle in the same plan: + 1. Call `projects_create` with `cycle_view=True` (to enable cycles feature) + 2. Call `cycles_create` with `project_id="