diff --git a/apps/pi/pi/services/actions/tools/comments.py b/apps/pi/pi/services/actions/tools/comments.py index 7581ab6e8c..88347c030d 100644 --- a/apps/pi/pi/services/actions/tools/comments.py +++ b/apps/pi/pi/services/actions/tools/comments.py @@ -1,7 +1,8 @@ """ -Comments API tools for Plane issue comment management. +Comments API tools for Plane issue comments operations. """ +import uuid from typing import Any from typing import Dict from typing import Optional @@ -31,7 +32,7 @@ def get_comment_tools(method_executor, context): Args: issue_id: Issue ID (required) comment_html: Comment content in HTML format (required) - project_id: Project ID (optional, auto-filled from context) + 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 @@ -43,6 +44,9 @@ def get_comment_tools(method_executor, context): 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", @@ -85,7 +89,7 @@ def get_comment_tools(method_executor, context): Args: comment_id: Comment ID (required) issue_id: Issue ID (required) - project_id: Project ID (optional, auto-filled from context) + 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 @@ -94,6 +98,26 @@ def get_comment_tools(method_executor, context): 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", @@ -123,7 +147,7 @@ def get_comment_tools(method_executor, context): 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) + 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 @@ -134,6 +158,26 @@ def get_comment_tools(method_executor, context): 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", @@ -162,7 +206,7 @@ def get_comment_tools(method_executor, context): Args: comment_id: Comment ID (required) issue_id: Issue ID (required) - project_id: Project ID (optional, auto-filled from context) + 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 @@ -171,6 +215,26 @@ def get_comment_tools(method_executor, context): 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", diff --git a/apps/pi/pi/services/actions/tools/worklogs.py b/apps/pi/pi/services/actions/tools/worklogs.py index 6f44223ab1..cdfa5b1b3f 100644 --- a/apps/pi/pi/services/actions/tools/worklogs.py +++ b/apps/pi/pi/services/actions/tools/worklogs.py @@ -2,6 +2,7 @@ Worklogs API tools for Plane time tracking operations. """ +import uuid from typing import Any from typing import Dict from typing import Optional @@ -31,7 +32,7 @@ def get_worklog_tools(method_executor, context): issue_id: Issue ID (required) description: Worklog description (required) duration: Duration in minutes (required) - project_id: Project ID (optional, auto-filled from context) + 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 @@ -40,6 +41,26 @@ def get_worklog_tools(method_executor, context): 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", @@ -67,6 +88,26 @@ def get_worklog_tools(method_executor, context): 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", @@ -122,7 +163,7 @@ def get_worklog_tools(method_executor, context): 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) + 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 @@ -131,6 +172,26 @@ def get_worklog_tools(method_executor, context): 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: 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 233e24aedc..53b73bfbb9 100644 --- a/apps/pi/pi/services/chat/helpers/action_execution_helpers.py +++ b/apps/pi/pi/services/chat/helpers/action_execution_helpers.py @@ -150,6 +150,195 @@ ENTITY_ID_FIELD_MAP = { } +def extract_tool_params_from_artifact_data(artifact_data: Dict[str, Any], entity_type: str, action: str) -> Dict[str, Any]: + """ + Extract tool-compatible parameters from artifact data for execution. + + This is the REVERSE of prepare_edited_workitem_artifact_data: + - prepare_edited: enriches simple IDs → full objects (for display) + - extract_tool_params: extracts/renames fields (for execution) + + Args: + artifact_data: Raw data from frontend (uses artifact schema field names) + entity_type: Type of entity (workitem, epic, project, etc.) + action: Action type (create, update, delete) + + Returns: + Tool-compatible parameters dictionary with renamed fields and unsupported fields removed + """ + # Field mapping: artifact schema → tool parameter names + ARTIFACT_TO_TOOL_MAPPING = { + "assignee_ids": "assignees", + "label_ids": "labels", + "parent_id": "parent", + "state_id": "state", # SDK adapter handles state_id internally + "lead_id": "lead", # Module lead + "member_ids": "members", # Module members + } + + # Fields that cannot be set during creation (require separate API calls after creation) + CREATE_UNSUPPORTED_FIELDS = { + "workitem": {"cycle_id", "module_ids"}, + "epic": {"cycle_id", "module_ids"}, + } + + tool_params = {} + unsupported = CREATE_UNSUPPORTED_FIELDS.get(entity_type, set()) if action == "create" else set() + + # Handle nested properties structure if present + data_to_process = artifact_data.copy() + if "properties" in data_to_process and isinstance(data_to_process["properties"], dict): + # Merge properties into top level for processing + properties = data_to_process.pop("properties") + data_to_process.update(properties) + + for key, value in data_to_process.items(): + # Skip None values + if value is None: + continue + + # Skip empty lists (but allow empty strings for explicit clearing) + if isinstance(value, list) and not value: + continue + + # Skip empty description_html - API rejects empty string, use None instead + if key == "description_html" and value == "": + continue + + # Skip metadata fields that shouldn't be sent to tools + if key in {"entity_info", "artifact_sub_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) + tool_params[mapped_key] = value + + log.debug(f"Extracted tool params for {entity_type} {action}: {list(tool_params.keys())}") + return tool_params + + +# Implicit dependency rules for action execution +# Format: (prerequisite_tool, dependent_tool) +# When both tools are present in planned actions, dependent must wait for prerequisite +IMPLICIT_DEPENDENCY_RULES = [ + # Project updates must complete before creating project-scoped features + # (modules, cycles, pages, worklogs, views, intake all require project feature flags) + ("projects_update", "modules_create"), + ("projects_update", "cycles_create"), + ("projects_update", "worklogs_create"), + ("projects_update", "pages_create"), + ("projects_update", "views_create"), + ("projects_update", "intake_create"), + # Entity creation must complete before adding items to it + ("modules_create", "modules_add_work_items"), + ("cycles_create", "cycles_add_work_items"), +] + +# Entity type to ID field mapping for update operations +# Maps entity types to the parameter name used for their ID in update tools +ENTITY_ID_FIELD_MAP = { + "workitem": "issue_id", + "epic": "issue_id", + "cycle": "cycle_id", + "module": "module_id", + "project": "project_id", + "label": "label_id", + "state": "state_id", + "comment": "comment_id", + "attachment": "attachment_id", + "type": "type_id", + "property": "property_id", + "intake": "intake_id", + "worklog": "worklog_id", + "link": "link_id", +} + + +async def validate_and_resolve_ids(tool_args: Dict[str, Any], workspace_slug: Optional[str] = None) -> Dict[str, Any]: + """ + Validate and resolve issue_id and project_id parameters before tool execution. + + Handles two cases: + 1. issue_id as work item identifier (e.g., 'CARB-1') instead of UUID + 2. project_id as non-UUID value (e.g., project identifier, URL, etc.) + + Args: + tool_args: Tool arguments from planned action + workspace_slug: Workspace context for lookups + + Returns: + Updated tool_args with resolved UUIDs + """ + import uuid as uuid_module + + resolved_args = tool_args.copy() + issue_id = resolved_args.get("issue_id") + project_id = resolved_args.get("project_id") + + # Step 1: Validate and resolve issue_id if it's a work item identifier (e.g., CARB-1) + if issue_id: + try: + uuid_module.UUID(str(issue_id)) + # issue_id is already a valid UUID, no need to resolve + except (ValueError, AttributeError): + # issue_id is not a UUID, might be an identifier like CARB-1 + # Try to resolve it using search_workitem_by_identifier + try: + from pi.app.api.v1.helpers.plane_sql_queries import search_workitem_by_identifier + + workitem_info = await search_workitem_by_identifier(str(issue_id), workspace_slug) + if workitem_info and workitem_info.get("id"): + log.info(f"Resolved issue_id identifier '{issue_id}' to UUID: {workitem_info["id"]}") + resolved_args["issue_id"] = str(workitem_info["id"]) # Convert to string + + # Also resolve project_id from workitem if not already set + if not project_id and workitem_info.get("project_id"): + resolved_args["project_id"] = str(workitem_info["project_id"]) # Convert to string + log.info(f"Auto-resolved project_id from workitem: {workitem_info["project_id"]}") + + except Exception as e: + log.warning(f"Failed to resolve issue_id '{issue_id}' from identifier: {e}") + + # Step 2: Validate and resolve project_id if it's not a valid UUID + # Only resolve if we have an issue_id to resolve from + if issue_id and project_id: + try: + uuid_module.UUID(str(project_id)) + # project_id is a valid UUID, no need to resolve + except (ValueError, AttributeError): + # project_id is not a UUID (might be URL, identifier, etc.) + # Resolve from issue_id + if resolved_args.get("issue_id"): # Use potentially resolved issue_id + 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(resolved_args["issue_id"])) + if issue_info and issue_info.get("project_id"): + log.info(f"Resolved invalid project_id '{project_id}' to UUID: {issue_info["project_id"]}") + resolved_args["project_id"] = str(issue_info["project_id"]) # Convert to string + except Exception as e: + log.warning(f"Failed to resolve project_id from issue_id: {e}") + elif issue_id and not project_id: + # project_id missing entirely, try to resolve from issue_id + if resolved_args.get("issue_id"): + 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(resolved_args["issue_id"])) + if issue_info and issue_info.get("project_id"): + log.info(f"Resolved missing project_id from issue_id: {issue_info["project_id"]}") + resolved_args["project_id"] = str(issue_info["project_id"]) # Convert to string + except Exception as e: + log.warning(f"Failed to resolve missing project_id from issue_id: {e}") + + return resolved_args + + def extract_tool_params_from_artifact_data(artifact_data: Dict[str, Any], entity_type: str, action: str) -> Dict[str, Any]: """ Extract tool-compatible parameters from artifact data for execution. @@ -229,6 +418,9 @@ async def load_artifacts(request_data: List[ArtifactData], db: AsyncSession) -> original_query = artifacts[0].data.get("planning_context", {}).get("original_query", "") conversation_context = artifacts[0].data.get("planning_context", {}).get("conversation_context", {}) + # Extract workspace_slug from conversation_context for ID resolution + workspace_slug = conversation_context.get("workspace_slug") + planned_actions = [] for artifact, req_item in zip(artifacts, request_data): entity_type = artifact.data.get("planning_data", {}).get("artifact_type", "") @@ -258,6 +450,10 @@ async def load_artifacts(request_data: List[ArtifactData], db: AsyncSession) -> # Use original planned tool arguments tool_args = artifact.data.get("tool_args_raw", {}) + # CENTRALIZED ID VALIDATION AND RESOLUTION + # Resolve issue_id (if identifier like CARB-1) and project_id (if invalid UUID) before execution + tool_args = await validate_and_resolve_ids(tool_args, workspace_slug) + planned_actions.append({ "artifact_id": str(artifact.id), "tool_name": artifact.data.get("planning_data", {}).get("tool_name", ""), diff --git a/apps/pi/pi/tests/example_slack_answer_request.py b/apps/pi/pi/tests/example_slack_answer_request.py index 57c0ed30de..e5af88963e 100644 --- a/apps/pi/pi/tests/example_slack_answer_request.py +++ b/apps/pi/pi/tests/example_slack_answer_request.py @@ -13,7 +13,7 @@ import requests # Configuration BASE_URL = "http://localhost:8001" # Update with your actual base URL BASE_PATH = "" # Update if your API has a base path (e.g., "/pi") -AUTHORIZATION_TOKEN = "plane_api_c9a0aae9011c483bae231b655afac38d" # Your Plane access token or API key +AUTHORIZATION_TOKEN = "plane_api_28a5ceb716424a47b358881d9d185e1f" # Your Plane access token or API key # AUTHORIZATION_TOKEN = "plane_api_c9a0aae9011c483bae231b655afac38d" # Your Plane access token or API key # Endpoint URL @@ -46,45 +46,9 @@ project_id = "47ed2e28-3e91-4ba8-a463-4a117941e2d4" context = {"first_name": "sunder", "last_name": "Chintada"} query = """ -This was the previous conversation history: \n\n -===================\n\n -User: give me one issue assigned to me in PALL project -Assistant: There are currently no work-items assigned to you in the PALL project. If you need help finding work-items in another project or have a different request, just let me know! -Assistant: There are currently no work-items assigned to you in the PALL project. If you need help finding work-items in another project or want to adjust your search, just let me know! -User: create a task in PALL project title - "Hello task" and assign it to me -Assistant: Successfully created work item 'Hello task' - - -User: do I have any task assigned to me in PALL project. If yes, show it -Assistant: Yes, you currently have one task assigned to you in the PALL project: - -• *Task Name:* Hello task -• *Unique Key:* Not provided in the data -• *Priority:* None -• *State:* Backlog - -If you need more details or want to take action on this task, just let me know! -User: what project are we discussing in this thread -Assistant: It seems like you're referring to a previous conversation about the PALL project. If you have any specific questions or need further information about the PALL project, feel free to ask! -User: create a task with title Test World -Assistant: Successfully created work item 'Test World' - - -User: what are we discussing? -Assistant: I'm sorry, but I don't have access to past conversation history. However, I can help with any questions or information you need right now. Feel free to ask! -User: try again -Assistant: I'm here to help with any questions or tasks you have. However, I don't have access to past conversation history. If you have any specific questions or need assistance, feel free to let me know! -User: past conversation is available under `This was the previous conversation history` what are we discussing -Assistant: I'm sorry, but I don't have access to the previous conversation history. However, if you provide me with some details or context about the project and work items, I'd be happy to help you summarize or discuss them further! -User: summarize this thread -Assistant: I'm sorry, but I can't provide a summary of the conversation history as there is no prior conversation content available. If you have specific questions or need assistance with a project, feel free to let me know! - -===================\n\n -πCurrent user messageπ:\ -What are we discussing in this thread? +Here is the conversation history for context: =================== User: can you talk to SSI folks once. also anything from EOP? User: any update on this for SSI? User: CDN one resolved, we are fixing all external call related issues in Air-gapped 2.0.0 commercial release. User: do let us know if you hear anything from SSI, . User: Can we check with them on which version they are on? User: I guess they are still running Plane v1.11.1 User: If they are on the old version. Please request them to move to latest version. User: New Airgapped License can be downloaded from the prime.plane.so User: Here was Jake @ SSIs response to me: "Thanks Jason. There are no active issues I am aware of besides the broken image links on the air-gapped version. Any word on if your team is close to a fix there?" User: create an issue on we application project and assign it to Manish here’s context Here was Jake @ SSIs response to me: "Thanks Jason. There are no active issues I am aware of besides the broken image links on the air-gapped version. Any word on if your team is close to a fix there?" Assistant: Successfully created work item 'Chassis Design' - User: created the issue you’ll be notified after it’s fixed User: Any idea which version they are on? User: I have updated the description with details. in the above ticket. User: Hmm, may know User: About? User: > Any idea which version they are on? Curious if he knows the version User: Ohh okay. User: Just to set the expectation. The image thing that was mentioned by SSI will not be fixed as part of v2.0.0. Which is scheduled to release on 5th december. User: in the same issue can you add a comment saying "Send latest instructions for SSI to upgrade to v2, Sriram mentioned their issue will be fixed by tomorrow after a patch" =================== πCurrent user messageπ: in the same issue can you add a comment saying "Send latest instructions for SSI to upgrade to v2, Sriram mentioned their issue will be fixed by tomorrow after a patch" """ # noqa E501 -query = """ -πCurrent user messageπ: -create a task with title Test World in Starlette project -""" + # Request payload - ChatRequest schema payload = { "query": query,