diff --git a/services/src/main/java/io/meeds/task/mcp/TaskMcpTool.java b/services/src/main/java/io/meeds/task/mcp/TaskMcpTool.java index b1d7a0758..9613375dd 100644 --- a/services/src/main/java/io/meeds/task/mcp/TaskMcpTool.java +++ b/services/src/main/java/io/meeds/task/mcp/TaskMcpTool.java @@ -32,8 +32,10 @@ import java.util.Collections; import java.util.Date; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.ResourceBundle; import java.util.Set; @@ -54,6 +56,8 @@ import org.exoplatform.social.core.space.SpaceUtils; import org.exoplatform.social.core.space.model.Space; import org.exoplatform.social.core.space.spi.SpaceService; +import org.exoplatform.social.metadata.favorite.FavoriteService; +import org.exoplatform.social.metadata.favorite.model.Favorite; import org.exoplatform.task.dao.OrderBy; import org.exoplatform.task.dao.TaskQuery; import org.exoplatform.task.domain.Priority; @@ -83,6 +87,7 @@ import io.meeds.task.mcp.model.ProjectCollectionModel; import io.meeds.task.mcp.model.ProjectLabel; import io.meeds.task.mcp.model.ProjectModel; +import io.meeds.task.mcp.model.ProjectStatisticsModel; import io.meeds.task.mcp.model.ProjectStatus; import io.meeds.task.mcp.model.TaskChangeLog; import io.meeds.task.mcp.model.TaskCollectionModel; @@ -150,6 +155,8 @@ public class TaskMcpTool implements McpToolPlugin { private final PermanentLinkService permanentLinkService; + private final FavoriteService favoriteService; + public TaskMcpTool(ProjectService projectService, StatusService statusService, TaskService taskService, @@ -162,7 +169,9 @@ public TaskMcpTool(ProjectService projectService, ProfilePropertyService profilePropertyService, UserACL userAcl, UserPortalConfigService portalConfigService, - PermanentLinkService permanentLinkService) { + PermanentLinkService permanentLinkService, + FavoriteService favoriteService) { + this.favoriteService = favoriteService; this.projectService = projectService; this.statusService = statusService; this.taskService = taskService; @@ -524,6 +533,31 @@ public void removeProjectLabelFromTask(Long taskId, Long labelId) throws Illegal labelService.removeTaskFromLabel(task, labelId); } + @SneakyThrows + public List listTaskLabels(long taskId) throws IllegalAccessException, ObjectNotFoundException { + TaskDto task = getTask(taskId); + if (task == null) { + throw new ObjectNotFoundException(MSG_TASK_NOT_FOUND.formatted(taskId)); + } else if (!userAcl.hasAccessPermission(TaskAclPlugin.OBJECT_TYPE, String.valueOf(taskId), getCurrentUserName())) { + throw new IllegalAccessException(MSG_TASK_NOT_ACCESSIBLE.formatted(taskId, getCurrentUserName())); + } + return getTaskLabels(task, getProjectId(task), getCurrentUserAclIdentity()); + } + + @SneakyThrows + public ProjectLabel updateProjectLabel(long labelId, String name) throws IllegalAccessException, ObjectNotFoundException { + LabelDto label = getEditableLabel(labelId); + label.setName(name); + label = labelService.updateLabel(label); + return new ProjectLabel(label.getId(), label.getName()); + } + + @SneakyThrows + public void deleteProjectLabel(long labelId) throws IllegalAccessException, ObjectNotFoundException { + getEditableLabel(labelId); + labelService.removeLabel(labelId); + } + @SneakyThrows public void updateTaskStatus(Long taskId, Long statusId) throws IllegalAccessException, ObjectNotFoundException { TaskDto task = getTask(taskId); @@ -541,6 +575,247 @@ public void updateTaskStatus(Long taskId, Long statusId) throws IllegalAccessExc taskService.updateTask(task); } + public TaskModel setTaskDates(long taskId, + String startDate, + String endDate, + String dueDate) throws ObjectNotFoundException, IllegalAccessException { + TaskDto task = getEditableTask(taskId); + task.setStartDate(toDate(startDate)); + task.setEndDate(toDate(endDate)); + task.setDueDate(toDate(dueDate)); + task = taskService.updateTask(task); + return toTaskModel(task); + } + + public TaskModel completeTask(long taskId) throws ObjectNotFoundException, IllegalAccessException { + return setTaskCompleted(taskId, true); + } + + public TaskModel reopenTask(long taskId) throws ObjectNotFoundException, IllegalAccessException { + return setTaskCompleted(taskId, false); + } + + public TaskModel setTaskPriority(long taskId, Priority priority) throws ObjectNotFoundException, IllegalAccessException { + TaskDto task = getEditableTask(taskId); + task.setPriority(priority == null ? Priority.NONE : priority); + task = taskService.updateTask(task); + return toTaskModel(task); + } + + public TaskCollectionModel listUnscheduledTasks(Long projectId, + Integer limit) throws IllegalAccessException, ObjectNotFoundException { + int max = getInteger(limit, DEFAULT_LIMIT); + List unscheduled = getTasks(projectId, 0, MAX_TO_LOAD, true).stream() + .filter(t -> t.getStartDate() == null + && t.getDueDate() == null) + .limit(max) + .map(this::toTaskModel) + .toList(); + return new TaskCollectionModel(unscheduled, 0, max, unscheduled.size()); + } + + private TaskModel setTaskCompleted(long taskId, boolean completed) throws ObjectNotFoundException, IllegalAccessException { + TaskDto task = getEditableTask(taskId); + task.setCompleted(completed); + task = taskService.updateTask(task); + return toTaskModel(task); + } + + private TaskDto getEditableTask(long taskId) throws ObjectNotFoundException, IllegalAccessException { + TaskDto task = getTask(taskId); + if (task == null) { + throw new ObjectNotFoundException(MSG_TASK_NOT_FOUND.formatted(taskId)); + } else if (!userAcl.hasEditPermission(TaskAclPlugin.OBJECT_TYPE, String.valueOf(taskId), getCurrentUserName())) { + throw new IllegalAccessException(MSG_TASK_NOT_EDITABLE.formatted(taskId, getCurrentUserName())); + } + return task; + } + + public ProjectModel updateProject(Long projectId, + String name, + String description, + String color) throws ObjectNotFoundException, IllegalAccessException { + ProjectDto project = getEditableProject(projectId); + if (StringUtils.isNotBlank(name)) { + project.setName(name); + } + if (description != null) { + project.setDescription(description); + } + if (StringUtils.isNotBlank(color)) { + project.setColor(color); + } + project = projectService.updateProject(project); + return toProjectModel(project); + } + + public ProjectModel addProjectMember(Long projectId, String username) throws ObjectNotFoundException, IllegalAccessException { + return updateProjectMembership(projectId, username, true, false); + } + + public ProjectModel removeProjectMember(Long projectId, String username) throws ObjectNotFoundException, + IllegalAccessException { + return updateProjectMembership(projectId, username, false, false); + } + + public ProjectModel setProjectManager(Long projectId, String username) throws ObjectNotFoundException, IllegalAccessException { + return updateProjectMembership(projectId, username, true, true); + } + + public ProjectModel removeProjectManager(Long projectId, String username) throws ObjectNotFoundException, + IllegalAccessException { + return updateProjectMembership(projectId, username, false, true); + } + + public ProjectStatus createProjectStatus(Long projectId, String name) throws ObjectNotFoundException, IllegalAccessException { + ProjectDto project = getEditableProject(projectId); + StatusDto status = statusService.createStatus(project, name); + return new ProjectStatus(status.getId(), status.getName(), status.getRank()); + } + + @SneakyThrows + public ProjectStatus renameProjectStatus(Long statusId, String name) throws ObjectNotFoundException, IllegalAccessException { + StatusDto status = statusService.getStatus(statusId); + if (status == null) { + throw new ObjectNotFoundException(MSG_STATUS_NOT_FOUND.formatted(statusId, "[]")); + } + // ensure the caller can edit the owning project + getEditableProject(status.getProject().getId()); + status = statusService.updateStatus(statusId, name); + return new ProjectStatus(status.getId(), status.getName(), status.getRank()); + } + + private ProjectModel updateProjectMembership(Long projectId, + String username, + boolean add, + boolean manager) throws ObjectNotFoundException, IllegalAccessException { + if (StringUtils.isBlank(username)) { + throw new IllegalArgumentException("Parameter 'username' is mandatory"); + } + username = username.startsWith("@") ? username.substring(1) : username; + ProjectDto project = getEditableProject(projectId); + Set members = new HashSet<>(manager ? safeSet(project.getManager()) : safeSet(project.getParticipator())); + if (add) { + members.add(username); + } else { + members.remove(username); + } + if (manager) { + project.setManager(members); + } else { + project.setParticipator(members); + } + project = projectService.updateProject(project); + return toProjectModel(project); + } + + private Set safeSet(Set set) { + return set == null ? Collections.emptySet() : set; + } + + private ProjectDto getEditableProject(Long projectId) throws ObjectNotFoundException, IllegalAccessException { + ProjectDto project = getProject(projectId); + if (project == null) { + throw new ObjectNotFoundException(MSG_TASK_PROJECT_NOT_FOUND.formatted(projectId)); + } else if (!project.canEdit(getCurrentUserAclIdentity())) { + throw new IllegalAccessException(MSG_TASK_PROJECT_NOT_EDITABLE.formatted(projectId, getCurrentUserName())); + } + return project; + } + + public ProjectStatisticsModel getProjectStatistics(Long projectId) throws ObjectNotFoundException, IllegalAccessException { + getViewableProject(projectId); + Map byStatus = new LinkedHashMap<>(); + for (StatusDto status : statusService.getStatuses(projectId)) { + byStatus.put(status.getName(), 0L); + } + long total = 0; + List rows = taskService.countTaskStatusByProject(projectId); + if (rows != null) { + for (Object[] row : rows) { + String name = (String) row[0]; + long count = ((Number) row[1]).longValue(); + byStatus.put(name, count); + total += count; + } + } + return new ProjectStatisticsModel(total, byStatus); + } + + public void favoriteProject(Long projectId) throws ObjectNotFoundException, IllegalAccessException { + getViewableProject(projectId); + createFavorite(ProjectPermanentLinkPlugin.OBJECT_TYPE, String.valueOf(projectId)); + } + + public void unfavoriteProject(Long projectId) throws ObjectNotFoundException, IllegalAccessException { + getViewableProject(projectId); + removeFavorite(ProjectPermanentLinkPlugin.OBJECT_TYPE, String.valueOf(projectId)); + } + + public void favoriteTask(long taskId) throws ObjectNotFoundException, IllegalAccessException { + getAccessibleTask(taskId); + createFavorite(TaskAclPlugin.OBJECT_TYPE, String.valueOf(taskId)); + } + + public void unfavoriteTask(long taskId) throws ObjectNotFoundException, IllegalAccessException { + getAccessibleTask(taskId); + removeFavorite(TaskAclPlugin.OBJECT_TYPE, String.valueOf(taskId)); + } + + @SneakyThrows + public void deleteTaskComment(long commentId) throws ObjectNotFoundException, IllegalAccessException { + CommentDto comment = commentService.getComment(commentId); + if (comment == null) { + throw new ObjectNotFoundException("Comment with identifier '%s' is not found".formatted(commentId)); + } + long taskId = comment.getTask().getId(); + boolean isAuthor = StringUtils.equals(comment.getAuthor(), getCurrentUserName()); + if (!isAuthor && !userAcl.hasEditPermission(TaskAclPlugin.OBJECT_TYPE, String.valueOf(taskId), getCurrentUserName())) { + throw new IllegalAccessException("Comment '%s' is not deletable by user '%s'".formatted(commentId, getCurrentUserName())); + } + commentService.removeComment(commentId); + } + + @SneakyThrows + private void createFavorite(String objectType, String objectId) { + Favorite favorite = new Favorite(objectType, objectId, null, getCurrentUserIdentityId()); + if (!favoriteService.isFavorite(favorite)) { + favoriteService.createFavorite(favorite); + } + } + + @SneakyThrows + private void removeFavorite(String objectType, String objectId) { + Favorite favorite = new Favorite(objectType, objectId, null, getCurrentUserIdentityId()); + if (favoriteService.isFavorite(favorite)) { + favoriteService.deleteFavorite(favorite); + } + } + + private long getCurrentUserIdentityId() { + return Long.parseLong(identityManager.getOrCreateUserIdentity(getCurrentUserName()).getId()); + } + + private ProjectDto getViewableProject(Long projectId) throws ObjectNotFoundException, IllegalAccessException { + ProjectDto project = getProject(projectId); + if (project == null) { + throw new ObjectNotFoundException(MSG_TASK_PROJECT_NOT_FOUND.formatted(projectId)); + } else if (!project.canView(getCurrentUserAclIdentity())) { + throw new IllegalAccessException(MSG_TASK_PROJECT_NOT_ACCESSIBLE.formatted(projectId, getCurrentUserName())); + } + return project; + } + + private TaskDto getAccessibleTask(long taskId) throws ObjectNotFoundException, IllegalAccessException { + TaskDto task = getTask(taskId); + if (task == null) { + throw new ObjectNotFoundException(MSG_TASK_NOT_FOUND.formatted(taskId)); + } else if (!userAcl.hasAccessPermission(TaskAclPlugin.OBJECT_TYPE, String.valueOf(taskId), getCurrentUserName())) { + throw new IllegalAccessException(MSG_TASK_NOT_ACCESSIBLE.formatted(taskId, getCurrentUserName())); + } + return task; + } + public ProjectActivityModel listProjectActivitySince(Long projectId, Integer days) throws IllegalAccessException, ObjectNotFoundException { long count = countTaskModels(projectId, false); @@ -914,6 +1189,22 @@ private List getTaskLabels(TaskDto task, } } + private LabelDto getEditableLabel(long labelId) throws ObjectNotFoundException, IllegalAccessException { + LabelDto label = labelService.getLabel(labelId); + if (label == null) { + throw new ObjectNotFoundException(MSG_LABEL_NOT_FOUND.formatted(labelId, "")); + } + ProjectDto project = label.getProject(); + if (project != null) { + if (!project.canEdit(getCurrentUserAclIdentity())) { + throw new IllegalAccessException(MSG_TASK_PROJECT_NOT_EDITABLE.formatted(project.getId(), getCurrentUserName())); + } + } else if (!StringUtils.equals(label.getUsername(), getCurrentUserName())) { + throw new IllegalAccessException("Label '%s' is not editable by user '%s'.".formatted(labelId, getCurrentUserName())); + } + return label; + } + private List getProjectLabels(long projectId) { List labels = labelService.findLabelsByProject(projectId, getCurrentUserAclIdentity(), diff --git a/services/src/main/java/io/meeds/task/mcp/model/ProjectStatisticsModel.java b/services/src/main/java/io/meeds/task/mcp/model/ProjectStatisticsModel.java new file mode 100644 index 000000000..942dc018e --- /dev/null +++ b/services/src/main/java/io/meeds/task/mcp/model/ProjectStatisticsModel.java @@ -0,0 +1,43 @@ +/** + * This file is part of the Meeds project (https://meeds.io/). + * + * Copyright (C) 2020 - 2025 Meeds Association contact@meeds.io + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package io.meeds.task.mcp.model; + +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@JsonInclude(value = Include.NON_EMPTY) +public class ProjectStatisticsModel { + + @JsonProperty("total_uncompleted_tasks") + private long totalUncompletedTasks; + + @JsonProperty("uncompleted_by_status") + private Map uncompletedByStatus; + +} diff --git a/services/src/main/resources/ai-tool-definitions.json b/services/src/main/resources/ai-tool-definitions.json index 5391a1a67..6b418d917 100644 --- a/services/src/main/resources/ai-tool-definitions.json +++ b/services/src/main/resources/ai-tool-definitions.json @@ -545,6 +545,426 @@ "openWorldHint": false }, "require_approval": true + }, + { + "name": "set_task_dates", + "title": "Set a Task's dates", + "description": "Set or reschedule the start, due and/or end dates of an existing task. Pass a date as a string; omit a date to clear it.", + "input_schema": { + "type": "object", + "properties": { + "task_id": { "type": "integer" }, + "start_date": { + "type": "string", + "description": "Optional task start date as a string, parsed into a date." + }, + "end_date": { + "type": "string", + "description": "Optional task end date as a string, parsed into a date." + }, + "due_date": { + "type": "string", + "description": "Optional task due date as a string, parsed into a date." + } + }, + "required": ["task_id"] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false + }, + "require_approval": true + }, + { + "name": "complete_task", + "title": "Complete a Task", + "description": "Mark a task as completed (ticks the task's completion checkmark).", + "input_schema": { + "type": "object", + "properties": { + "task_id": { "type": "integer" } + }, + "required": ["task_id"] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "require_approval": true + }, + { + "name": "reopen_task", + "title": "Reopen a Task", + "description": "Mark a completed task as not completed (re-opens it).", + "input_schema": { + "type": "object", + "properties": { + "task_id": { "type": "integer" } + }, + "required": ["task_id"] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "require_approval": true + }, + { + "name": "set_task_priority", + "title": "Set a Task's priority", + "description": "Set the priority of a task.", + "input_schema": { + "type": "object", + "properties": { + "task_id": { "type": "integer" }, + "priority": { + "type": "string", + "enum": ["NONE", "LOW", "NORMAL", "HIGH"], + "description": "Task priority." + } + }, + "required": ["task_id", "priority"] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false + }, + "require_approval": true + }, + { + "name": "list_unscheduled_tasks", + "title": "List unscheduled Tasks", + "description": "List the uncompleted tasks of a project that have neither a start nor a due date (the tasks that still need to be scheduled in the Plan view).", + "input_schema": { + "type": "object", + "properties": { + "project_id": { "type": "integer" }, + "limit": { "type": "integer" } + }, + "required": ["project_id"] + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "update_project", + "title": "Update a Task Project", + "description": "Update a task project's name, description and/or colour.", + "input_schema": { + "type": "object", + "properties": { + "project_id": { "type": "integer" }, + "name": { "type": "string" }, + "description": { "type": "string" }, + "color": { "type": "string", "description": "Optional project colour (e.g. red, munsell_blue, green)." } + }, + "required": ["project_id"] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false + }, + "require_approval": true + }, + { + "name": "add_project_member", + "title": "Add a Project member", + "description": "Add a user as a participant of a task project.", + "input_schema": { + "type": "object", + "properties": { + "project_id": { "type": "integer" }, + "username": { "type": "string", "description": "Username of the participant to add." } + }, + "required": ["project_id", "username"] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "require_approval": true + }, + { + "name": "remove_project_member", + "title": "Remove a Project member", + "description": "Remove a user from the participants of a task project.", + "input_schema": { + "type": "object", + "properties": { + "project_id": { "type": "integer" }, + "username": { "type": "string", "description": "Username of the participant to remove." } + }, + "required": ["project_id", "username"] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false + }, + "require_approval": true + }, + { + "name": "set_project_manager", + "title": "Set a Project manager", + "description": "Add a user as a manager of a task project (can edit the project and its workflow).", + "input_schema": { + "type": "object", + "properties": { + "project_id": { "type": "integer" }, + "username": { "type": "string", "description": "Username of the manager to add." } + }, + "required": ["project_id", "username"] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "require_approval": true + }, + { + "name": "remove_project_manager", + "title": "Remove a Project manager", + "description": "Remove a user from the managers of a task project.", + "input_schema": { + "type": "object", + "properties": { + "project_id": { "type": "integer" }, + "username": { "type": "string", "description": "Username of the manager to remove." } + }, + "required": ["project_id", "username"] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false + }, + "require_approval": true + }, + { + "name": "create_project_status", + "title": "Create a Project status", + "description": "Create a new status (Dashboard UI column) in a task project.", + "input_schema": { + "type": "object", + "properties": { + "project_id": { "type": "integer" }, + "name": { "type": "string", "description": "Name of the new status / column." } + }, + "required": ["project_id", "name"] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "require_approval": true + }, + { + "name": "rename_project_status", + "title": "Rename a Project status", + "description": "Rename an existing status (Dashboard UI column) of a task project.", + "input_schema": { + "type": "object", + "properties": { + "status_id": { "type": "integer" }, + "name": { "type": "string", "description": "New name for the status / column." } + }, + "required": ["status_id", "name"] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false + }, + "require_approval": true + }, + { + "name": "get_project_statistics", + "title": "Get Project statistics", + "description": "Get the count of uncompleted tasks of a project, broken down by status (Dashboard UI column), plus the total.", + "input_schema": { + "type": "object", + "properties": { + "project_id": { "type": "integer" } + }, + "required": ["project_id"] + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "favorite_project", + "title": "Favorite a Project", + "description": "Add a task project to the current user's favorites.", + "input_schema": { + "type": "object", + "properties": { + "project_id": { "type": "integer" } + }, + "required": ["project_id"] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "unfavorite_project", + "title": "Unfavorite a Project", + "description": "Remove a task project from the current user's favorites.", + "input_schema": { + "type": "object", + "properties": { + "project_id": { "type": "integer" } + }, + "required": ["project_id"] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "favorite_task", + "title": "Favorite a Task", + "description": "Add a task to the current user's favorites.", + "input_schema": { + "type": "object", + "properties": { + "task_id": { "type": "integer" } + }, + "required": ["task_id"] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "unfavorite_task", + "title": "Unfavorite a Task", + "description": "Remove a task from the current user's favorites.", + "input_schema": { + "type": "object", + "properties": { + "task_id": { "type": "integer" } + }, + "required": ["task_id"] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "delete_task_comment", + "title": "Delete a Task comment", + "description": "Delete a comment from a task. Only the comment author or a user who can edit the task may delete it.", + "input_schema": { + "type": "object", + "properties": { + "comment_id": { "type": "integer" } + }, + "required": ["comment_id"] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false + }, + "require_approval": true + }, + { + "name": "list_task_labels", + "title": "List a Task's labels", + "description": "List the labels currently assigned to a given task.", + "input_schema": { + "type": "object", + "properties": { + "task_id": { "type": "integer" } + }, + "required": ["task_id"] + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + } + }, + { + "name": "update_project_label", + "title": "Rename a Task Project label", + "description": "Rename an existing project label. The new name applies everywhere the label is used.", + "input_schema": { + "type": "object", + "properties": { + "label_id": { "type": "integer" }, + "name": { "type": "string" } + }, + "required": ["label_id", "name"] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "require_approval": true + }, + { + "name": "delete_project_label", + "title": "Delete a Task Project label", + "description": "Permanently delete a project label and remove it from every task it was assigned to.", + "input_schema": { + "type": "object", + "properties": { + "label_id": { "type": "integer" } + }, + "required": ["label_id"] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false + }, + "require_approval": true } ] } diff --git a/services/src/test/java/io/meeds/task/mcp/TaskMcpToolTest.java b/services/src/test/java/io/meeds/task/mcp/TaskMcpToolTest.java index b53f42f00..e300a751c 100644 --- a/services/src/test/java/io/meeds/task/mcp/TaskMcpToolTest.java +++ b/services/src/test/java/io/meeds/task/mcp/TaskMcpToolTest.java @@ -74,12 +74,14 @@ import io.meeds.mcp.server.util.McpToolUtils; import io.meeds.portal.permlink.service.PermanentLinkService; +import org.exoplatform.social.metadata.favorite.FavoriteService; import io.meeds.social.space.plugin.SpaceAclPlugin; import io.meeds.social.translation.service.TranslationService; import io.meeds.task.mcp.model.ProjectActivityModel; import io.meeds.task.mcp.model.ProjectCollectionModel; import io.meeds.task.mcp.model.ProjectLabel; import io.meeds.task.mcp.model.ProjectModel; +import io.meeds.task.mcp.model.ProjectStatisticsModel; import io.meeds.task.mcp.model.ProjectStatus; import io.meeds.task.mcp.model.TaskCollectionModel; import io.meeds.task.mcp.model.TaskCommentCollectionModel; @@ -146,6 +148,9 @@ public class TaskMcpToolTest { @Mock private PermanentLinkService permanentLinkService; + @Mock + private FavoriteService favoriteService; + @Mock private Identity currentIdentity; @@ -345,6 +350,75 @@ public void addProjectLabelToTaskWhenLabelDoesNotExistShouldThrowException() thr tool.addProjectLabelToTask(TASK_ID, 99L); } + @Test + public void listTaskLabelsShouldReturnAssignedLabels() throws Exception {// NOSONAR + TaskDto task = mock(TaskDto.class); + StatusDto status = mock(StatusDto.class); + ProjectDto project = mock(ProjectDto.class); + LabelDto label = mock(LabelDto.class); + + when(task.getStatus()).thenReturn(status); + when(status.getProject()).thenReturn(project); + when(project.getId()).thenReturn(PROJECT_ID); + when(taskService.getTask(TASK_ID)).thenReturn(task); + when(userAcl.hasAccessPermission(TaskAclPlugin.OBJECT_TYPE, String.valueOf(TASK_ID), USER)).thenReturn(true); + when(label.getId()).thenReturn(7L); + when(label.getName()).thenReturn("urgent"); + when(labelService.findLabelsByTask(task, PROJECT_ID, currentIdentity, 0, 100)).thenReturn(Collections.singletonList(label)); + + List result = tool.listTaskLabels(TASK_ID); + + assertEquals(1, result.size()); + assertEquals(7L, result.get(0).getId()); + assertEquals("urgent", result.get(0).getName()); + } + + @Test + public void updateProjectLabelShouldRenameAndReturn() throws Exception {// NOSONAR + LabelDto label = mock(LabelDto.class); + ProjectDto project = mock(ProjectDto.class); + + when(labelService.getLabel(99L)).thenReturn(label); + when(label.getProject()).thenReturn(project); + when(project.canEdit(currentIdentity)).thenReturn(true); + when(labelService.updateLabel(label)).thenReturn(label); + when(label.getId()).thenReturn(99L); + when(label.getName()).thenReturn("Renamed"); + + ProjectLabel result = tool.updateProjectLabel(99L, "Renamed"); + + verify(label).setName("Renamed"); + verify(labelService).updateLabel(label); + assertEquals(99L, result.getId()); + assertEquals("Renamed", result.getName()); + } + + @Test(expected = IllegalAccessException.class) + public void updateProjectLabelWhenNotEditableShouldThrowException() throws Exception {// NOSONAR + LabelDto label = mock(LabelDto.class); + ProjectDto project = mock(ProjectDto.class); + + when(labelService.getLabel(99L)).thenReturn(label); + when(label.getProject()).thenReturn(project); + when(project.canEdit(currentIdentity)).thenReturn(false); + + tool.updateProjectLabel(99L, "Renamed"); + } + + @Test + public void deleteProjectLabelShouldDelegateToService() throws Exception {// NOSONAR + LabelDto label = mock(LabelDto.class); + ProjectDto project = mock(ProjectDto.class); + + when(labelService.getLabel(99L)).thenReturn(label); + when(label.getProject()).thenReturn(project); + when(project.canEdit(currentIdentity)).thenReturn(true); + + tool.deleteProjectLabel(99L); + + verify(labelService).removeLabel(99L); + } + @Test public void updateTaskStatusShouldSetStatusAndPersistTask() throws Exception {// NOSONAR TaskDto task = mockTask(); @@ -367,6 +441,153 @@ public void updateTaskStatusShouldSetStatusAndPersistTask() throws Exception {// verify(taskService).updateTask(task); } + @Test(expected = IllegalAccessException.class) + public void setTaskDatesWhenCurrentUserCannotEditShouldThrowException() throws Exception {// NOSONAR + TaskDto task = mockTask(); + when(taskService.getTask(TASK_ID)).thenReturn(task); + when(userAcl.hasEditPermission(TaskAclPlugin.OBJECT_TYPE, String.valueOf(TASK_ID), USER)).thenReturn(false); + tool.setTaskDates(TASK_ID, "2024-01-01", null, null); + } + + @Test + public void setTaskDatesShouldUpdateAndPersistTask() throws Exception {// NOSONAR + TaskDto task = mockTask(); + when(taskService.getTask(TASK_ID)).thenReturn(task); + when(userAcl.hasEditPermission(TaskAclPlugin.OBJECT_TYPE, String.valueOf(TASK_ID), USER)).thenReturn(true); + when(taskService.updateTask(task)).thenReturn(task); + runWithDateFormatMockResult(() -> tool.setTaskDates(TASK_ID, "2024-01-01", null, "2024-01-05")); + verify(task).setStartDate(any()); + verify(task).setDueDate(any()); + verify(taskService).updateTask(task); + } + + @Test + public void completeTaskShouldSetCompletedAndPersist() throws Exception {// NOSONAR + TaskDto task = mockTask(); + when(taskService.getTask(TASK_ID)).thenReturn(task); + when(userAcl.hasEditPermission(TaskAclPlugin.OBJECT_TYPE, String.valueOf(TASK_ID), USER)).thenReturn(true); + when(taskService.updateTask(task)).thenReturn(task); + runWithDateFormatMockResult(() -> tool.completeTask(TASK_ID)); + verify(task).setCompleted(true); + verify(taskService).updateTask(task); + } + + @Test + public void reopenTaskShouldSetNotCompletedAndPersist() throws Exception {// NOSONAR + TaskDto task = mockTask(); + when(taskService.getTask(TASK_ID)).thenReturn(task); + when(userAcl.hasEditPermission(TaskAclPlugin.OBJECT_TYPE, String.valueOf(TASK_ID), USER)).thenReturn(true); + when(taskService.updateTask(task)).thenReturn(task); + runWithDateFormatMockResult(() -> tool.reopenTask(TASK_ID)); + verify(task).setCompleted(false); + verify(taskService).updateTask(task); + } + + @Test + public void setTaskPriorityShouldSetPriorityAndPersist() throws Exception {// NOSONAR + TaskDto task = mockTask(); + when(taskService.getTask(TASK_ID)).thenReturn(task); + when(userAcl.hasEditPermission(TaskAclPlugin.OBJECT_TYPE, String.valueOf(TASK_ID), USER)).thenReturn(true); + when(taskService.updateTask(task)).thenReturn(task); + runWithDateFormatMockResult(() -> tool.setTaskPriority(TASK_ID, Priority.HIGH)); + verify(task).setPriority(Priority.HIGH); + verify(taskService).updateTask(task); + } + + @Test + public void updateProjectShouldUpdateFieldsAndPersist() throws Exception {// NOSONAR + ProjectDto project = mock(ProjectDto.class); + when(projectService.getProject(PROJECT_ID)).thenReturn(project); + when(project.canEdit(currentIdentity)).thenReturn(true); + when(projectService.updateProject(project)).thenReturn(project); + runWithDateFormatMockResult(() -> tool.updateProject(PROJECT_ID, "Renamed", "desc", "red")); + verify(project).setName("Renamed"); + verify(project).setDescription("desc"); + verify(project).setColor("red"); + verify(projectService).updateProject(project); + } + + @Test + public void addProjectMemberShouldPersistProject() throws Exception {// NOSONAR + ProjectDto project = mock(ProjectDto.class); + when(projectService.getProject(PROJECT_ID)).thenReturn(project); + when(project.canEdit(currentIdentity)).thenReturn(true); + when(project.getParticipator()).thenReturn(new HashSet<>()); + when(projectService.updateProject(project)).thenReturn(project); + runWithDateFormatMockResult(() -> tool.addProjectMember(PROJECT_ID, "@john")); + verify(project).setParticipator(any()); + verify(projectService).updateProject(project); + } + + @Test + public void createProjectStatusShouldDelegateToStatusService() throws Exception {// NOSONAR + ProjectDto project = mock(ProjectDto.class); + StatusDto status = mock(StatusDto.class); + when(projectService.getProject(PROJECT_ID)).thenReturn(project); + when(project.canEdit(currentIdentity)).thenReturn(true); + when(statusService.createStatus(project, "Review")).thenReturn(status); + when(status.getId()).thenReturn(9L); + when(status.getName()).thenReturn("Review"); + when(status.getRank()).thenReturn(4); + ProjectStatus result = tool.createProjectStatus(PROJECT_ID, "Review"); + assertEquals(9L, result.getId()); + assertEquals("Review", result.getName()); + } + + @Test + public void getProjectStatisticsShouldCountByStatus() throws Exception {// NOSONAR + ProjectDto project = mock(ProjectDto.class); + StatusDto todo = mock(StatusDto.class); + when(projectService.getProject(PROJECT_ID)).thenReturn(project); + when(project.canView(currentIdentity)).thenReturn(true); + when(statusService.getStatuses(PROJECT_ID)).thenReturn(Collections.singletonList(todo)); + when(todo.getName()).thenReturn("ToDo"); + when(taskService.countTaskStatusByProject(PROJECT_ID)).thenReturn(Collections.singletonList(new Object[] { "ToDo", 3L })); + ProjectStatisticsModel stats = tool.getProjectStatistics(PROJECT_ID); + assertEquals(3L, stats.getTotalUncompletedTasks()); + assertEquals(Long.valueOf(3L), stats.getUncompletedByStatus().get("ToDo")); + } + + @Test + public void favoriteTaskShouldCreateFavorite() throws Exception {// NOSONAR + TaskDto task = mockTask(); + org.exoplatform.social.core.identity.model.Identity identity = + mock(org.exoplatform.social.core.identity.model.Identity.class); + when(identity.getId()).thenReturn("1"); + when(identityManager.getOrCreateUserIdentity(USER)).thenReturn(identity); + when(taskService.getTask(TASK_ID)).thenReturn(task); + when(userAcl.hasAccessPermission(TaskAclPlugin.OBJECT_TYPE, String.valueOf(TASK_ID), USER)).thenReturn(true); + when(favoriteService.isFavorite(any())).thenReturn(false); + tool.favoriteTask(TASK_ID); + verify(favoriteService).createFavorite(any()); + } + + @Test + public void unfavoriteProjectShouldDeleteFavorite() throws Exception {// NOSONAR + ProjectDto project = mock(ProjectDto.class); + org.exoplatform.social.core.identity.model.Identity identity = + mock(org.exoplatform.social.core.identity.model.Identity.class); + when(identity.getId()).thenReturn("1"); + when(identityManager.getOrCreateUserIdentity(USER)).thenReturn(identity); + when(projectService.getProject(PROJECT_ID)).thenReturn(project); + when(project.canView(currentIdentity)).thenReturn(true); + when(favoriteService.isFavorite(any())).thenReturn(true); + tool.unfavoriteProject(PROJECT_ID); + verify(favoriteService).deleteFavorite(any()); + } + + @Test + public void deleteTaskCommentByAuthorShouldRemoveComment() throws Exception {// NOSONAR + CommentDto comment = mock(CommentDto.class); + TaskDto task = mock(TaskDto.class); + when(task.getId()).thenReturn(TASK_ID); + when(comment.getTask()).thenReturn(task); + when(comment.getAuthor()).thenReturn(USER); + when(commentService.getComment(55L)).thenReturn(comment); + tool.deleteTaskComment(55L); + verify(commentService).removeComment(55L); + } + @Test(expected = ObjectNotFoundException.class) public void getProjectIdByTaskIdWhenTaskDoesNotExistShouldThrowException() throws Exception {// NOSONAR when(taskService.getTask(TASK_ID)).thenThrow(new EntityNotFoundException(TASK_ID, Task.class)); @@ -792,7 +1013,8 @@ private class TestableTaskMcpTool extends TaskMcpTool { profilePropertyService, userAcl, portalConfigService, - permanentLinkService); + permanentLinkService, + favoriteService); } @Override