Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
293 changes: 292 additions & 1 deletion services/src/main/java/io/meeds/task/mcp/TaskMcpTool.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -524,6 +533,31 @@ public void removeProjectLabelFromTask(Long taskId, Long labelId) throws Illegal
labelService.removeTaskFromLabel(task, labelId);
}

@SneakyThrows
public List<ProjectLabel> 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);
Expand All @@ -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<TaskModel> 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<String> 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<String> safeSet(Set<String> 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<String, Long> byStatus = new LinkedHashMap<>();
for (StatusDto status : statusService.getStatuses(projectId)) {
byStatus.put(status.getName(), 0L);
}
long total = 0;
List<Object[]> 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);
Expand Down Expand Up @@ -914,6 +1189,22 @@ private List<ProjectLabel> 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<ProjectLabel> getProjectLabels(long projectId) {
List<LabelDto> labels = labelService.findLabelsByProject(projectId,
getCurrentUserAclIdentity(),
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, Long> uncompletedByStatus;

}
Loading
Loading