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 500324efd..8a66269c2 100644 --- a/services/src/main/java/io/meeds/task/mcp/TaskMcpTool.java +++ b/services/src/main/java/io/meeds/task/mcp/TaskMcpTool.java @@ -47,6 +47,7 @@ import java.util.function.BiFunction; import java.util.function.Supplier; import java.util.regex.Pattern; +import java.util.stream.Stream; import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.StringUtils; @@ -74,6 +75,7 @@ import org.exoplatform.task.domain.Priority; import org.exoplatform.task.dto.ChangeLogEntry; import org.exoplatform.task.dto.CommentDto; +import org.exoplatform.task.dto.CommentsSummaryDto; import org.exoplatform.task.dto.LabelDto; import org.exoplatform.task.dto.ProjectDto; import org.exoplatform.task.dto.StatusDto; @@ -1744,7 +1746,9 @@ private long countTasks(TaskQuery taskQuery) { * @param includeDescription whether each item carries its description as * cut plain text * @return their list items, each overdue one with its days overdue, each - * with its changes since that date when one is given + * with its changes since that date when one is given, each + * commented one with its comments count and latest comment, all read + * in two queries, whatever the number of tasks */ private List toTaskListItems(List tasks, Date since, @@ -1755,8 +1759,17 @@ private List toTaskListItems(List tasks, } LocalDate today = getToday(zone); Identity aclIdentity = getCurrentUserAclIdentity(); + Map commentsSummaries = commentService.getCommentsSummaries(tasks.stream() + .map(TaskDto::getId) + .toList()); return tasks.stream().map(task -> { TaskListItemModel item = toTaskListItem(task, since, zone, today, aclIdentity); + CommentsSummaryDto commentsSummary = commentsSummaries.get(task.getId()); + if (commentsSummary != null) { + item.setCommentsCount((int) commentsSummary.getCount()); + item.setLastComment(formatDate(commentsSummary.getLastCommentDate()), + getTaskAssignee(commentsSummary.getLastCommentAuthor())); + } if (includeDescription) { // the stored HTML read as text, without the rendering that resolves links and mentions item.setPlainTextDescription(TaskDescriptionText.toPlainText(task.getDescription())); @@ -1767,9 +1780,10 @@ private List toTaskListItems(List tasks, /** * Maps a task to its list item: what tells it apart on its board, as a - * search result, with its assignee, due date, creation date, priority and - * comments count. It reads neither the description, nor the coworkers, nor the last update - * date that the full model holds. + * search result, with its assignee, due date, creation date and priority, + * without its comments, which {@link #toTaskListItems} reads for the whole + * page. It reads neither the description, nor the coworkers, nor the last + * update date that the full model holds. * * @param task a task * @param since the date the changes are listed from, null for none @@ -1802,7 +1816,7 @@ private TaskListItemModel toTaskListItem(TaskDto task, Date since, ZoneId zone, formatDate(task.getCreatedTime()), getDaysOverdue(task, zone, today), task.getPriority(), - commentService.countComments(task.getId()), + 0, changes); } @@ -1953,7 +1967,8 @@ private TaskWithChangeLogModel toTaskWithChangeLogModel(TaskDto task, Instant si * @param logs its change log entries, as the task service returns them * @param since only the entries and comments after this instant, null for * all - * @return its change log and comments, oldest first when it has comments + * @return its change log and comments, replies included, oldest first when + * it has comments */ private List getChangeLogs(TaskDto task, List logs, Instant since) { List taskLogs = logs.stream() @@ -1964,8 +1979,12 @@ private List getChangeLogs(TaskDto task, List log .toList(); int commentsCount = commentService.countComments(task.getId()); if (commentsCount > 0) { - List commentTaskLogs = commentService.getComments(task.getId(), 0, commentsCount) + // the count bounds the top-level comments, each read with its replies + List commentTaskLogs = commentService.getCommentsWithSubs(task.getId(), 0, commentsCount) .stream() + .flatMap(c -> Stream.concat(Stream.of(c), + CollectionUtils.emptyIfNull(c.getSubComments()) + .stream())) .filter(c -> since == null || Instant.ofEpochMilli(c.getCreatedTime().getTime()) .isAfter(since)) @@ -2105,7 +2124,7 @@ private TaskModel toTaskModel(TaskDto task, ZoneId zone, LocalDate today) { Locale locale = getCurrentUserLocale(); StatusDto status = task.getStatus(); ProjectDto project = status != null && status.getProject() != null ? status.getProject() : null; - int commentsCount = commentService.countComments(task.getId()); + int commentsCount = commentService.countCommentsWithSubs(task.getId()); String assignee = task.getAssignee(); Space space = project == null ? null : getProjectSpace(project); Long projectId = project == null ? null : project.getId(); diff --git a/services/src/main/java/io/meeds/task/mcp/model/TaskAssigneeModel.java b/services/src/main/java/io/meeds/task/mcp/model/TaskAssigneeModel.java index 3c9541cce..ca7bac8db 100644 --- a/services/src/main/java/io/meeds/task/mcp/model/TaskAssigneeModel.java +++ b/services/src/main/java/io/meeds/task/mcp/model/TaskAssigneeModel.java @@ -27,8 +27,8 @@ import lombok.NoArgsConstructor; /** - * The assignee of a task in a list: who it is, without the profile - * get_user_by_username returns. + * A user of a task in a list, its assignee or the author of its latest + * comment: who it is, without the profile get_user_by_username returns. */ @Data @NoArgsConstructor diff --git a/services/src/main/java/io/meeds/task/mcp/model/TaskListItemModel.java b/services/src/main/java/io/meeds/task/mcp/model/TaskListItemModel.java index b54ed159b..02c447e75 100644 --- a/services/src/main/java/io/meeds/task/mcp/model/TaskListItemModel.java +++ b/services/src/main/java/io/meeds/task/mcp/model/TaskListItemModel.java @@ -36,8 +36,9 @@ * A task in a list: what tells the card apart on its board, as a search * result does, with its assignee, due date, creation date, priority and * comments count, without its description, coworkers or other dates, which - * get_task_by_id returns. The creation date tells the tasks created since a - * date apart. On request, the description as cut plain text. + * get_task_by_id returns. The creation date tells apart the tasks created + * since a date; the date of the latest comment tells apart those nobody has + * commented on since. On request, the description as cut plain text. */ @Data @NoArgsConstructor @@ -84,9 +85,25 @@ public class TaskListItemModel { private Priority priority; + /** + * The number of its comments, replies included. + */ @JsonProperty("comments_count") private int commentsCount; + /** + * When its latest comment, a reply or not, was written, formatted as + * created_date; none for a task without comments. + */ + @JsonProperty("last_comment_date") + private String lastCommentDate; + + /** + * Who wrote its latest comment; none for a task without comments. + */ + @JsonProperty("last_comment_author") + private TaskAssigneeModel lastCommentAuthor; + /** * The changes since the date of a listing with {@code changed_since} or * {@code completed_since}, oldest first; none otherwise. @@ -109,8 +126,10 @@ public class TaskListItemModel { private Boolean descriptionTruncated; /** - * A list item without its description, which - * {@link #setPlainTextDescription(String)} adds on request. + * A list item without its latest comment, which + * {@link #setLastComment(String, TaskAssigneeModel)} adds, nor its + * description, which {@link #setPlainTextDescription(String)} adds on + * request. * * @param id the task * @param title its title @@ -125,7 +144,7 @@ public class TaskListItemModel { * @param createdDate its creation date * @param daysOverdue its days overdue, null when not overdue * @param priority its priority - * @param commentsCount its comments count + * @param commentsCount its comments count, replies included * @param changes its changes since a date, null for none */ public TaskListItemModel(long id, // NOSONAR the item's fields, each one its own @@ -157,6 +176,8 @@ public TaskListItemModel(long id, // NOSONAR the item's fields, each one its own daysOverdue, priority, commentsCount, + null, + null, changes, null, null); @@ -175,4 +196,15 @@ public void setPlainTextDescription(String plainText) { this.descriptionTruncated = cut != null && cut.length() < plainText.length() ? Boolean.TRUE : null; } + /** + * Sets when and by whom its latest comment was written. + * + * @param date the date of its latest comment, formatted as created_date + * @param author its author + */ + public void setLastComment(String date, TaskAssigneeModel author) { + this.lastCommentDate = date; + this.lastCommentAuthor = author; + } + } diff --git a/services/src/main/java/org/exoplatform/task/dao/CommentHandler.java b/services/src/main/java/org/exoplatform/task/dao/CommentHandler.java index a180a9f9e..b5dcafb02 100644 --- a/services/src/main/java/org/exoplatform/task/dao/CommentHandler.java +++ b/services/src/main/java/org/exoplatform/task/dao/CommentHandler.java @@ -18,12 +18,15 @@ */ package org.exoplatform.task.dao; +import java.util.Collection; import java.util.List; +import java.util.Map; import java.util.Set; import org.exoplatform.commons.api.persistence.GenericDAO; import org.exoplatform.commons.utils.ListAccess; import org.exoplatform.task.domain.Comment; +import org.exoplatform.task.dto.CommentsSummaryDto; public interface CommentHandler extends GenericDAO { ListAccess findComments(long taskId); @@ -40,4 +43,15 @@ public interface CommentHandler extends GenericDAO { */ Comment getLastComment(long taskId); + /** + * Summarizes the comments of several tasks in two queries, whatever their + * number. + * + * @param taskIds the tasks + * @return for each of them that has comments, the number of its comments, + * replies included, and its latest comment's date and author; the + * tasks without comments are absent + */ + Map getCommentsSummaries(Collection taskIds); + } diff --git a/services/src/main/java/org/exoplatform/task/dao/jpa/CommentDAOImpl.java b/services/src/main/java/org/exoplatform/task/dao/jpa/CommentDAOImpl.java index 1610a0f31..023dd7cab 100644 --- a/services/src/main/java/org/exoplatform/task/dao/jpa/CommentDAOImpl.java +++ b/services/src/main/java/org/exoplatform/task/dao/jpa/CommentDAOImpl.java @@ -24,17 +24,38 @@ import org.exoplatform.commons.utils.ListAccess; import org.exoplatform.task.dao.CommentHandler; import org.exoplatform.task.domain.Comment; +import org.exoplatform.task.dto.CommentsSummaryDto; import jakarta.persistence.NoResultException; import jakarta.persistence.TypedQuery; +import java.util.Collection; import java.util.Collections; +import java.util.Date; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Set; public class CommentDAOImpl extends CommonJPADAO implements CommentHandler { + /** + * For each of the given tasks that has comments, the number of its + * comments, replies included, and the creation time of its latest one: one + * grouped scan, with no subquery run per row. + */ + private static final String COMMENTS_COUNTS_QUERY = "SELECT c.task.id, count(c), max(c.createdTime) FROM TaskComment c" + + " WHERE c.task.id IN (:taskIds) GROUP BY c.task.id"; + + /** + * The comments of the given tasks created at one of the given times: the + * latest comment of each task, with the comments of another task created + * at the same time as one of them, which the caller leaves out. + */ + private static final String LATEST_COMMENTS_QUERY = "SELECT c.task.id, c.createdTime, c.author, c.id FROM TaskComment c" + + " WHERE c.task.id IN (:taskIds) AND c.createdTime IN (:createdTimes)"; + @Override public void delete(Comment entity) { List comments = getSubComments(Collections.singletonList(entity)); @@ -109,4 +130,43 @@ public int countCommentsWithSubs(long taskId) { } } + /** + * Runs {@link #COMMENTS_COUNTS_QUERY}, then {@link #LATEST_COMMENTS_QUERY} + * for the authors of the latest comments, keeping for each task the comment + * created at its latest time, the one created last when several were. + * + * @param taskIds the tasks + * @return the summaries of those that have comments, by task id + */ + @Override + public Map getCommentsSummaries(Collection taskIds) { + if (CollectionUtils.isEmpty(taskIds)) { + return Collections.emptyMap(); + } + TypedQuery countsQuery = getEntityManager().createQuery(COMMENTS_COUNTS_QUERY, Object[].class); + countsQuery.setParameter("taskIds", taskIds); + Map summaries = new LinkedHashMap<>(); + for (Object[] row : countsQuery.getResultList()) { + long taskId = ((Number) row[0]).longValue(); + summaries.put(taskId, new CommentsSummaryDto(taskId, ((Number) row[1]).longValue(), (Date) row[2], null, 0)); + } + if (summaries.isEmpty()) { + return summaries; + } + TypedQuery latestQuery = getEntityManager().createQuery(LATEST_COMMENTS_QUERY, Object[].class); + latestQuery.setParameter("taskIds", summaries.keySet()); + latestQuery.setParameter("createdTimes", + summaries.values().stream().map(CommentsSummaryDto::getLastCommentDate).distinct().toList()); + for (Object[] row : latestQuery.getResultList()) { + CommentsSummaryDto summary = summaries.get(((Number) row[0]).longValue()); + long commentId = ((Number) row[3]).longValue(); + if (((Date) row[1]).getTime() == summary.getLastCommentDate().getTime() + && (summary.getLastCommentAuthor() == null || commentId > summary.getLastCommentId())) { + summary.setLastCommentAuthor((String) row[2]); + summary.setLastCommentId(commentId); + } + } + return summaries; + } + } diff --git a/services/src/main/java/org/exoplatform/task/dto/CommentsSummaryDto.java b/services/src/main/java/org/exoplatform/task/dto/CommentsSummaryDto.java new file mode 100644 index 000000000..36965813c --- /dev/null +++ b/services/src/main/java/org/exoplatform/task/dto/CommentsSummaryDto.java @@ -0,0 +1,54 @@ +/** + * This file is part of the Meeds project (https://meeds.io/). + * + * Copyright (C) 2026 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 org.exoplatform.task.dto; + +import java.util.Date; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * The comments of a task at a glance: how many it has, replies included, and + * when and by whom the latest one, a reply or not, was written. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class CommentsSummaryDto { + + /** The task. */ + private long taskId; + + /** The number of its comments, replies included. */ + private long count; + + /** The creation date of its latest comment, a reply or not. */ + private Date lastCommentDate; + + /** The username of the author of its latest comment. */ + private String lastCommentAuthor; + + /** + * The id of its latest comment, which tells apart two comments created at + * the same time. + */ + private long lastCommentId; + +} diff --git a/services/src/main/java/org/exoplatform/task/service/CommentService.java b/services/src/main/java/org/exoplatform/task/service/CommentService.java index 0efab0745..83d31c4a5 100644 --- a/services/src/main/java/org/exoplatform/task/service/CommentService.java +++ b/services/src/main/java/org/exoplatform/task/service/CommentService.java @@ -18,9 +18,12 @@ */ package org.exoplatform.task.service; +import java.util.Collection; import java.util.List; +import java.util.Map; import org.exoplatform.task.dto.CommentDto; +import org.exoplatform.task.dto.CommentsSummaryDto; import org.exoplatform.task.dto.TaskDto; import org.exoplatform.task.exception.EntityNotFoundException; @@ -61,4 +64,16 @@ public interface CommentService { * @return null if no comments else last created comment on task */ CommentDto getLastComment(long taskId); + + /** + * Summarizes the comments of several tasks in two queries, whatever their + * number. + * + * @param taskIds the tasks + * @return for each of them that has comments, the number of its comments, + * replies included, and its latest comment's date and author; the + * tasks without comments are absent + */ + Map getCommentsSummaries(Collection taskIds); + } diff --git a/services/src/main/java/org/exoplatform/task/service/impl/CommentServiceImpl.java b/services/src/main/java/org/exoplatform/task/service/impl/CommentServiceImpl.java index 72355d073..653d999fc 100644 --- a/services/src/main/java/org/exoplatform/task/service/impl/CommentServiceImpl.java +++ b/services/src/main/java/org/exoplatform/task/service/impl/CommentServiceImpl.java @@ -22,13 +22,16 @@ import static org.exoplatform.task.util.TaskUtil.TASK_COMMENT_DELETED; import static org.exoplatform.task.util.TaskUtil.broadcastEvent; +import java.util.Collection; import java.util.List; +import java.util.Map; import org.exoplatform.services.listener.ListenerService; import org.exoplatform.services.log.ExoLogger; import org.exoplatform.services.log.Log; import org.exoplatform.social.core.utils.MentionUtils; import org.exoplatform.task.dto.CommentDto; +import org.exoplatform.task.dto.CommentsSummaryDto; import org.exoplatform.task.dto.TaskDto; import org.exoplatform.task.exception.EntityNotFoundException; import org.exoplatform.task.service.CommentService; @@ -126,4 +129,16 @@ public void removeComment(long commentId) throws EntityNotFoundException { broadcastEvent(listenerService, TASK_COMMENT_DELETED, comment.getTask(), comment); } + /** + * Summarizes the comments of several tasks in two queries, whatever their + * number. + * + * @param taskIds the tasks + * @return the summaries of those that have comments, by task id + */ + @Override + public Map getCommentsSummaries(Collection taskIds) { + return commentStorage.getCommentsSummaries(taskIds); + } + } diff --git a/services/src/main/java/org/exoplatform/task/storage/CommentStorage.java b/services/src/main/java/org/exoplatform/task/storage/CommentStorage.java index 27640f397..96cdb19f1 100644 --- a/services/src/main/java/org/exoplatform/task/storage/CommentStorage.java +++ b/services/src/main/java/org/exoplatform/task/storage/CommentStorage.java @@ -18,9 +18,12 @@ */ package org.exoplatform.task.storage; +import java.util.Collection; import java.util.List; +import java.util.Map; import org.exoplatform.task.dto.CommentDto; +import org.exoplatform.task.dto.CommentsSummaryDto; import org.exoplatform.task.dto.TaskDto; import org.exoplatform.task.exception.EntityNotFoundException; @@ -62,4 +65,15 @@ public interface CommentStorage { */ CommentDto getLastComment(long taskId); + /** + * Summarizes the comments of several tasks in two queries, whatever their + * number. + * + * @param taskIds the tasks + * @return for each of them that has comments, the number of its comments, + * replies included, and its latest comment's date and author; the + * tasks without comments are absent + */ + Map getCommentsSummaries(Collection taskIds); + } diff --git a/services/src/main/java/org/exoplatform/task/storage/impl/CommentStorageImpl.java b/services/src/main/java/org/exoplatform/task/storage/impl/CommentStorageImpl.java index 6894ca27a..ee9f3ad7d 100644 --- a/services/src/main/java/org/exoplatform/task/storage/impl/CommentStorageImpl.java +++ b/services/src/main/java/org/exoplatform/task/storage/impl/CommentStorageImpl.java @@ -29,6 +29,7 @@ import org.exoplatform.task.dao.DAOHandler; import org.exoplatform.task.domain.Comment; import org.exoplatform.task.dto.CommentDto; +import org.exoplatform.task.dto.CommentsSummaryDto; import org.exoplatform.task.dto.TaskDto; import org.exoplatform.task.exception.EntityNotFoundException; import org.exoplatform.task.storage.CommentStorage; @@ -155,4 +156,16 @@ public List loadSubComments(List listComments) { return new ArrayList(); } + /** + * Summarizes the comments of several tasks in two queries, whatever their + * number. + * + * @param taskIds the tasks + * @return the summaries of those that have comments, by task id + */ + @Override + public Map getCommentsSummaries(Collection taskIds) { + return daoHandler.getCommentHandler().getCommentsSummaries(taskIds); + } + } diff --git a/services/src/main/resources/ai-tool-definitions.json b/services/src/main/resources/ai-tool-definitions.json index 662c46366..7516c12f9 100644 --- a/services/src/main/resources/ai-tool-definitions.json +++ b/services/src/main/resources/ai-tool-definitions.json @@ -143,7 +143,7 @@ { "name": "list_tasks", "title": "List Tasks", - "description": "List tasks, optionally filtered by project; without project_id, every task the user can access, personal tasks included. With overdue_only, only the open tasks past their due date, the most overdue first; an overdue task carries days_overdue. With title, only the tasks whose title contains it, ignoring case. With changed_since, only the tasks whose status, project, due date, assignee or completion changed since that date, completed tasks included unless hide_completed_tasks; an assignee change of a task created since that date is not counted. With completed_since, only the tasks completed since that date. A task listed with either carries changes: each change since that date, oldest first, with its field (status, project, due_date, assignee or completed), its old_value and new_value when the change log records them, its date and its author; to tell what moved, use them instead of comparing dates. With assignee, only the tasks assigned to that user; with status_id, only the tasks in that status. Compact items (status is the board column); get_task_by_id for details. count is the total. While has_more, call again with offset=next_offset. In a run limited to a space, project_id is required and must be a project of that space, as list_projects returns them; to find a task by its title, list the tasks of each of those projects with title. Elsewhere, to find a task by words of its title or description, call search_tasks instead of listing a whole project.", + "description": "List tasks, optionally filtered by project; without project_id, every task the user can access, personal tasks included. With overdue_only, only the open tasks past their due date, the most overdue first; an overdue task carries days_overdue. With title, only the tasks whose title contains it, ignoring case. With changed_since, only the tasks whose status, project, due date, assignee or completion changed since that date, completed tasks included unless hide_completed_tasks; an assignee change of a task created since that date is not counted. With completed_since, only the tasks completed since that date. A task listed with either carries changes: each change since that date, oldest first, with its field (status, project, due_date, assignee or completed), its old_value and new_value when the change log records them, its date and its author; to tell what moved, use them instead of comparing dates. With assignee, only the tasks assigned to that user; with status_id, only the tasks in that status. Compact items (status is the board column); get_task_by_id for details. A commented task carries last_comment_date and last_comment_author, its latest comment or reply; comments_count counts the replies. count is the total. While has_more, call again with offset=next_offset. In a run limited to a space, project_id is required and must be a project of that space, as list_projects returns them; to find a task by its title, list the tasks of each of those projects with title. Elsewhere, to find a task by words of its title or description, call search_tasks instead of listing a whole project.", "input_schema": { "type": "object", "properties": { @@ -191,7 +191,7 @@ { "name": "list_assigned_tasks", "title": "Get Self Assigned Tasks", - "description": "List uncompleted tasks assigned to the current user, or of which they are a coworker. With overdue_only, only those past their due date, the most overdue first; an overdue task carries days_overdue. With changed_since, only those whose status, project, due date, assignee or completion changed since that date, completed tasks included; an assignee change of a task created since that date is not counted. With completed_since, only those completed since that date; not together with overdue_only. A task listed with either carries changes: each change since that date, oldest first, with its field, its old_value and new_value when the change log records them, its date and its author. Otherwise, the most recently active first. Compact items. count is the total. While has_more, call again with offset=next_offset.", + "description": "List uncompleted tasks assigned to the current user, or of which they are a coworker. With overdue_only, only those past their due date, the most overdue first; an overdue task carries days_overdue. With changed_since, only those whose status, project, due date, assignee or completion changed since that date, completed tasks included; an assignee change of a task created since that date is not counted. With completed_since, only those completed since that date; not together with overdue_only. A task listed with either carries changes: each change since that date, oldest first, with its field, its old_value and new_value when the change log records them, its date and its author. Otherwise, the most recently active first. Compact items. A commented task carries last_comment_date and last_comment_author, its latest comment or reply; comments_count counts the replies. count is the total. While has_more, call again with offset=next_offset.", "input_schema": { "type": "object", "properties": { @@ -663,7 +663,7 @@ { "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), among its 100 most recently active uncompleted tasks. Compact items. No offset: while has_more, call again with a higher limit.", + "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), among its 100 most recently active uncompleted tasks. Compact items. A commented task carries last_comment_date and last_comment_author, its latest comment or reply; comments_count counts the replies. No offset: while has_more, call again with a higher limit.", "input_schema": { "type": "object", "properties": { 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 e5c3ce1d8..6e6b7aa2e 100644 --- a/services/src/test/java/io/meeds/task/mcp/TaskMcpToolTest.java +++ b/services/src/test/java/io/meeds/task/mcp/TaskMcpToolTest.java @@ -55,6 +55,7 @@ import java.util.List; import java.util.ListResourceBundle; import java.util.Locale; +import java.util.Map; import java.util.ResourceBundle; import java.util.Set; import java.util.TimeZone; @@ -89,6 +90,7 @@ import org.exoplatform.task.domain.Task; import org.exoplatform.task.dto.ChangeLogEntry; import org.exoplatform.task.dto.CommentDto; +import org.exoplatform.task.dto.CommentsSummaryDto; import org.exoplatform.task.dto.LabelDto; import org.exoplatform.task.dto.ProjectDto; import org.exoplatform.task.dto.StatusDto; @@ -2112,11 +2114,14 @@ public void listProjectsOfASpaceInALimitedRunSaysWhetherMoreRemain() throws Exce /** * list_tasks returns compact items: the task's identifier, title, link, * completion, project, status, labels, assignee as a username and a display - * name, due date, creation date (the full task's), days overdue, priority - * and comments count; no description, no coworkers, and no user profile - * read for the coworkers. With include_change_log, the full task. Mutants: - * the full model listed; the coworkers looked up; the assignee's display - * name not read; the creation date dropped. + * name, due date, creation date (the full task's), days overdue, priority, + * comments count with the replies, and the date and author of the latest + * comment; no description, no coworkers, and no user profile read for the + * coworkers. With include_change_log, the full task, whose comments count + * holds the replies too. Mutants: the full model listed; the coworkers + * looked up; the assignee's display name not read; the creation date + * dropped; the latest comment dropped; the full task counting the top-level + * comments only. * * @throws Exception never */ @@ -2128,7 +2133,19 @@ public void listTasksReturnsCompactItemsAndTheFullTaskWithTheChangeLog() throws lenient().when(task.getDescription()).thenReturn("

A long description

"); when(task.getDueDate()).thenReturn(Date.from(Instant.parse("2026-10-01T10:00:00Z"))); when(task.getPriority()).thenReturn(Priority.HIGH); - when(commentService.countComments(TASK_ID)).thenReturn(3); + when(commentService.countComments(TASK_ID)).thenReturn(1); + when(commentService.countCommentsWithSubs(TASK_ID)).thenReturn(3); + when(commentService.getCommentsSummaries(List.of(TASK_ID))).thenReturn(Map.of(TASK_ID, + new CommentsSummaryDto(TASK_ID, + 3, + new Date(), + "paul", + 55L))); + org.exoplatform.social.core.identity.model.Identity paul = mock(org.exoplatform.social.core.identity.model.Identity.class); + Profile paulProfile = mock(Profile.class); + when(paul.getProfile()).thenReturn(paulProfile); + when(paulProfile.getFullName()).thenReturn("Paul Martin"); + when(identityManager.getOrCreateUserIdentity("paul")).thenReturn(paul); org.exoplatform.social.core.identity.model.Identity john = mock(org.exoplatform.social.core.identity.model.Identity.class); Profile johnProfile = mock(Profile.class); when(john.getProfile()).thenReturn(johnProfile); @@ -2151,6 +2168,9 @@ public void listTasksReturnsCompactItemsAndTheFullTaskWithTheChangeLog() throws assertEquals(Integer.valueOf(7), item.getDaysOverdue()); assertEquals(Priority.HIGH, item.getPriority()); assertEquals(3, item.getCommentsCount()); + assertEquals("2024-01-01T00:00:00Z", item.getLastCommentDate()); + assertEquals("paul", item.getLastCommentAuthor().getUsername()); + assertEquals("Paul Martin", item.getLastCommentAuthor().getDisplayName()); assertNull("no changes without changed_since", item.getChanges()); verify(identityManager, never()).getOrCreateUserIdentity("mary"); verify(taskService, never()).getTaskLogs(anyLong(), anyInt(), anyInt()); @@ -2167,9 +2187,12 @@ public void listTasksReturnsCompactItemsAndTheFullTaskWithTheChangeLog() throws "created_date", "days_overdue", "priority", - "comments_count"), + "comments_count", + "last_comment_date", + "last_comment_author"), fieldNames(json)); assertEquals(Set.of("username", "display_name"), fieldNames(json.path("assignee"))); + assertEquals(Set.of("username", "display_name"), fieldNames(json.path("last_comment_author"))); TaskCollectionModel full = runWithDateFormatMockResult(() -> tool.listTasks(null, false, true, null, null, null, null, null, null, null, null, null)); @@ -2177,10 +2200,90 @@ public void listTasksReturnsCompactItemsAndTheFullTaskWithTheChangeLog() throws assertTrue(fullTask instanceof TaskWithChangeLogModel); assertEquals("A long description", fullTask.getDescription().replaceAll("<[^>]+>", "").trim()); assertEquals(1, fullTask.getCoworkers().size()); + assertEquals("the full task counts the replies", 3, fullTask.getCommentsCount()); assertNotNull(item.getCreatedDate()); assertEquals("the same creation date as the full task", fullTask.getCreatedDate(), item.getCreatedDate()); } + /** + * The full task of a listing with include_change_log has one "commented" + * entry per comment, each reply included, as its comments count does. + * Mutant: the top-level comments read without their replies. + * + * @throws Exception never + */ + @Test + public void theChangeLogOfAFullTaskHoldsTheReplies() throws Exception { // NOSONAR + TaskDto task = mockTaskWithProject(); + CommentDto comment = new CommentDto(); + comment.setAuthor(OTHER_USER); + comment.setCreatedTime(Date.from(Instant.parse("2026-10-01T10:00:00Z"))); + CommentDto reply = new CommentDto(); + reply.setAuthor(OTHER_USER); + reply.setCreatedTime(Date.from(Instant.parse("2026-10-02T10:00:00Z"))); + comment.setSubComments(List.of(reply)); + when(commentService.countComments(TASK_ID)).thenReturn(1); + when(commentService.countCommentsWithSubs(TASK_ID)).thenReturn(2); + when(commentService.getCommentsWithSubs(TASK_ID, 0, 1)).thenReturn(List.of(comment)); + when(taskService.findLastUpdatedTasks(any(), eq(0), eq(10))).thenReturn(List.of(task)); + when(taskService.countTasks(any())).thenReturn(1); + + TaskCollectionModel full = runWithDateFormatMockResult(() -> tool.listTasks(null, false, true, null, null, null, null, null, null, null, null, null)); + + TaskWithChangeLogModel fullTask = (TaskWithChangeLogModel) full.getTasks().get(0); + assertEquals(2, fullTask.getCommentsCount()); + assertEquals(2, fullTask.getChangeLogs().stream().filter(l -> "commented".equals(l.getActionId())).count()); + } + + /** + * The comments of a page of 100 tasks are read in one call, whatever the + * number of tasks, never task by task; a commented task carries the date of + * its latest comment, not its own creation or due date, and a task without + * comments carries neither a latest comment date nor its author. Mutants: + * the comments counted task by task; the latest comment's date swapped for + * the task's creation or due date. + * + * @throws Exception never + */ + @Test + public void aPageOfTasksReadsItsCommentsInOneQuery() throws Exception { // NOSONAR + List tasks = new ArrayList<>(); + for (long id = 1; id <= 100; id++) { + tasks.add(mockTaskWithProject(id, new Date())); + } + when(taskService.findLastUpdatedTasks(any(), eq(0), eq(100))).thenReturn(tasks); + when(taskService.countTasks(any())).thenReturn(100); + List taskIds = tasks.stream().map(TaskDto::getId).toList(); + when(commentService.getCommentsSummaries(taskIds)).thenReturn(Map.of(1L, + new CommentsSummaryDto(1L, + 16, + Date.from(Instant.parse("2026-09-01T07:00:00Z")), + OTHER_USER, + 9L))); + when(tasks.get(0).getDueDate()).thenReturn(Date.from(Instant.parse("2026-12-01T00:00:00Z"))); + + TaskCollectionModel result; + try (MockedStatic mocked = mockStatic(McpToolUtils.class)) { + mocked.when(McpToolUtils::getUserTimeZone).thenReturn(TimeZone.getTimeZone("UTC")); + mocked.when(() -> McpToolUtils.formatDate(any(Date.class))) + .thenAnswer(invocation -> ((Date) invocation.getArgument(0)).toInstant().toString()); + result = tool.listTasks(null, false, false, null, 0, 100, null, null, null, null, null, null); + } + + assertEquals(100, result.getTasks().size()); + verify(commentService).getCommentsSummaries(taskIds); + verify(commentService, never()).countComments(anyLong()); + verify(commentService, never()).countCommentsWithSubs(anyLong()); + TaskListItemModel commented = (TaskListItemModel) result.getTasks().get(0); + assertEquals(16, commented.getCommentsCount()); + assertEquals("2026-09-01T07:00:00Z", commented.getLastCommentDate()); + assertEquals(OTHER_USER, commented.getLastCommentAuthor().getUsername()); + JsonNode uncommented = JSON.readTree(JSON.writeValueAsString(result.getTasks().get(1))); + assertFalse(uncommented.has("last_comment_date")); + assertFalse(uncommented.has("last_comment_author")); + assertEquals(0, uncommented.path("comments_count").asInt()); + } + /** * A compact item of list_tasks and of list_assigned_tasks carries the * task's creation date, the one the full task carries, not its due date, so diff --git a/services/src/test/java/org/exoplatform/task/dao/TestCommentDAO.java b/services/src/test/java/org/exoplatform/task/dao/TestCommentDAO.java index bce912ce4..eef07a5ae 100644 --- a/services/src/test/java/org/exoplatform/task/dao/TestCommentDAO.java +++ b/services/src/test/java/org/exoplatform/task/dao/TestCommentDAO.java @@ -21,6 +21,7 @@ import java.util.Arrays; import java.util.Date; import java.util.List; +import java.util.Map; import java.util.Set; import org.junit.After; @@ -35,6 +36,7 @@ import org.exoplatform.task.AbstractTest; import org.exoplatform.task.domain.Comment; import org.exoplatform.task.domain.Task; +import org.exoplatform.task.dto.CommentsSummaryDto; import org.exoplatform.task.util.ListUtil; public class TestCommentDAO extends AbstractTest { @@ -279,6 +281,51 @@ public void testMentionedUsers() { Assert.assertEquals(0, mentionedUsers.size()); } + /** + * The comments of several tasks are summarized in two queries, whatever the + * number of tasks, run by the real engine: the count holds the replies, the + * latest comment may be a reply, two comments created at the same time give + * the one created last, a comment saved later but created at the time of + * another task's latest one is not taken for its own task's latest, and a + * task without comments, or not asked for, is absent. The query plans are + * not checked here: HSQLDB runs the statements, no real database measured + * their cost. + */ + @Test + public void testGetCommentsSummaries() { + Task repliedTask = taskDAO.create(newDefaultSimpleTask()); + Task tiedTask = taskDAO.create(newDefaultSimpleTask()); + Task uncommentedTask = taskDAO.create(newDefaultSimpleTask()); + Task otherTask = taskDAO.create(newDefaultSimpleTask()); + + Comment comment = commentDAO.create(newComment(repliedTask, "root", 1000L)); + Comment reply = newComment(repliedTask, "john", 3000L); + reply.setParentComment(comment); + commentDAO.create(reply); + commentDAO.create(newComment(repliedTask, "mary", 2000L)); + + commentDAO.create(newComment(tiedTask, "root", 5000L)); + Comment lastTied = commentDAO.create(newComment(tiedTask, "mary", 5000L)); + commentDAO.create(newComment(tiedTask, "paul", 3000L)); + commentDAO.create(newComment(otherTask, "root", 9000L)); + RequestLifeCycle.restartTransaction(); + + Map summaries = commentDAO.getCommentsSummaries(List.of(repliedTask.getId(), + tiedTask.getId(), + uncommentedTask.getId())); + + Assert.assertEquals(Set.of(repliedTask.getId(), tiedTask.getId()), summaries.keySet()); + CommentsSummaryDto replied = summaries.get(repliedTask.getId()); + Assert.assertEquals(3, replied.getCount()); + Assert.assertEquals(3000L, replied.getLastCommentDate().getTime()); + Assert.assertEquals("john", replied.getLastCommentAuthor()); + CommentsSummaryDto tied = summaries.get(tiedTask.getId()); + Assert.assertEquals(3, tied.getCount()); + Assert.assertEquals(lastTied.getId().longValue(), tied.getLastCommentId()); + Assert.assertEquals("mary", tied.getLastCommentAuthor()); + Assert.assertTrue(commentDAO.getCommentsSummaries(List.of()).isEmpty()); + } + private Task newDefaultSimpleTask() { Task task = new Task(); task.setTitle("Default task"); @@ -296,4 +343,17 @@ private Comment newDefaultSimpleComment(Task task) { comment.setTask(task); return comment; } + + /** + * @param task the task commented + * @param author the comment's author + * @param time the comment's creation time, in milliseconds + * @return a comment, not saved + */ + private Comment newComment(Task task, String author, long time) { + Comment comment = newDefaultSimpleComment(task); + comment.setAuthor(author); + comment.setCreatedTime(new Date(time)); + return comment; + } }