From 351526e9c7674a6762a1b5c6e9c25d5609887c7b Mon Sep 17 00:00:00 2001 From: Benjamin Mestrallet Date: Thu, 22 Oct 2026 21:29:33 +0100 Subject: [PATCH 1/4] feat: the comments of a page of tasks are summarized in one query, replies included EXO-91997 Co-Authored-By: Claude Opus 5.5 --- .../exoplatform/task/dao/CommentHandler.java | 13 +++++ .../task/dao/jpa/CommentDAOImpl.java | 39 +++++++++++++ .../task/dto/CommentsSummaryDto.java | 54 ++++++++++++++++++ .../task/service/CommentService.java | 14 +++++ .../task/service/impl/CommentServiceImpl.java | 14 +++++ .../task/storage/CommentStorage.java | 13 +++++ .../task/storage/impl/CommentStorageImpl.java | 12 ++++ .../exoplatform/task/dao/TestCommentDAO.java | 55 +++++++++++++++++++ 8 files changed, 214 insertions(+) create mode 100644 services/src/main/java/org/exoplatform/task/dto/CommentsSummaryDto.java 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..030d075e1 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,14 @@ public interface CommentHandler extends GenericDAO { */ Comment getLastComment(long taskId); + /** + * Summarizes the comments of several tasks in one query. + * + * @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..90fffc677 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,34 @@ 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.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, its latest comment, a reply + * or not, with the number of its comments: two comments created at the same + * time each give a row. + */ + private static final String COMMENTS_SUMMARIES_QUERY = + "SELECT new org.exoplatform.task.dto.CommentsSummaryDto(c.task.id," + + " (SELECT count(c2) FROM TaskComment c2 WHERE c2.task.id = c.task.id)," + + " c.createdTime, c.author, c.id)" + + " FROM TaskComment c WHERE c.task.id IN (:taskIds)" + + " AND c.createdTime = (SELECT max(c3.createdTime) FROM TaskComment c3" + + " WHERE c3.task.id = c.task.id)"; + @Override public void delete(Comment entity) { List comments = getSubComments(Collections.singletonList(entity)); @@ -109,4 +126,26 @@ public int countCommentsWithSubs(long taskId) { } } + /** + * Runs {@link #COMMENTS_SUMMARIES_QUERY} and keeps, for a task whose latest + * comments share their creation time, the one created last. + * + * @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 query = getEntityManager().createQuery(COMMENTS_SUMMARIES_QUERY, CommentsSummaryDto.class); + query.setParameter("taskIds", taskIds); + Map summaries = new LinkedHashMap<>(); + query.getResultList() + .forEach(summary -> summaries.merge(summary.getTaskId(), + summary, + (first, second) -> first.getLastCommentId() >= second.getLastCommentId() ? first : second)); + 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..c146cdccb 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,15 @@ 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 one query. + * + * @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..7275c9ddd 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,15 @@ public void removeComment(long commentId) throws EntityNotFoundException { broadcastEvent(listenerService, TASK_COMMENT_DELETED, comment.getTask(), comment); } + /** + * Summarizes the comments of several tasks in one query. + * + * @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..dba291bff 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,14 @@ public interface CommentStorage { */ CommentDto getLastComment(long taskId); + /** + * Summarizes the comments of several tasks in one query. + * + * @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..2768de70c 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,15 @@ public List loadSubComments(List listComments) { return new ArrayList(); } + /** + * Summarizes the comments of several tasks in one query. + * + * @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/test/java/org/exoplatform/task/dao/TestCommentDAO.java b/services/src/test/java/org/exoplatform/task/dao/TestCommentDAO.java index bce912ce4..5e9acf210 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,46 @@ public void testMentionedUsers() { Assert.assertEquals(0, mentionedUsers.size()); } + /** + * The comments of several tasks are summarized in one query, 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, + * and a task without comments, or not asked for, is absent. + */ + @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(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(2, 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 +338,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; + } } From b32d6b88749e4640aad75be086c4fc44b287970d Mon Sep 17 00:00:00 2001 From: Benjamin Mestrallet Date: Thu, 22 Oct 2026 21:49:20 +0100 Subject: [PATCH 2/4] feat: the compact task items carry the date and author of their latest comment, and count the replies EXO-91997 Co-Authored-By: Claude Opus 5.5 --- .../java/io/meeds/task/mcp/TaskMcpTool.java | 25 ++++-- .../task/mcp/model/TaskAssigneeModel.java | 4 +- .../task/mcp/model/TaskListItemModel.java | 40 +++++++++- .../main/resources/ai-tool-definitions.json | 6 +- .../io/meeds/task/mcp/TaskMcpToolTest.java | 77 +++++++++++++++++-- 5 files changed, 130 insertions(+), 22 deletions(-) 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..c5ebbf049 100644 --- a/services/src/main/java/io/meeds/task/mcp/TaskMcpTool.java +++ b/services/src/main/java/io/meeds/task/mcp/TaskMcpTool.java @@ -74,6 +74,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 +1745,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 one query */ private List toTaskListItems(List tasks, Date since, @@ -1755,8 +1758,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 +1779,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 +1815,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); } @@ -2105,7 +2118,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..10700beaf 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 @@ -37,7 +37,8 @@ * 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. + * date apart, the date of the latest comment the tasks left without one. + * 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/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..dcc7c5ff7 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,50 @@ 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 comments of a page of 100 tasks are read in one query, whatever the + * number of tasks, never task by task, and a task without comments carries + * neither a latest comment date nor its author. Mutants: the comments + * counted task by task; the summaries read per task. + * + * @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, + new Date(), + OTHER_USER, + 9L))); + + TaskCollectionModel result = runWithDateFormatMockResult(() -> 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(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 From 8c27e91a88db24e3d69bf08c5d8a452ba69f7778 Mon Sep 17 00:00:00 2001 From: Benjamin Mestrallet Date: Thu, 22 Oct 2026 21:53:59 +0100 Subject: [PATCH 3/4] fix: the change log of a full task holds a commented entry per reply, as its comments count does EXO-91997 Co-Authored-By: Claude Opus 5.5 --- .../java/io/meeds/task/mcp/TaskMcpTool.java | 10 +++++-- .../io/meeds/task/mcp/TaskMcpToolTest.java | 30 +++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) 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 c5ebbf049..caed99527 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; @@ -1966,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() @@ -1977,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)) 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 dcc7c5ff7..dd3a2d424 100644 --- a/services/src/test/java/io/meeds/task/mcp/TaskMcpToolTest.java +++ b/services/src/test/java/io/meeds/task/mcp/TaskMcpToolTest.java @@ -2205,6 +2205,36 @@ public void listTasksReturnsCompactItemsAndTheFullTaskWithTheChangeLog() throws 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 query, whatever the * number of tasks, never task by task, and a task without comments carries From 9e23860e15e04515fb6732b6fff5741b4db92d25 Mon Sep 17 00:00:00 2001 From: Benjamin Mestrallet Date: Thu, 22 Oct 2026 22:02:54 +0100 Subject: [PATCH 4/4] fix: the comments summary runs a grouped query and an author lookup, with no subquery per comment row EXO-91997 Co-Authored-By: Claude Opus 5.5 --- .../java/io/meeds/task/mcp/TaskMcpTool.java | 2 +- .../task/mcp/model/TaskListItemModel.java | 6 +- .../exoplatform/task/dao/CommentHandler.java | 3 +- .../task/dao/jpa/CommentDAOImpl.java | 57 +++++++++++++------ .../task/service/CommentService.java | 3 +- .../task/service/impl/CommentServiceImpl.java | 3 +- .../task/storage/CommentStorage.java | 3 +- .../task/storage/impl/CommentStorageImpl.java | 3 +- .../io/meeds/task/mcp/TaskMcpToolTest.java | 22 +++++-- .../exoplatform/task/dao/TestCommentDAO.java | 15 +++-- 10 files changed, 79 insertions(+), 38 deletions(-) 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 caed99527..8a66269c2 100644 --- a/services/src/main/java/io/meeds/task/mcp/TaskMcpTool.java +++ b/services/src/main/java/io/meeds/task/mcp/TaskMcpTool.java @@ -1748,7 +1748,7 @@ private long countTasks(TaskQuery taskQuery) { * @return their list items, each overdue one with its days overdue, each * with its changes since that date when one is given, each * commented one with its comments count and latest comment, all read - * in one query + * in two queries, whatever the number of tasks */ private List toTaskListItems(List tasks, Date since, 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 10700beaf..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,9 +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, the date of the latest comment the tasks left without one. - * 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 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 030d075e1..b5dcafb02 100644 --- a/services/src/main/java/org/exoplatform/task/dao/CommentHandler.java +++ b/services/src/main/java/org/exoplatform/task/dao/CommentHandler.java @@ -44,7 +44,8 @@ public interface CommentHandler extends GenericDAO { Comment getLastComment(long taskId); /** - * Summarizes the comments of several tasks in one query. + * 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, 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 90fffc677..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 @@ -31,6 +31,7 @@ import java.util.Collection; import java.util.Collections; +import java.util.Date; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; @@ -40,17 +41,20 @@ public class CommentDAOImpl extends CommonJPADAO implements CommentHandler { /** - * For each of the given tasks that has comments, its latest comment, a reply - * or not, with the number of its comments: two comments created at the same - * time each give a row. + * 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_SUMMARIES_QUERY = - "SELECT new org.exoplatform.task.dto.CommentsSummaryDto(c.task.id," - + " (SELECT count(c2) FROM TaskComment c2 WHERE c2.task.id = c.task.id)," - + " c.createdTime, c.author, c.id)" - + " FROM TaskComment c WHERE c.task.id IN (:taskIds)" - + " AND c.createdTime = (SELECT max(c3.createdTime) FROM TaskComment c3" - + " WHERE c3.task.id = c.task.id)"; + 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) { @@ -127,8 +131,9 @@ public int countCommentsWithSubs(long taskId) { } /** - * Runs {@link #COMMENTS_SUMMARIES_QUERY} and keeps, for a task whose latest - * comments share their creation time, the one created last. + * 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 @@ -138,13 +143,29 @@ public Map getCommentsSummaries(Collection taskI if (CollectionUtils.isEmpty(taskIds)) { return Collections.emptyMap(); } - TypedQuery query = getEntityManager().createQuery(COMMENTS_SUMMARIES_QUERY, CommentsSummaryDto.class); - query.setParameter("taskIds", taskIds); + TypedQuery countsQuery = getEntityManager().createQuery(COMMENTS_COUNTS_QUERY, Object[].class); + countsQuery.setParameter("taskIds", taskIds); Map summaries = new LinkedHashMap<>(); - query.getResultList() - .forEach(summary -> summaries.merge(summary.getTaskId(), - summary, - (first, second) -> first.getLastCommentId() >= second.getLastCommentId() ? first : second)); + 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/service/CommentService.java b/services/src/main/java/org/exoplatform/task/service/CommentService.java index c146cdccb..83d31c4a5 100644 --- a/services/src/main/java/org/exoplatform/task/service/CommentService.java +++ b/services/src/main/java/org/exoplatform/task/service/CommentService.java @@ -66,7 +66,8 @@ public interface CommentService { CommentDto getLastComment(long taskId); /** - * Summarizes the comments of several tasks in one query. + * 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, 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 7275c9ddd..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 @@ -130,7 +130,8 @@ public void removeComment(long commentId) throws EntityNotFoundException { } /** - * Summarizes the comments of several tasks in one query. + * 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 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 dba291bff..96cdb19f1 100644 --- a/services/src/main/java/org/exoplatform/task/storage/CommentStorage.java +++ b/services/src/main/java/org/exoplatform/task/storage/CommentStorage.java @@ -66,7 +66,8 @@ public interface CommentStorage { CommentDto getLastComment(long taskId); /** - * Summarizes the comments of several tasks in one query. + * 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, 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 2768de70c..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 @@ -157,7 +157,8 @@ public List loadSubComments(List listComments) { } /** - * Summarizes the comments of several tasks in one query. + * 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 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 dd3a2d424..6e6b7aa2e 100644 --- a/services/src/test/java/io/meeds/task/mcp/TaskMcpToolTest.java +++ b/services/src/test/java/io/meeds/task/mcp/TaskMcpToolTest.java @@ -2236,10 +2236,12 @@ public void theChangeLogOfAFullTaskHoldsTheReplies() throws Exception { // NOSON } /** - * The comments of a page of 100 tasks are read in one query, whatever the - * number of tasks, never task by task, and a task without comments carries - * neither a latest comment date nor its author. Mutants: the comments - * counted task by task; the summaries read per task. + * 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 */ @@ -2255,11 +2257,18 @@ public void aPageOfTasksReadsItsCommentsInOneQuery() throws Exception { // NOSON when(commentService.getCommentsSummaries(taskIds)).thenReturn(Map.of(1L, new CommentsSummaryDto(1L, 16, - new Date(), + 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 = runWithDateFormatMockResult(() -> tool.listTasks(null, false, false, null, 0, 100, null, null, null, null, null, null)); + 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); @@ -2267,6 +2276,7 @@ public void aPageOfTasksReadsItsCommentsInOneQuery() throws Exception { // NOSON 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")); 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 5e9acf210..eef07a5ae 100644 --- a/services/src/test/java/org/exoplatform/task/dao/TestCommentDAO.java +++ b/services/src/test/java/org/exoplatform/task/dao/TestCommentDAO.java @@ -282,10 +282,14 @@ public void testMentionedUsers() { } /** - * The comments of several tasks are summarized in one query, 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, - * and a task without comments, or not asked for, is absent. + * 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() { @@ -302,6 +306,7 @@ public void testGetCommentsSummaries() { 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(); @@ -315,7 +320,7 @@ public void testGetCommentsSummaries() { Assert.assertEquals(3000L, replied.getLastCommentDate().getTime()); Assert.assertEquals("john", replied.getLastCommentAuthor()); CommentsSummaryDto tied = summaries.get(tiedTask.getId()); - Assert.assertEquals(2, tied.getCount()); + Assert.assertEquals(3, tied.getCount()); Assert.assertEquals(lastTied.getId().longValue(), tied.getLastCommentId()); Assert.assertEquals("mary", tied.getLastCommentAuthor()); Assert.assertTrue(commentDAO.getCommentsSummaries(List.of()).isEmpty());