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

@@ -4406,7 +4406,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CommentBodyDto"
"$ref": "#/components/schemas/AddCommentDto"
}
}
}
@@ -4491,7 +4491,7 @@
"pireon": []
}
],
"summary": "Comment on the ticket",
"summary": "Comment on the ticket, or answer a comment (replyTo)",
"tags": [
"Issues"
]
@@ -11423,7 +11423,18 @@
"$ref": "#/components/schemas/UserRefDto"
},
"body": {
"type": "string"
"type": "string",
"description": "Empty on a deleted comment kept for its replies"
},
"replyTo": {
"type": "string",
"nullable": true,
"format": "uuid",
"description": "The top-level comment it answers; null for a top-level one"
},
"deleted": {
"type": "boolean",
"description": "Deleted, but kept (without its text) while it has replies"
},
"createdAt": {
"type": "string",
@@ -11443,6 +11454,8 @@
"issueId",
"author",
"body",
"replyTo",
"deleted",
"createdAt",
"editedAt",
"version"
@@ -11466,13 +11479,18 @@
"total"
]
},
"CommentBodyDto": {
"AddCommentDto": {
"type": "object",
"properties": {
"body": {
"type": "string",
"example": "Reproducido en Safari 17.",
"maxLength": 20000
},
"replyTo": {
"type": "string",
"format": "uuid",
"description": "Comment it answers (one level: answering a reply joins its thread)"
}
},
"required": [
@@ -11497,6 +11515,19 @@
"file"
]
},
"CommentBodyDto": {
"type": "object",
"properties": {
"body": {
"type": "string",
"example": "Reproducido en Safari 17.",
"maxLength": 20000
}
},
"required": [
"body"
]
},
"ReleaseStatus": {
"type": "string",
"enum": [
@@ -11929,6 +11960,7 @@
"QA_FAILED",
"COMMENTED",
"MENTIONED",
"REPLIED",
"WATCHED",
"ADDED_TO_PROJECT",
"REMINDER"

View File

@@ -196,11 +196,19 @@ type CommandResult {
type Comment {
author: UserRef!
"""Empty on a deleted comment kept for its replies"""
body: String!
createdAt: String!
"""Deleted, but kept (without its text) while it has replies"""
deleted: Boolean!
editedAt: String
id: ID!
issueId: ID!
"""The top-level comment it answers; null for a top-level one"""
replyTo: ID
version: Int!
}
@@ -602,7 +610,11 @@ type Me {
type Mutation {
"""Add a step to a ticket's checklist"""
addChecklistItem(input: AddChecklistItemInput!): CommandResult!
addComment(body: String!, issueId: ID!): CommandResult!
"""
Comment on a ticket, or answer a comment (replyTo; one level of replies)
"""
addComment(body: String!, issueId: ID!, replyTo: ID): CommandResult!
"""
Add a repository with its own secret (the first turns the integration on)
@@ -776,6 +788,7 @@ enum NotificationKind {
MENTIONED
QA_FAILED
REMINDER
REPLIED
STATUS_CHANGED
WATCHED
}

View File

@@ -635,7 +635,7 @@ export interface paths {
/** Comments of the ticket (oldest first) */
get: operations["IssuesController_listComments"];
put?: never;
/** Comment on the ticket */
/** Comment on the ticket, or answer a comment (replyTo) */
post: operations["IssuesController_addComment"];
delete?: never;
options?: never;
@@ -2087,7 +2087,15 @@ export interface components {
/** Format: uuid */
issueId: string;
author: components["schemas"]["UserRefDto"];
/** @description Empty on a deleted comment kept for its replies */
body: string;
/**
* Format: uuid
* @description The top-level comment it answers; null for a top-level one
*/
replyTo: string | null;
/** @description Deleted, but kept (without its text) while it has replies */
deleted: boolean;
/** Format: date-time */
createdAt: string;
/** Format: date-time */
@@ -2098,9 +2106,14 @@ export interface components {
items: components["schemas"]["CommentDto"][];
total: number;
};
CommentBodyDto: {
AddCommentDto: {
/** @example Reproducido en Safari 17. */
body: string;
/**
* Format: uuid
* @description Comment it answers (one level: answering a reply joins its thread)
*/
replyTo?: string;
};
AttachFileDto: {
/**
@@ -2114,6 +2127,10 @@ export interface components {
*/
commentId?: string;
};
CommentBodyDto: {
/** @example Reproducido en Safari 17. */
body: string;
};
/** @enum {string} */
ReleaseStatus: "PLANNED" | "RELEASED" | "ARCHIVED";
ReleaseDto: {
@@ -2250,7 +2267,7 @@ export interface components {
goodFirst: components["schemas"]["ShowcaseIssueDto"][];
};
/** @enum {string} */
NotificationKind: "ASSIGNED" | "STATUS_CHANGED" | "QA_FAILED" | "COMMENTED" | "MENTIONED" | "WATCHED" | "ADDED_TO_PROJECT" | "REMINDER";
NotificationKind: "ASSIGNED" | "STATUS_CHANGED" | "QA_FAILED" | "COMMENTED" | "MENTIONED" | "REPLIED" | "WATCHED" | "ADDED_TO_PROJECT" | "REMINDER";
NotificationDto: {
id: string;
kind: components["schemas"]["NotificationKind"];
@@ -5497,7 +5514,7 @@ export interface operations {
};
requestBody: {
content: {
"application/json": components["schemas"]["CommentBodyDto"];
"application/json": components["schemas"]["AddCommentDto"];
};
};
responses: {

View File

@@ -224,9 +224,10 @@ export class IssuesResolver {
)
).items.map((c) => ({
id: c.id,
author: c.author.name,
replyTo: c.replyTo,
author: c.deleted ? null : c.author.name,
at: c.createdAt,
body: c.body,
body: c.deleted ? '(deleted; kept for its replies)' : c.body,
}))
: undefined;
const history = params.withHistory
@@ -411,19 +412,27 @@ export class IssuesResolver {
@Tool({
name: 'add_comment',
description: 'Comment on a ticket (decisions, explanations, steps).',
description:
'Comment on a ticket (decisions, explanations, steps), or answer a comment with replyTo (one level: answering a reply joins its thread).',
paramsSchema: z.object({
issue: issueRef,
body: z.string().min(1).max(20000),
replyTo: z
.string()
.optional()
.describe('Id of the comment it answers (see get_issue)'),
}),
annotations: WRITE,
})
comment({ issue, body }: { issue: string; body: string }, ctx: McpContext) {
comment(
{ issue, body, replyTo }: { issue: string; body: string; replyTo?: string },
ctx: McpContext,
) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).POST('/api/issues/{issue}/comments', {
params: { path: { issue } },
body: { body },
body: { body, replyTo },
}),
),
);