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

@@ -8,13 +8,18 @@ import { CommandResultGql, CommentGql } from './types';
export class CommentResolver {
constructor(private readonly comments: CommentsService) {}
@Mutation(() => CommandResultGql)
@Mutation(() => CommandResultGql, {
description:
'Comment on a ticket, or answer a comment (replyTo; one level of replies)',
})
addComment(
@CurrentUser() me: User,
@Args('issueId', { type: () => ID }) issueId: string,
@Args('body') body: string,
@Args('replyTo', { type: () => ID, nullable: true })
replyTo: string | null,
) {
return this.comments.add(me.id, issueId, body);
return this.comments.add(me.id, issueId, body, replyTo ?? null);
}
@Mutation(() => CommandResultGql)

View File

@@ -445,7 +445,17 @@ export class CommentGql {
@Field(() => ID) id!: string;
@Field(() => ID) issueId!: string;
@Field(() => UserRefGql) author!: UserRefGql;
@Field() body!: string;
@Field({ description: 'Empty on a deleted comment kept for its replies' })
body!: string;
@Field(() => ID, {
nullable: true,
description: 'The top-level comment it answers; null for a top-level one',
})
replyTo!: string | null;
@Field({
description: 'Deleted, but kept (without its text) while it has replies',
})
deleted!: boolean;
@Field() createdAt!: string;
@Field(() => String, { nullable: true }) editedAt!: string | null;
@Field(() => Int) version!: number;