feat: replies to comments

A comment can answer another one (replyTo), one level deep: answering a
reply joins the same thread, and it must be on the same ticket. Whoever
wrote the comment gets a new REPLIED notification, and the activity says
"respondió a un comentario".

A comment deleted while it has replies stays, without its text
(deleted: true), so the thread still reads; it goes with its last reply
and does not count in commentCount.

The ticket page shows threads with their replies indented and a
"Responder" box; REST, GraphQL and the MCP (add_comment, get_issue) take
and show replyTo. Existing comments are unchanged. Docs, Word and PDF
updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 11:14:37 +02:00
parent c2928d2385
commit ea4ef82239
44 changed files with 517 additions and 60 deletions

View File

@@ -12,6 +12,7 @@ import {
} from '@task-stream/api/tracker/domain';
import { FakeUserDirectory } from '../../testing/in-memory';
import { ChangeFeed } from '../ports/change-feed';
import { FindCommentsRepository } from '../ports/find-comments.repository';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { NotificationMailer } from '../ports/notification-mailer';
@@ -100,6 +101,13 @@ function setup(mailEnabled = false) {
}),
} as unknown as FindProjectsRepository,
users,
{
// A comment of the PM's, that others answer.
findById: async (id: string) =>
id === 'c-pm'
? { id, author: { id: 'pm', name: 'PM' }, deleted: false }
: null,
} as unknown as FindCommentsRepository,
);
return { repo, published, mailed, handler };
}
@@ -163,6 +171,7 @@ describe('notifications', () => {
projectId: 'p-1',
authorId: 'bob',
body: 'Ya está arreglado',
replyTo: null,
deleted: false,
createdAt: '2026-10-08T10:00:00.000Z',
editedAt: null,
@@ -185,6 +194,7 @@ describe('notifications', () => {
projectId: 'p-1',
authorId: 'bob',
body: '@[PM](pm) mira esto, y @[Fuera](stranger) también',
replyTo: null,
deleted: false,
createdAt: '2026-10-08T10:00:00.000Z',
editedAt: null,
@@ -200,6 +210,32 @@ describe('notifications', () => {
expect(repo.all[0].summary).toContain('«@PM mira esto');
});
it('tells whoever wrote a comment that someone answered it', async () => {
const { handler, repo } = setup();
await handler.handle(
at(
new CommentAddedEvent('bob', {
id: 'c-3',
issueId: 'i-1',
projectId: 'p-1',
authorId: 'bob',
body: 'Lo miro',
replyTo: 'c-pm',
deleted: false,
createdAt: '2026-10-08T10:00:00.000Z',
editedAt: null,
}),
1,
),
);
expect(repo.all.map((n) => [n.userId, n.kind])).toEqual([
['pm', NotificationKind.REPLIED],
['qa', NotificationKind.COMMENTED],
]);
expect(repo.all[0].summary).toContain('respondió a tu comentario');
});
it('copies by e-mail only to whoever asked for it, when mail is configured', async () => {
const { handler, repo, mailed } = setup(true);
repo.emailOn.add('bob');