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:
@@ -4406,7 +4406,7 @@
|
|||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/CommentBodyDto"
|
"$ref": "#/components/schemas/AddCommentDto"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4491,7 +4491,7 @@
|
|||||||
"pireon": []
|
"pireon": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"summary": "Comment on the ticket",
|
"summary": "Comment on the ticket, or answer a comment (replyTo)",
|
||||||
"tags": [
|
"tags": [
|
||||||
"Issues"
|
"Issues"
|
||||||
]
|
]
|
||||||
@@ -11423,7 +11423,18 @@
|
|||||||
"$ref": "#/components/schemas/UserRefDto"
|
"$ref": "#/components/schemas/UserRefDto"
|
||||||
},
|
},
|
||||||
"body": {
|
"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": {
|
"createdAt": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -11443,6 +11454,8 @@
|
|||||||
"issueId",
|
"issueId",
|
||||||
"author",
|
"author",
|
||||||
"body",
|
"body",
|
||||||
|
"replyTo",
|
||||||
|
"deleted",
|
||||||
"createdAt",
|
"createdAt",
|
||||||
"editedAt",
|
"editedAt",
|
||||||
"version"
|
"version"
|
||||||
@@ -11466,13 +11479,18 @@
|
|||||||
"total"
|
"total"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"CommentBodyDto": {
|
"AddCommentDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"body": {
|
"body": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"example": "Reproducido en Safari 17.",
|
"example": "Reproducido en Safari 17.",
|
||||||
"maxLength": 20000
|
"maxLength": 20000
|
||||||
|
},
|
||||||
|
"replyTo": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"description": "Comment it answers (one level: answering a reply joins its thread)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -11497,6 +11515,19 @@
|
|||||||
"file"
|
"file"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"CommentBodyDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"body": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Reproducido en Safari 17.",
|
||||||
|
"maxLength": 20000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"body"
|
||||||
|
]
|
||||||
|
},
|
||||||
"ReleaseStatus": {
|
"ReleaseStatus": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
@@ -11929,6 +11960,7 @@
|
|||||||
"QA_FAILED",
|
"QA_FAILED",
|
||||||
"COMMENTED",
|
"COMMENTED",
|
||||||
"MENTIONED",
|
"MENTIONED",
|
||||||
|
"REPLIED",
|
||||||
"WATCHED",
|
"WATCHED",
|
||||||
"ADDED_TO_PROJECT",
|
"ADDED_TO_PROJECT",
|
||||||
"REMINDER"
|
"REMINDER"
|
||||||
|
|||||||
@@ -196,11 +196,19 @@ type CommandResult {
|
|||||||
|
|
||||||
type Comment {
|
type Comment {
|
||||||
author: UserRef!
|
author: UserRef!
|
||||||
|
|
||||||
|
"""Empty on a deleted comment kept for its replies"""
|
||||||
body: String!
|
body: String!
|
||||||
createdAt: String!
|
createdAt: String!
|
||||||
|
|
||||||
|
"""Deleted, but kept (without its text) while it has replies"""
|
||||||
|
deleted: Boolean!
|
||||||
editedAt: String
|
editedAt: String
|
||||||
id: ID!
|
id: ID!
|
||||||
issueId: ID!
|
issueId: ID!
|
||||||
|
|
||||||
|
"""The top-level comment it answers; null for a top-level one"""
|
||||||
|
replyTo: ID
|
||||||
version: Int!
|
version: Int!
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -602,7 +610,11 @@ type Me {
|
|||||||
type Mutation {
|
type Mutation {
|
||||||
"""Add a step to a ticket's checklist"""
|
"""Add a step to a ticket's checklist"""
|
||||||
addChecklistItem(input: AddChecklistItemInput!): CommandResult!
|
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)
|
Add a repository with its own secret (the first turns the integration on)
|
||||||
@@ -776,6 +788,7 @@ enum NotificationKind {
|
|||||||
MENTIONED
|
MENTIONED
|
||||||
QA_FAILED
|
QA_FAILED
|
||||||
REMINDER
|
REMINDER
|
||||||
|
REPLIED
|
||||||
STATUS_CHANGED
|
STATUS_CHANGED
|
||||||
WATCHED
|
WATCHED
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -635,7 +635,7 @@ export interface paths {
|
|||||||
/** Comments of the ticket (oldest first) */
|
/** Comments of the ticket (oldest first) */
|
||||||
get: operations["IssuesController_listComments"];
|
get: operations["IssuesController_listComments"];
|
||||||
put?: never;
|
put?: never;
|
||||||
/** Comment on the ticket */
|
/** Comment on the ticket, or answer a comment (replyTo) */
|
||||||
post: operations["IssuesController_addComment"];
|
post: operations["IssuesController_addComment"];
|
||||||
delete?: never;
|
delete?: never;
|
||||||
options?: never;
|
options?: never;
|
||||||
@@ -2087,7 +2087,15 @@ export interface components {
|
|||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
issueId: string;
|
issueId: string;
|
||||||
author: components["schemas"]["UserRefDto"];
|
author: components["schemas"]["UserRefDto"];
|
||||||
|
/** @description Empty on a deleted comment kept for its replies */
|
||||||
body: string;
|
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 */
|
/** Format: date-time */
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
@@ -2098,9 +2106,14 @@ export interface components {
|
|||||||
items: components["schemas"]["CommentDto"][];
|
items: components["schemas"]["CommentDto"][];
|
||||||
total: number;
|
total: number;
|
||||||
};
|
};
|
||||||
CommentBodyDto: {
|
AddCommentDto: {
|
||||||
/** @example Reproducido en Safari 17. */
|
/** @example Reproducido en Safari 17. */
|
||||||
body: string;
|
body: string;
|
||||||
|
/**
|
||||||
|
* Format: uuid
|
||||||
|
* @description Comment it answers (one level: answering a reply joins its thread)
|
||||||
|
*/
|
||||||
|
replyTo?: string;
|
||||||
};
|
};
|
||||||
AttachFileDto: {
|
AttachFileDto: {
|
||||||
/**
|
/**
|
||||||
@@ -2114,6 +2127,10 @@ export interface components {
|
|||||||
*/
|
*/
|
||||||
commentId?: string;
|
commentId?: string;
|
||||||
};
|
};
|
||||||
|
CommentBodyDto: {
|
||||||
|
/** @example Reproducido en Safari 17. */
|
||||||
|
body: string;
|
||||||
|
};
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
ReleaseStatus: "PLANNED" | "RELEASED" | "ARCHIVED";
|
ReleaseStatus: "PLANNED" | "RELEASED" | "ARCHIVED";
|
||||||
ReleaseDto: {
|
ReleaseDto: {
|
||||||
@@ -2250,7 +2267,7 @@ export interface components {
|
|||||||
goodFirst: components["schemas"]["ShowcaseIssueDto"][];
|
goodFirst: components["schemas"]["ShowcaseIssueDto"][];
|
||||||
};
|
};
|
||||||
/** @enum {string} */
|
/** @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: {
|
NotificationDto: {
|
||||||
id: string;
|
id: string;
|
||||||
kind: components["schemas"]["NotificationKind"];
|
kind: components["schemas"]["NotificationKind"];
|
||||||
@@ -5497,7 +5514,7 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
requestBody: {
|
requestBody: {
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["CommentBodyDto"];
|
"application/json": components["schemas"]["AddCommentDto"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
responses: {
|
responses: {
|
||||||
|
|||||||
@@ -224,9 +224,10 @@ export class IssuesResolver {
|
|||||||
)
|
)
|
||||||
).items.map((c) => ({
|
).items.map((c) => ({
|
||||||
id: c.id,
|
id: c.id,
|
||||||
author: c.author.name,
|
replyTo: c.replyTo,
|
||||||
|
author: c.deleted ? null : c.author.name,
|
||||||
at: c.createdAt,
|
at: c.createdAt,
|
||||||
body: c.body,
|
body: c.deleted ? '(deleted; kept for its replies)' : c.body,
|
||||||
}))
|
}))
|
||||||
: undefined;
|
: undefined;
|
||||||
const history = params.withHistory
|
const history = params.withHistory
|
||||||
@@ -411,19 +412,27 @@ export class IssuesResolver {
|
|||||||
|
|
||||||
@Tool({
|
@Tool({
|
||||||
name: 'add_comment',
|
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({
|
paramsSchema: z.object({
|
||||||
issue: issueRef,
|
issue: issueRef,
|
||||||
body: z.string().min(1).max(20000),
|
body: z.string().min(1).max(20000),
|
||||||
|
replyTo: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Id of the comment it answers (see get_issue)'),
|
||||||
}),
|
}),
|
||||||
annotations: WRITE,
|
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(() =>
|
return toolResult(() =>
|
||||||
this.api.data(
|
this.api.data(
|
||||||
this.api.as(ctx).POST('/api/issues/{issue}/comments', {
|
this.api.as(ctx).POST('/api/issues/{issue}/comments', {
|
||||||
params: { path: { issue } },
|
params: { path: { issue } },
|
||||||
body: { body },
|
body: { body, replyTo },
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
| 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) |
|
| 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) |
|
||||||
| 📝 Notas de versión | Borrador en Markdown con los tickets terminados por tipo, editable y copiable, también tras publicar |
|
| 📝 Notas de versión | Borrador en Markdown con los tickets terminados por tipo, editable y copiable, también tras publicar |
|
||||||
| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona |
|
| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona |
|
||||||
| 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con `@menciones`; el autor edita, el admin modera |
|
| 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con respuestas y `@menciones`; el autor edita, el admin modera |
|
||||||
| 🔎 Vistas | El tablero se filtra por texto, persona (o sin asignar), tipo, prioridad, área y release; los filtros se guardan como vistas propias o compartidas con el proyecto |
|
| 🔎 Vistas | El tablero se filtra por texto, persona (o sin asignar), tipo, prioridad, área y release; los filtros se guardan como vistas propias o compartidas con el proyecto |
|
||||||
| 🚦 WIP y carriles | Límite de trabajo en curso por estado (la columna se marca al pasarse) y tablero agrupable por persona, épica, prioridad o tipo |
|
| 🚦 WIP y carriles | Límite de trabajo en curso por estado (la columna se marca al pasarse) y tablero agrupable por persona, épica, prioridad o tipo |
|
||||||
| 🎲 Estimación | Puntos por ticket (escala 0-1-2-3-5-8-13-21) en la ficha, las tarjetas, las columnas y el backlog; velocidad por sprint y aviso si un sprint planificado supera la media |
|
| 🎲 Estimación | Puntos por ticket (escala 0-1-2-3-5-8-13-21) en la ficha, las tarjetas, las columnas y el backlog; velocidad por sprint y aviso si un sprint planificado supera la media |
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
| 🌐 Web del colectivo | La página de cada proyecto en pireon.dev enseña su hoja de ruta, versiones, sprint y tickets para recién llegados (`good-first-issue`) |
|
| 🌐 Web del colectivo | La página de cada proyecto en pireon.dev enseña su hoja de ruta, versiones, sprint y tickets para recién llegados (`good-first-issue`) |
|
||||||
| 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños |
|
| 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños |
|
||||||
| 👀 Seguir | Cualquier miembro sigue los tickets que le interesan; quien comenta, los sigue solo |
|
| 👀 Seguir | Cualquier miembro sigue los tickets que le interesan; quien comenta, los sigue solo |
|
||||||
| 🔔 Avisos | Campana con lo que te concierne (asignaciones, cambios de estado, QA fallido, comentarios), en vivo y opcionalmente por correo |
|
| 🔔 Avisos | Campana con lo que te concierne (asignaciones, cambios de estado, QA fallido, comentarios, respuestas), en vivo y opcionalmente por correo |
|
||||||
| ⏰ Recordatorios | Aviso (y correo, si se quiere) el día antes, el mismo día y al vencer la fecha límite, a quien lo tiene y a quien lo sigue; las tarjetas marcan lo que vence |
|
| ⏰ Recordatorios | Aviso (y correo, si se quiere) el día antes, el mismo día y al vencer la fecha límite, a quien lo tiene y a quien lo sigue; las tarjetas marcan lo que vence |
|
||||||
| 📎 Adjuntos | Capturas, logs y vídeos en el ticket o bajo un comentario; arrastrar, elegir o pegar con Ctrl+V; miniaturas de las imágenes |
|
| 📎 Adjuntos | Capturas, logs y vídeos en el ticket o bajo un comentario; arrastrar, elegir o pegar con Ctrl+V; miniaturas de las imágenes |
|
||||||
| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA) |
|
| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA) |
|
||||||
|
|||||||
@@ -145,7 +145,11 @@ stateDiagram-v2
|
|||||||
Las notas de versión (`notes`, Markdown) se escriben también después de publicar (no una vez archivada);
|
Las notas de versión (`notes`, Markdown) se escriben también después de publicar (no una vez archivada);
|
||||||
`notesDraft` / `GET /api/releases/{id}/notes/draft` propone un borrador con los tickets hechos agrupados por tipo
|
`notesDraft` / `GET /api/releases/{id}/notes/draft` propone un borrador con los tickets hechos agrupados por tipo
|
||||||
(los de QA quedan fuera).
|
(los de QA quedan fuera).
|
||||||
- **Comentarios**: solo el autor edita; borra el autor o un admin del proyecto.
|
- **Comentarios**: solo el autor edita; borra el autor o un admin del proyecto. Se puede responder a un comentario
|
||||||
|
(`replyTo`), con un solo nivel de respuestas: responder a una respuesta la suma al mismo hilo, y la respuesta tiene
|
||||||
|
que ser del mismo ticket. Quien escribió el comentario recibe un aviso (`REPLIED`). Un comentario borrado que tiene
|
||||||
|
respuestas se queda, sin texto (`deleted: true`), para que el hilo se siga entendiendo, y desaparece con su última
|
||||||
|
respuesta; no cuenta en `commentCount`.
|
||||||
- **Cambios de UI**: `figmaUrl` debe ser un enlace `https://…figma.com/…`.
|
- **Cambios de UI**: `figmaUrl` debe ser un enlace `https://…figma.com/…`.
|
||||||
- **Adjuntos**: los suben los miembros (no los lectores), hasta `ATTACHMENTS_MAX_MB` (25 MB) por archivo y 50 por
|
- **Adjuntos**: los suben los miembros (no los lectores), hasta `ATTACHMENTS_MAX_MB` (25 MB) por archivo y 50 por
|
||||||
ticket; pueden ir en el ticket o bajo uno de sus comentarios (`commentId`). Quita uno quien lo subió o un admin.
|
ticket; pueden ir en el ticket o bajo uno de sus comentarios (`commentId`). Quita uno quien lo subió o un admin.
|
||||||
@@ -333,15 +337,15 @@ Todas devuelven `CommandResult { id, key, version }`.
|
|||||||
|
|
||||||
### Subscriptions
|
### Subscriptions
|
||||||
|
|
||||||
| Subscription | Se emite cuando |
|
| Subscription | Se emite cuando |
|
||||||
| --------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
| --------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `issueChanged(projectId)` | Se crea o cambia cualquier ticket del proyecto (incluye el ticket completo y `action` = nombre del evento) |
|
| `issueChanged(projectId)` | Se crea o cambia cualquier ticket del proyecto (incluye el ticket completo y `action` = nombre del evento) |
|
||||||
| `commentChanged(issueId)` | Se añade, edita o borra un comentario (`comment` es `null` al borrar) |
|
| `commentChanged(issueId)` | Se añade, edita o borra un comentario (`comment` es `null` al borrar, o el que queda sin texto si tenía respuestas) |
|
||||||
| `activityAdded(projectId)` | Hay una nueva entrada en el feed |
|
| `activityAdded(projectId)` | Hay una nueva entrada en el feed |
|
||||||
| `releaseChanged(projectId)` | Cambia una release |
|
| `releaseChanged(projectId)` | Cambia una release |
|
||||||
| `sprintChanged(projectId)` | Se planifica, cambia, empieza, cierra o borra un sprint (`sprint` es `null` al borrar) |
|
| `sprintChanged(projectId)` | Se planifica, cambia, empieza, cierra o borra un sprint (`sprint` es `null` al borrar) |
|
||||||
| `projectChanged(projectId)` | Cambian los datos, la configuración o los miembros del proyecto |
|
| `projectChanged(projectId)` | Cambian los datos, la configuración o los miembros del proyecto |
|
||||||
| `myProjectsChanged` | Me añaden a un proyecto o me quitan de uno |
|
| `myProjectsChanged` | Me añaden a un proyecto o me quitan de uno |
|
||||||
|
|
||||||
Solo los miembros pueden suscribirse a un proyecto; las conexiones sin token válido se cierran con el código 4403.
|
Solo los miembros pueden suscribirse a un proyecto; las conexiones sin token válido se cierran con el código 4403.
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ flowchart LR
|
|||||||
| | `change_issue_status` | Mover de estado (con motivo cuando el estado lo pide) |
|
| | `change_issue_status` | Mover de estado (con motivo cuando el estado lo pide) |
|
||||||
| | `assign_issue` | Asignar o desasignar |
|
| | `assign_issue` | Asignar o desasignar |
|
||||||
| | `set_issue_release` | Meter en una release o sacar |
|
| | `set_issue_release` | Meter en una release o sacar |
|
||||||
| | `add_comment` / `edit_comment` / `delete_comment` | Comentar, corregir o borrar un comentario |
|
| | `add_comment` / `edit_comment` / `delete_comment` | Comentar (o responder a un comentario), corregir o borrar |
|
||||||
| | `link_issues` / `unlink_issues` | Relacionar tickets (`DEPENDS_ON`, `BLOCKS`, `TESTS`…) o quitar la relación |
|
| | `link_issues` / `unlink_issues` | Relacionar tickets (`DEPENDS_ON`, `BLOCKS`, `TESTS`…) o quitar la relación |
|
||||||
| | `watch_issue` | Seguir un ticket o dejar de seguirlo |
|
| | `watch_issue` | Seguir un ticket o dejar de seguirlo |
|
||||||
| | `add_checklist_item` / `update_checklist_item` / `remove_checklist_item` | La lista de comprobación: añadir, marcar o reescribir, quitar |
|
| | `add_checklist_item` / `update_checklist_item` / `remove_checklist_item` | La lista de comprobación: añadir, marcar o reescribir, quitar |
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -1,4 +1,5 @@
|
|||||||
import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs';
|
import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs';
|
||||||
|
import { BusinessRuleError } from '@task-stream/api/shared/domain';
|
||||||
import { CommentFactory } from '@task-stream/api/tracker/domain';
|
import { CommentFactory } from '@task-stream/api/tracker/domain';
|
||||||
import { AddCommentCommand } from './add-comment.command';
|
import { AddCommentCommand } from './add-comment.command';
|
||||||
import { CommandResult, ack } from './command-result';
|
import { CommandResult, ack } from './command-result';
|
||||||
@@ -24,10 +25,24 @@ export class AddCommentCommandHandler
|
|||||||
projectId: issue.projectId,
|
projectId: issue.projectId,
|
||||||
authorId: command.actorId,
|
authorId: command.actorId,
|
||||||
body: command.body,
|
body: command.body,
|
||||||
|
replyTo: command.replyTo
|
||||||
|
? await this.threadOf(command.replyTo, issue.id)
|
||||||
|
: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.eventPublisher.mergeObjectContext(comment);
|
this.eventPublisher.mergeObjectContext(comment);
|
||||||
await comment.commitAndWait();
|
await comment.commitAndWait();
|
||||||
return ack(comment);
|
return ack(comment);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The top-level comment a reply hangs from: one level only, so answering
|
||||||
|
* a reply joins its thread.
|
||||||
|
*/
|
||||||
|
private async threadOf(commentId: string, issueId: string): Promise<string> {
|
||||||
|
const parent = await this.access.comment(commentId);
|
||||||
|
if (parent.issueId !== issueId)
|
||||||
|
throw new BusinessRuleError('A reply must be on the same ticket');
|
||||||
|
return parent.replyTo ?? parent.id;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ export class AddCommentCommand extends Command<CommandResult> {
|
|||||||
public readonly actorId: string,
|
public readonly actorId: string,
|
||||||
public readonly issueId: string,
|
public readonly issueId: string,
|
||||||
public readonly body: string,
|
public readonly body: string,
|
||||||
|
/** Comment it answers; a reply to a reply joins the same thread. */
|
||||||
|
public readonly replyTo: string | null = null,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -832,6 +832,38 @@ describe('tracker commands (event sourced)', () => {
|
|||||||
expect(rebuilt.deleted).toBe(true);
|
expect(rebuilt.deleted).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('answers comments one level deep, on the same ticket', async () => {
|
||||||
|
const { run, rehydrator } = await setup();
|
||||||
|
const projectId = await projectWithTeam(run);
|
||||||
|
const spike = await run(
|
||||||
|
new CreateIssueCommand('dev', projectId, IssueType.TASK, 'Spike', {}),
|
||||||
|
);
|
||||||
|
const other = await run(
|
||||||
|
new CreateIssueCommand('dev', projectId, IssueType.TASK, 'Other', {}),
|
||||||
|
);
|
||||||
|
const root = await run(new AddCommentCommand('dev', spike.id, 'Redis?'));
|
||||||
|
const reply = await run(
|
||||||
|
new AddCommentCommand('qa', spike.id, 'Sí', root.id),
|
||||||
|
);
|
||||||
|
// Answering a reply joins the same thread.
|
||||||
|
const again = await run(
|
||||||
|
new AddCommentCommand('dev', spike.id, 'Vale', reply.id),
|
||||||
|
);
|
||||||
|
const replyTo = async (id: string) =>
|
||||||
|
(await rehydrator.rehydrate(id, Comment)).replyTo;
|
||||||
|
expect(await replyTo(root.id)).toBeNull();
|
||||||
|
expect(await replyTo(reply.id)).toBe(root.id);
|
||||||
|
expect(await replyTo(again.id)).toBe(root.id);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
run(new AddCommentCommand('dev', other.id, 'Aquí no', root.id)),
|
||||||
|
).rejects.toThrow(BusinessRuleError);
|
||||||
|
await run(new DeleteCommentCommand('qa', reply.id));
|
||||||
|
await expect(
|
||||||
|
run(new AddCommentCommand('dev', spike.id, 'Tarde', reply.id)),
|
||||||
|
).rejects.toThrow(NotFoundError);
|
||||||
|
});
|
||||||
|
|
||||||
it('detects concurrent modifications through stream positions', async () => {
|
it('detects concurrent modifications through stream positions', async () => {
|
||||||
const { run, rehydrator } = await setup();
|
const { run, rehydrator } = await setup();
|
||||||
const projectId = await projectWithTeam(run);
|
const projectId = await projectWithTeam(run);
|
||||||
|
|||||||
@@ -15,9 +15,14 @@ export class CommentsService {
|
|||||||
private readonly queryBus: QueryBus,
|
private readonly queryBus: QueryBus,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
add(actorId: string, issueId: string, body: string): Promise<CommandResult> {
|
add(
|
||||||
|
actorId: string,
|
||||||
|
issueId: string,
|
||||||
|
body: string,
|
||||||
|
replyTo: string | null = null,
|
||||||
|
): Promise<CommandResult> {
|
||||||
return this.commandBus.execute(
|
return this.commandBus.execute(
|
||||||
new AddCommentCommand(actorId, issueId, body),
|
new AddCommentCommand(actorId, issueId, body, replyTo),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -200,7 +200,9 @@ export function describeEvent(
|
|||||||
return `${actor} quitó «${event.text}» de la lista de ${issueKey}`;
|
return `${actor} quitó «${event.text}» de la lista de ${issueKey}`;
|
||||||
|
|
||||||
if (event instanceof CommentAddedEvent)
|
if (event instanceof CommentAddedEvent)
|
||||||
return `${actor} comentó en ${issueKey}`;
|
return event.comment.replyTo
|
||||||
|
? `${actor} respondió a un comentario en ${issueKey}`
|
||||||
|
: `${actor} comentó en ${issueKey}`;
|
||||||
if (event instanceof CommentEditedEvent)
|
if (event instanceof CommentEditedEvent)
|
||||||
return `${actor} editó un comentario en ${issueKey}`;
|
return `${actor} editó un comentario en ${issueKey}`;
|
||||||
if (event instanceof CommentDeletedEvent)
|
if (event instanceof CommentDeletedEvent)
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ export class CommentAddedEventHandler
|
|||||||
projectId: comment.projectId,
|
projectId: comment.projectId,
|
||||||
author: users.of(comment.authorId),
|
author: users.of(comment.authorId),
|
||||||
body: comment.body,
|
body: comment.body,
|
||||||
|
replyTo: comment.replyTo ?? null,
|
||||||
|
deleted: false,
|
||||||
createdAt: comment.createdAt,
|
createdAt: comment.createdAt,
|
||||||
editedAt: null,
|
editedAt: null,
|
||||||
version: versionOf(event),
|
version: versionOf(event),
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { CommentDeletedEvent } from '@task-stream/api/tracker/domain';
|
|||||||
import { FindCommentsRepository } from '../ports/find-comments.repository';
|
import { FindCommentsRepository } from '../ports/find-comments.repository';
|
||||||
import { UpsertMaterializedCommentRepository } from '../ports/upsert-materialized-comment.repository';
|
import { UpsertMaterializedCommentRepository } from '../ports/upsert-materialized-comment.repository';
|
||||||
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
|
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
|
||||||
import { ReadModelNotifier } from './read-model-notifier';
|
import { ReadModelNotifier, versionOf } from './read-model-notifier';
|
||||||
|
|
||||||
@EventsHandler(CommentDeletedEvent)
|
@EventsHandler(CommentDeletedEvent)
|
||||||
export class CommentDeletedEventHandler
|
export class CommentDeletedEventHandler
|
||||||
@@ -17,12 +17,35 @@ export class CommentDeletedEventHandler
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async handle(event: CommentDeletedEvent) {
|
async handle(event: CommentDeletedEvent) {
|
||||||
await this.upsertMaterializedCommentRepository.remove(event.aggregateId);
|
const comment = await this.findComments.findById(event.aggregateId);
|
||||||
|
await this.vanishOrKeep(event.aggregateId, event);
|
||||||
|
// The last reply under a deleted comment takes it along.
|
||||||
|
const parent = comment?.replyTo
|
||||||
|
? await this.findComments.findById(comment.replyTo)
|
||||||
|
: null;
|
||||||
|
if (parent?.deleted) await this.vanishOrKeep(parent.id, event);
|
||||||
await this.upsertMaterializedIssueRepository.setCommentCount(
|
await this.upsertMaterializedIssueRepository.setCommentCount(
|
||||||
event.issueId,
|
event.issueId,
|
||||||
await this.findComments.countByIssue(event.issueId),
|
await this.findComments.countByIssue(event.issueId),
|
||||||
);
|
);
|
||||||
await this.notifier.comment(event.aggregateId, event.issueId, event);
|
|
||||||
await this.notifier.issue(event.issueId, event);
|
await this.notifier.issue(event.issueId, event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A comment with replies stays, without its text, so the conversation
|
||||||
|
* under it still reads; one without them goes.
|
||||||
|
*/
|
||||||
|
private async vanishOrKeep(id: string, event: CommentDeletedEvent) {
|
||||||
|
if (await this.findComments.countReplies(id)) {
|
||||||
|
await this.upsertMaterializedCommentRepository.upsert({
|
||||||
|
id,
|
||||||
|
version: versionOf(event),
|
||||||
|
body: '',
|
||||||
|
deleted: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await this.upsertMaterializedCommentRepository.remove(id);
|
||||||
|
}
|
||||||
|
await this.notifier.comment(id, event.issueId, event);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
TrackerEvent,
|
TrackerEvent,
|
||||||
withoutMentionMarkup,
|
withoutMentionMarkup,
|
||||||
} from '@task-stream/api/tracker/domain';
|
} from '@task-stream/api/tracker/domain';
|
||||||
|
import { FindCommentsRepository } from '../ports/find-comments.repository';
|
||||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||||
import { UserDirectory } from '../ports/user-directory';
|
import { UserDirectory } from '../ports/user-directory';
|
||||||
@@ -43,6 +44,7 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
|
|||||||
private readonly findIssues: FindIssuesRepository,
|
private readonly findIssues: FindIssuesRepository,
|
||||||
private readonly findProjects: FindProjectsRepository,
|
private readonly findProjects: FindProjectsRepository,
|
||||||
private readonly users: UserDirectory,
|
private readonly users: UserDirectory,
|
||||||
|
private readonly findComments: FindCommentsRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async handle(event: TrackerEvent): Promise<void> {
|
async handle(event: TrackerEvent): Promise<void> {
|
||||||
@@ -154,6 +156,16 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
|
|||||||
summary: `${actor} te mencionó en ${issue.key}: ${said}`,
|
summary: `${actor} te mencionó en ${issue.key}: ${said}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const answered = event.comment.replyTo
|
||||||
|
? await this.findComments.findById(event.comment.replyTo)
|
||||||
|
: null;
|
||||||
|
if (answered && !answered.deleted) {
|
||||||
|
drafts.push({
|
||||||
|
userId: answered.author.id,
|
||||||
|
kind: NotificationKind.REPLIED,
|
||||||
|
summary: `${actor} respondió a tu comentario en ${issue.key}: ${said}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
for (const userId of [assigneeId, reporterId]) {
|
for (const userId of [assigneeId, reporterId]) {
|
||||||
if (!userId) continue;
|
if (!userId) continue;
|
||||||
drafts.push({
|
drafts.push({
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from '@task-stream/api/tracker/domain';
|
} from '@task-stream/api/tracker/domain';
|
||||||
import { FakeUserDirectory } from '../../testing/in-memory';
|
import { FakeUserDirectory } from '../../testing/in-memory';
|
||||||
import { ChangeFeed } from '../ports/change-feed';
|
import { ChangeFeed } from '../ports/change-feed';
|
||||||
|
import { FindCommentsRepository } from '../ports/find-comments.repository';
|
||||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||||
import { NotificationMailer } from '../ports/notification-mailer';
|
import { NotificationMailer } from '../ports/notification-mailer';
|
||||||
@@ -100,6 +101,13 @@ function setup(mailEnabled = false) {
|
|||||||
}),
|
}),
|
||||||
} as unknown as FindProjectsRepository,
|
} as unknown as FindProjectsRepository,
|
||||||
users,
|
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 };
|
return { repo, published, mailed, handler };
|
||||||
}
|
}
|
||||||
@@ -163,6 +171,7 @@ describe('notifications', () => {
|
|||||||
projectId: 'p-1',
|
projectId: 'p-1',
|
||||||
authorId: 'bob',
|
authorId: 'bob',
|
||||||
body: 'Ya está arreglado',
|
body: 'Ya está arreglado',
|
||||||
|
replyTo: null,
|
||||||
deleted: false,
|
deleted: false,
|
||||||
createdAt: '2026-10-08T10:00:00.000Z',
|
createdAt: '2026-10-08T10:00:00.000Z',
|
||||||
editedAt: null,
|
editedAt: null,
|
||||||
@@ -185,6 +194,7 @@ describe('notifications', () => {
|
|||||||
projectId: 'p-1',
|
projectId: 'p-1',
|
||||||
authorId: 'bob',
|
authorId: 'bob',
|
||||||
body: '@[PM](pm) mira esto, y @[Fuera](stranger) también',
|
body: '@[PM](pm) mira esto, y @[Fuera](stranger) también',
|
||||||
|
replyTo: null,
|
||||||
deleted: false,
|
deleted: false,
|
||||||
createdAt: '2026-10-08T10:00:00.000Z',
|
createdAt: '2026-10-08T10:00:00.000Z',
|
||||||
editedAt: null,
|
editedAt: null,
|
||||||
@@ -200,6 +210,32 @@ describe('notifications', () => {
|
|||||||
expect(repo.all[0].summary).toContain('«@PM mira esto');
|
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 () => {
|
it('copies by e-mail only to whoever asked for it, when mail is configured', async () => {
|
||||||
const { handler, repo, mailed } = setup(true);
|
const { handler, repo, mailed } = setup(true);
|
||||||
repo.emailOn.add('bob');
|
repo.emailOn.add('bob');
|
||||||
|
|||||||
@@ -8,5 +8,7 @@ export abstract class FindCommentsRepository {
|
|||||||
limit: number,
|
limit: number,
|
||||||
offset: number,
|
offset: number,
|
||||||
): Promise<Page<CommentReadModel>>;
|
): Promise<Page<CommentReadModel>>;
|
||||||
|
/** Comments still standing (deleted ones kept for their replies do not count). */
|
||||||
abstract countByIssue(issueId: string): Promise<number>;
|
abstract countByIssue(issueId: string): Promise<number>;
|
||||||
|
abstract countReplies(commentId: string): Promise<number>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ describe('CommentersFollowSaga', () => {
|
|||||||
projectId: 'p-1',
|
projectId: 'p-1',
|
||||||
authorId: 'ana',
|
authorId: 'ana',
|
||||||
body: 'Lo miro',
|
body: 'Lo miro',
|
||||||
|
replyTo: null,
|
||||||
deleted: false,
|
deleted: false,
|
||||||
createdAt: '2026-10-08T10:00:00.000Z',
|
createdAt: '2026-10-08T10:00:00.000Z',
|
||||||
editedAt: null,
|
editedAt: null,
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ export interface CommentProps {
|
|||||||
projectId: string;
|
projectId: string;
|
||||||
authorId: string;
|
authorId: string;
|
||||||
body: string;
|
body: string;
|
||||||
|
/**
|
||||||
|
* The comment this one answers; always a top-level one (one level of
|
||||||
|
* replies). Null for a top-level comment, and on comments made before
|
||||||
|
* there were replies.
|
||||||
|
*/
|
||||||
|
replyTo: string | null;
|
||||||
deleted: boolean;
|
deleted: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
editedAt: string | null;
|
editedAt: string | null;
|
||||||
@@ -22,12 +28,13 @@ export interface CommentProps {
|
|||||||
|
|
||||||
const MAX_BODY = 20_000;
|
const MAX_BODY = 20_000;
|
||||||
|
|
||||||
/** Decisions and explanations inside a ticket. */
|
/** Decisions and explanations inside a ticket, and the replies to them. */
|
||||||
export class Comment extends VersionedAggregateRoot implements CommentProps {
|
export class Comment extends VersionedAggregateRoot implements CommentProps {
|
||||||
public issueId!: string;
|
public issueId!: string;
|
||||||
public projectId!: string;
|
public projectId!: string;
|
||||||
public authorId!: string;
|
public authorId!: string;
|
||||||
public body!: string;
|
public body!: string;
|
||||||
|
public replyTo: string | null = null;
|
||||||
public deleted = false;
|
public deleted = false;
|
||||||
public createdAt!: string;
|
public createdAt!: string;
|
||||||
public editedAt: string | null = null;
|
public editedAt: string | null = null;
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export class CommentFactory {
|
|||||||
projectId: string;
|
projectId: string;
|
||||||
authorId: string;
|
authorId: string;
|
||||||
body: string;
|
body: string;
|
||||||
|
/** Top-level comment it answers. */
|
||||||
|
replyTo?: string | null;
|
||||||
}): Comment {
|
}): Comment {
|
||||||
const data: CommentProps = {
|
const data: CommentProps = {
|
||||||
id: newId(),
|
id: newId(),
|
||||||
@@ -17,6 +19,7 @@ export class CommentFactory {
|
|||||||
projectId: props.projectId,
|
projectId: props.projectId,
|
||||||
authorId: props.authorId,
|
authorId: props.authorId,
|
||||||
body: validCommentBody(props.body),
|
body: validCommentBody(props.body),
|
||||||
|
replyTo: props.replyTo ?? null,
|
||||||
deleted: false,
|
deleted: false,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
editedAt: null,
|
editedAt: null,
|
||||||
|
|||||||
@@ -6,6 +6,13 @@ export class CommentReadModel {
|
|||||||
projectId!: string;
|
projectId!: string;
|
||||||
author!: UserRefReadModel;
|
author!: UserRefReadModel;
|
||||||
body!: string;
|
body!: string;
|
||||||
|
/** Top-level comment it answers; null for a top-level one. */
|
||||||
|
replyTo!: string | null;
|
||||||
|
/**
|
||||||
|
* Deleted while it had replies: kept, without its text, so the
|
||||||
|
* conversation under it still makes sense. Gone with its last reply.
|
||||||
|
*/
|
||||||
|
deleted!: boolean;
|
||||||
createdAt!: string;
|
createdAt!: string;
|
||||||
editedAt!: string | null;
|
editedAt!: string | null;
|
||||||
version!: number;
|
version!: number;
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export enum NotificationKind {
|
|||||||
COMMENTED = 'COMMENTED',
|
COMMENTED = 'COMMENTED',
|
||||||
/** Someone wrote @you. */
|
/** Someone wrote @you. */
|
||||||
MENTIONED = 'MENTIONED',
|
MENTIONED = 'MENTIONED',
|
||||||
|
/** Someone answered a comment of yours. */
|
||||||
|
REPLIED = 'REPLIED',
|
||||||
/** A ticket you follow changed. */
|
/** A ticket you follow changed. */
|
||||||
WATCHED = 'WATCHED',
|
WATCHED = 'WATCHED',
|
||||||
/** You were added to a project. */
|
/** You were added to a project. */
|
||||||
|
|||||||
@@ -45,6 +45,13 @@ import {
|
|||||||
|
|
||||||
/* Query-side adapters (MongoDB). */
|
/* Query-side adapters (MongoDB). */
|
||||||
|
|
||||||
|
/** Comments projected before there were replies have neither field. */
|
||||||
|
const commentFromDoc = (doc: CommentDoc): CommentReadModel => ({
|
||||||
|
...fromDoc(doc),
|
||||||
|
replyTo: doc.replyTo ?? null,
|
||||||
|
deleted: doc.deleted ?? false,
|
||||||
|
});
|
||||||
|
|
||||||
const escapeRegex = (text: string) =>
|
const escapeRegex = (text: string) =>
|
||||||
text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
|
||||||
@@ -446,7 +453,7 @@ export class MongoFindCommentsRepository extends FindCommentsRepository {
|
|||||||
|
|
||||||
async findById(id: string): Promise<CommentReadModel | null> {
|
async findById(id: string): Promise<CommentReadModel | null> {
|
||||||
const doc = await this.comments.findById(id).lean<CommentDoc>();
|
const doc = await this.comments.findById(id).lean<CommentDoc>();
|
||||||
return doc ? fromDoc(doc) : null;
|
return doc ? commentFromDoc(doc) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByIssue(
|
async findByIssue(
|
||||||
@@ -463,11 +470,15 @@ export class MongoFindCommentsRepository extends FindCommentsRepository {
|
|||||||
.lean<CommentDoc[]>(),
|
.lean<CommentDoc[]>(),
|
||||||
this.comments.countDocuments({ issueId }),
|
this.comments.countDocuments({ issueId }),
|
||||||
]);
|
]);
|
||||||
return { items: docs.map(fromDoc), total };
|
return { items: docs.map(commentFromDoc), total };
|
||||||
}
|
}
|
||||||
|
|
||||||
countByIssue(issueId: string): Promise<number> {
|
countByIssue(issueId: string): Promise<number> {
|
||||||
return this.comments.countDocuments({ issueId });
|
return this.comments.countDocuments({ issueId, deleted: { $ne: true } });
|
||||||
|
}
|
||||||
|
|
||||||
|
countReplies(commentId: string): Promise<number> {
|
||||||
|
return this.comments.countDocuments({ replyTo: commentId });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ export const CommentSchema = new Schema<CommentDoc>(
|
|||||||
{
|
{
|
||||||
_id: String,
|
_id: String,
|
||||||
issueId: { type: String, index: true },
|
issueId: { type: String, index: true },
|
||||||
|
replyTo: { type: String, index: true },
|
||||||
author: userRef,
|
author: userRef,
|
||||||
version: Number,
|
version: Number,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,13 +8,18 @@ import { CommandResultGql, CommentGql } from './types';
|
|||||||
export class CommentResolver {
|
export class CommentResolver {
|
||||||
constructor(private readonly comments: CommentsService) {}
|
constructor(private readonly comments: CommentsService) {}
|
||||||
|
|
||||||
@Mutation(() => CommandResultGql)
|
@Mutation(() => CommandResultGql, {
|
||||||
|
description:
|
||||||
|
'Comment on a ticket, or answer a comment (replyTo; one level of replies)',
|
||||||
|
})
|
||||||
addComment(
|
addComment(
|
||||||
@CurrentUser() me: User,
|
@CurrentUser() me: User,
|
||||||
@Args('issueId', { type: () => ID }) issueId: string,
|
@Args('issueId', { type: () => ID }) issueId: string,
|
||||||
@Args('body') body: 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)
|
@Mutation(() => CommandResultGql)
|
||||||
|
|||||||
@@ -445,7 +445,17 @@ export class CommentGql {
|
|||||||
@Field(() => ID) id!: string;
|
@Field(() => ID) id!: string;
|
||||||
@Field(() => ID) issueId!: string;
|
@Field(() => ID) issueId!: string;
|
||||||
@Field(() => UserRefGql) author!: UserRefGql;
|
@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() createdAt!: string;
|
||||||
@Field(() => String, { nullable: true }) editedAt!: string | null;
|
@Field(() => String, { nullable: true }) editedAt!: string | null;
|
||||||
@Field(() => Int) version!: number;
|
@Field(() => Int) version!: number;
|
||||||
|
|||||||
@@ -955,6 +955,17 @@ export class CommentBodyDto {
|
|||||||
body!: string;
|
body!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class AddCommentDto extends CommentBodyDto {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
format: 'uuid',
|
||||||
|
description:
|
||||||
|
'Comment it answers (one level: answering a reply joins its thread)',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
replyTo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Releases ---------------------------------------------------------------
|
// ---- Releases ---------------------------------------------------------------
|
||||||
|
|
||||||
export class CreateReleaseDto {
|
export class CreateReleaseDto {
|
||||||
|
|||||||
@@ -466,7 +466,21 @@ export class CommentDto {
|
|||||||
@ApiProperty({ format: 'uuid' }) id!: string;
|
@ApiProperty({ format: 'uuid' }) id!: string;
|
||||||
@ApiProperty({ format: 'uuid' }) issueId!: string;
|
@ApiProperty({ format: 'uuid' }) issueId!: string;
|
||||||
@ApiProperty({ type: UserRefDto }) author!: UserRefDto;
|
@ApiProperty({ type: UserRefDto }) author!: UserRefDto;
|
||||||
@ApiProperty() body!: string;
|
@ApiProperty({
|
||||||
|
description: 'Empty on a deleted comment kept for its replies',
|
||||||
|
})
|
||||||
|
body!: string;
|
||||||
|
@ApiProperty({
|
||||||
|
type: String,
|
||||||
|
nullable: true,
|
||||||
|
format: 'uuid',
|
||||||
|
description: 'The top-level comment it answers; null for a top-level one',
|
||||||
|
})
|
||||||
|
replyTo!: string | null;
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Deleted, but kept (without its text) while it has replies',
|
||||||
|
})
|
||||||
|
deleted!: boolean;
|
||||||
@ApiProperty({ format: 'date-time' }) createdAt!: string;
|
@ApiProperty({ format: 'date-time' }) createdAt!: string;
|
||||||
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
|
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
|
||||||
editedAt!: string | null;
|
editedAt!: string | null;
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ import {
|
|||||||
BulkUpdateIssuesDto,
|
BulkUpdateIssuesDto,
|
||||||
ChangeStatusDto,
|
ChangeStatusDto,
|
||||||
ChecklistItemBodyDto,
|
ChecklistItemBodyDto,
|
||||||
CommentBodyDto,
|
AddCommentDto,
|
||||||
CreateIssueDto,
|
CreateIssueDto,
|
||||||
IssueSearchQueryDto,
|
IssueSearchQueryDto,
|
||||||
LinkIssueDto,
|
LinkIssueDto,
|
||||||
@@ -529,18 +529,21 @@ export class IssuesController {
|
|||||||
|
|
||||||
@Post('issues/:issue/comments')
|
@Post('issues/:issue/comments')
|
||||||
@ISSUE
|
@ISSUE
|
||||||
@ApiOperation({ summary: 'Comment on the ticket' })
|
@ApiOperation({
|
||||||
|
summary: 'Comment on the ticket, or answer a comment (replyTo)',
|
||||||
|
})
|
||||||
@ApiCreatedResponse({ type: CommandResultDto })
|
@ApiCreatedResponse({ type: CommandResultDto })
|
||||||
@ApiCommandErrors()
|
@ApiCommandErrors()
|
||||||
async addComment(
|
async addComment(
|
||||||
@CurrentUser() me: User,
|
@CurrentUser() me: User,
|
||||||
@Param('issue') issue: string,
|
@Param('issue') issue: string,
|
||||||
@Body() body: CommentBodyDto,
|
@Body() body: AddCommentDto,
|
||||||
) {
|
) {
|
||||||
return this.comments.add(
|
return this.comments.add(
|
||||||
me.id,
|
me.id,
|
||||||
await this.refs.issueId(me.id, issue),
|
await this.refs.issueId(me.id, issue),
|
||||||
body.body,
|
body.body,
|
||||||
|
body.replyTo ?? null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ export const issuePageEvents = eventGroup({
|
|||||||
body: string;
|
body: string;
|
||||||
/** Uploaded under the new comment once it exists. */
|
/** Uploaded under the new comment once it exists. */
|
||||||
files?: File[];
|
files?: File[];
|
||||||
|
/** Comment it answers (one level of replies). */
|
||||||
|
replyTo?: string | null;
|
||||||
}>(),
|
}>(),
|
||||||
watchRequested: type<{ issueId: string; watching: boolean }>(),
|
watchRequested: type<{ issueId: string; watching: boolean }>(),
|
||||||
trashRequested: type<{ issueId: string; trashed: boolean }>(),
|
trashRequested: type<{ issueId: string; trashed: boolean }>(),
|
||||||
|
|||||||
@@ -92,10 +92,14 @@ export class IssueFacade {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `files` are attached under the new comment. */
|
/** `files` are attached under the new comment; `replyTo` answers one. */
|
||||||
addComment(body: string, files: File[] = []): void {
|
addComment(
|
||||||
|
body: string,
|
||||||
|
files: File[] = [],
|
||||||
|
replyTo: string | null = null,
|
||||||
|
): void {
|
||||||
this.withIssue((issueId) =>
|
this.withIssue((issueId) =>
|
||||||
this.dispatch.commentAddRequested({ issueId, body, files }),
|
this.dispatch.commentAddRequested({ issueId, body, files, replyTo }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -460,7 +460,7 @@ export const IssueStore = signalStore(
|
|||||||
mergeMap(({ payload }) =>
|
mergeMap(({ payload }) =>
|
||||||
runCommand(
|
runCommand(
|
||||||
comments
|
comments
|
||||||
.add(payload.issueId, payload.body)
|
.add(payload.issueId, payload.body, payload.replyTo ?? null)
|
||||||
.pipe(
|
.pipe(
|
||||||
concatMap((comment) =>
|
concatMap((comment) =>
|
||||||
payload.files?.length
|
payload.files?.length
|
||||||
@@ -474,7 +474,9 @@ export const IssueStore = signalStore(
|
|||||||
),
|
),
|
||||||
payload.files?.length
|
payload.files?.length
|
||||||
? 'Comentario y adjuntos añadidos'
|
? 'Comentario y adjuntos añadidos'
|
||||||
: 'Comentario añadido',
|
: payload.replyTo
|
||||||
|
? 'Respuesta añadida'
|
||||||
|
: 'Comentario añadido',
|
||||||
done,
|
done,
|
||||||
failed,
|
failed,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export enum NotificationKind {
|
|||||||
QA_FAILED = 'QA_FAILED',
|
QA_FAILED = 'QA_FAILED',
|
||||||
COMMENTED = 'COMMENTED',
|
COMMENTED = 'COMMENTED',
|
||||||
MENTIONED = 'MENTIONED',
|
MENTIONED = 'MENTIONED',
|
||||||
|
REPLIED = 'REPLIED',
|
||||||
WATCHED = 'WATCHED',
|
WATCHED = 'WATCHED',
|
||||||
ADDED_TO_PROJECT = 'ADDED_TO_PROJECT',
|
ADDED_TO_PROJECT = 'ADDED_TO_PROJECT',
|
||||||
REMINDER = 'REMINDER',
|
REMINDER = 'REMINDER',
|
||||||
|
|||||||
@@ -269,7 +269,12 @@ export interface Comment {
|
|||||||
id: string;
|
id: string;
|
||||||
issueId: string;
|
issueId: string;
|
||||||
author: UserRef;
|
author: UserRef;
|
||||||
|
/** Empty on a deleted comment kept for its replies. */
|
||||||
body: string;
|
body: string;
|
||||||
|
/** The top-level comment it answers; null for a top-level one. */
|
||||||
|
replyTo: string | null;
|
||||||
|
/** Deleted, but kept (without its text) while it has replies. */
|
||||||
|
deleted: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
editedAt: string | null;
|
editedAt: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -230,7 +230,11 @@ export abstract class SavedFiltersGateway {
|
|||||||
|
|
||||||
export abstract class CommentsGateway {
|
export abstract class CommentsGateway {
|
||||||
abstract list(issueId: string): Observable<Comment[]>;
|
abstract list(issueId: string): Observable<Comment[]>;
|
||||||
abstract add(issueId: string, body: string): Observable<CommandResult>;
|
abstract add(
|
||||||
|
issueId: string,
|
||||||
|
body: string,
|
||||||
|
replyTo?: string | null,
|
||||||
|
): Observable<CommandResult>;
|
||||||
abstract edit(commentId: string, body: string): Observable<CommandResult>;
|
abstract edit(commentId: string, body: string): Observable<CommandResult>;
|
||||||
abstract remove(commentId: string): Observable<CommandResult>;
|
abstract remove(commentId: string): Observable<CommandResult>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,9 +42,13 @@ export class GraphqlCommentsGateway extends CommentsGateway {
|
|||||||
.pipe(map((d) => d.issue.comments.items.map(toComment)));
|
.pipe(map((d) => d.issue.comments.items.map(toComment)));
|
||||||
}
|
}
|
||||||
|
|
||||||
add(issueId: string, body: string): Observable<CommandResult> {
|
add(
|
||||||
|
issueId: string,
|
||||||
|
body: string,
|
||||||
|
replyTo: string | null = null,
|
||||||
|
): Observable<CommandResult> {
|
||||||
return this.gql
|
return this.gql
|
||||||
.mutate(AddCommentDocument, { issueId, body })
|
.mutate(AddCommentDocument, { issueId, body, replyTo })
|
||||||
.pipe(map((d) => toCommandResult(d.addComment)));
|
.pipe(map((d) => toCommandResult(d.addComment)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -141,6 +141,8 @@ fragment CommentFields on Comment {
|
|||||||
id
|
id
|
||||||
issueId
|
issueId
|
||||||
body
|
body
|
||||||
|
replyTo
|
||||||
|
deleted
|
||||||
createdAt
|
createdAt
|
||||||
editedAt
|
editedAt
|
||||||
author {
|
author {
|
||||||
|
|||||||
@@ -212,6 +212,7 @@ export type NotificationKind =
|
|||||||
| 'MENTIONED'
|
| 'MENTIONED'
|
||||||
| 'QA_FAILED'
|
| 'QA_FAILED'
|
||||||
| 'REMINDER'
|
| 'REMINDER'
|
||||||
|
| 'REPLIED'
|
||||||
| 'STATUS_CHANGED'
|
| 'STATUS_CHANGED'
|
||||||
| 'WATCHED';
|
| 'WATCHED';
|
||||||
|
|
||||||
@@ -554,6 +555,8 @@ export type CommentFieldsFragment = {
|
|||||||
id: string;
|
id: string;
|
||||||
issueId: string;
|
issueId: string;
|
||||||
body: string;
|
body: string;
|
||||||
|
replyTo: string | null;
|
||||||
|
deleted: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
editedAt: string | null;
|
editedAt: string | null;
|
||||||
author: { id: string; name: string };
|
author: { id: string; name: string };
|
||||||
@@ -875,6 +878,8 @@ export type IssueCommentsQuery = {
|
|||||||
id: string;
|
id: string;
|
||||||
issueId: string;
|
issueId: string;
|
||||||
body: string;
|
body: string;
|
||||||
|
replyTo: string | null;
|
||||||
|
deleted: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
editedAt: string | null;
|
editedAt: string | null;
|
||||||
author: { id: string; name: string };
|
author: { id: string; name: string };
|
||||||
@@ -886,6 +891,7 @@ export type IssueCommentsQuery = {
|
|||||||
export type AddCommentMutationVariables = Exact<{
|
export type AddCommentMutationVariables = Exact<{
|
||||||
issueId: string;
|
issueId: string;
|
||||||
body: string;
|
body: string;
|
||||||
|
replyTo?: string | null | undefined;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export type AddCommentMutation = {
|
export type AddCommentMutation = {
|
||||||
@@ -1853,6 +1859,8 @@ export type CommentChangedSubscription = {
|
|||||||
id: string;
|
id: string;
|
||||||
issueId: string;
|
issueId: string;
|
||||||
body: string;
|
body: string;
|
||||||
|
replyTo: string | null;
|
||||||
|
deleted: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
editedAt: string | null;
|
editedAt: string | null;
|
||||||
author: { id: string; name: string };
|
author: { id: string; name: string };
|
||||||
@@ -2698,6 +2706,8 @@ export const CommentFieldsFragmentDoc = {
|
|||||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'issueId' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'issueId' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'body' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'body' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'replyTo' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'deleted' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'editedAt' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'editedAt' } },
|
||||||
{
|
{
|
||||||
@@ -5477,6 +5487,8 @@ export const IssueCommentsDocument = {
|
|||||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'issueId' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'issueId' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'body' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'body' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'replyTo' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'deleted' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'editedAt' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'editedAt' } },
|
||||||
{
|
{
|
||||||
@@ -5527,6 +5539,14 @@ export const AddCommentDocument = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
kind: 'VariableDefinition',
|
||||||
|
variable: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'replyTo' },
|
||||||
|
},
|
||||||
|
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
selectionSet: {
|
selectionSet: {
|
||||||
kind: 'SelectionSet',
|
kind: 'SelectionSet',
|
||||||
@@ -5551,6 +5571,14 @@ export const AddCommentDocument = {
|
|||||||
name: { kind: 'Name', value: 'body' },
|
name: { kind: 'Name', value: 'body' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
kind: 'Argument',
|
||||||
|
name: { kind: 'Name', value: 'replyTo' },
|
||||||
|
value: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'replyTo' },
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
selectionSet: {
|
selectionSet: {
|
||||||
kind: 'SelectionSet',
|
kind: 'SelectionSet',
|
||||||
@@ -11232,6 +11260,8 @@ export const CommentChangedDocument = {
|
|||||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'issueId' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'issueId' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'body' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'body' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'replyTo' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'deleted' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'editedAt' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'editedAt' } },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -112,8 +112,8 @@ query IssueComments($idOrKey: String!) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mutation AddComment($issueId: ID!, $body: String!) {
|
mutation AddComment($issueId: ID!, $body: String!, $replyTo: ID) {
|
||||||
addComment(issueId: $issueId, body: $body) {
|
addComment(issueId: $issueId, body: $body, replyTo: $replyTo) {
|
||||||
...CommandResultFields
|
...CommandResultFields
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -531,3 +531,32 @@ a.ts-key {
|
|||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
background: color-mix(in srgb, var(--ts-c-red, #dc2626) 12%, transparent);
|
background: color-mix(in srgb, var(--ts-c-red, #dc2626) 12%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One level of replies under each comment.
|
||||||
|
.ts-replies {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin: -0.25rem 0 0.5rem 2.75rem;
|
||||||
|
padding-left: 0.875rem;
|
||||||
|
border-left: 2px solid var(--ts-line);
|
||||||
|
|
||||||
|
&:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-comment__gone {
|
||||||
|
margin: 0;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-reply__open {
|
||||||
|
justify-self: start;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-reply__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { NgTemplateOutlet } from '@angular/common';
|
||||||
import {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
@@ -71,6 +72,7 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|||||||
selector: 'ts-issue-page',
|
selector: 'ts-issue-page',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [
|
imports: [
|
||||||
|
NgTemplateOutlet,
|
||||||
RouterLink,
|
RouterLink,
|
||||||
FormField,
|
FormField,
|
||||||
AttachmentItem,
|
AttachmentItem,
|
||||||
@@ -512,22 +514,24 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|||||||
<article class="ts-block">
|
<article class="ts-block">
|
||||||
<h2 class="ts-section">
|
<h2 class="ts-section">
|
||||||
Comentarios
|
Comentarios
|
||||||
<span class="ts-mono">{{ facade.comments().length }}</span>
|
<span class="ts-mono">{{ commentCount() }}</span>
|
||||||
</h2>
|
</h2>
|
||||||
@for (comment of facade.comments(); track comment.id) {
|
<ng-template #commentView let-comment>
|
||||||
<div class="ts-comment">
|
<div class="ts-comment">
|
||||||
<span class="ts-comment__avatar" aria-hidden="true">{{
|
<span class="ts-comment__avatar" aria-hidden="true">{{
|
||||||
initialsOf(comment.author.name)
|
initialsOf(comment.author.name)
|
||||||
}}</span>
|
}}</span>
|
||||||
<div class="ts-comment__body">
|
<div class="ts-comment__body">
|
||||||
<div class="ts-comment__head">
|
<div class="ts-comment__head">
|
||||||
<strong>{{ comment.author.name }}</strong>
|
<strong>{{
|
||||||
|
comment.deleted ? '—' : comment.author.name
|
||||||
|
}}</strong>
|
||||||
<span class="ts-comment__when"
|
<span class="ts-comment__when"
|
||||||
>{{ ago(comment.createdAt)
|
>{{ ago(comment.createdAt)
|
||||||
}}{{ comment.editedAt ? ' · editado' : '' }}</span
|
}}{{ comment.editedAt ? ' · editado' : '' }}</span
|
||||||
>
|
>
|
||||||
<span class="ts-spacer"></span>
|
<span class="ts-spacer"></span>
|
||||||
@if (comment.author.id === facade.me()) {
|
@if (!comment.deleted && comment.author.id === facade.me()) {
|
||||||
<pi-icon-button
|
<pi-icon-button
|
||||||
icon="edit"
|
icon="edit"
|
||||||
color="grey"
|
color="grey"
|
||||||
@@ -537,7 +541,8 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@if (
|
@if (
|
||||||
comment.author.id === facade.me() || project.canManage()
|
!comment.deleted &&
|
||||||
|
(comment.author.id === facade.me() || project.canManage())
|
||||||
) {
|
) {
|
||||||
<pi-icon-button
|
<pi-icon-button
|
||||||
icon="trash"
|
icon="trash"
|
||||||
@@ -548,6 +553,9 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
@if (comment.deleted) {
|
||||||
|
<p class="ts-muted ts-comment__gone">Comentario borrado</p>
|
||||||
|
} @else {
|
||||||
<p class="ts-prewrap">
|
<p class="ts-prewrap">
|
||||||
@for (part of segments(comment.body); track $index) {
|
@for (part of segments(comment.body); track $index) {
|
||||||
@if ('mention' in part) {
|
@if ('mention' in part) {
|
||||||
@@ -561,7 +569,8 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
@if (filesOf(comment).length) {
|
}
|
||||||
|
@if (!comment.deleted && filesOf(comment).length) {
|
||||||
<div class="ts-attachments">
|
<div class="ts-attachments">
|
||||||
@for (file of filesOf(comment); track file.id) {
|
@for (file of filesOf(comment); track file.id) {
|
||||||
<ts-attachment
|
<ts-attachment
|
||||||
@@ -573,6 +582,57 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
@for (thread of threads(); track thread.comment.id) {
|
||||||
|
<ng-container
|
||||||
|
*ngTemplateOutlet="commentView; context: { $implicit: thread.comment }"
|
||||||
|
/>
|
||||||
|
<div class="ts-replies">
|
||||||
|
@for (reply of thread.replies; track reply.id) {
|
||||||
|
<ng-container
|
||||||
|
*ngTemplateOutlet="commentView; context: { $implicit: reply }"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
@if (replyingTo() === thread.comment.id) {
|
||||||
|
<form
|
||||||
|
class="ts-form ts-reply"
|
||||||
|
(submit)="$event.preventDefault(); reply(thread.comment)"
|
||||||
|
novalidate
|
||||||
|
>
|
||||||
|
<pi-textarea
|
||||||
|
[placeholder]="'Responde a ' + thread.comment.author.name + '…'"
|
||||||
|
[(value)]="replyBody"
|
||||||
|
/>
|
||||||
|
<div class="ts-reply__actions">
|
||||||
|
<pi-action-button
|
||||||
|
color="primary"
|
||||||
|
text="Responder"
|
||||||
|
[disabled]="!replyBody().trim()"
|
||||||
|
(execute)="reply(thread.comment)"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ts-plain-link"
|
||||||
|
(click)="replyingTo.set(null)"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
} @else if (project.canContribute() && !thread.comment.deleted) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ts-plain-link ts-reply__open"
|
||||||
|
(click)="startReply(thread.comment)"
|
||||||
|
>
|
||||||
|
Responder{{
|
||||||
|
thread.replies.length
|
||||||
|
? ' (' + thread.replies.length + ')'
|
||||||
|
: ''
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
@if (project.canContribute()) {
|
@if (project.canContribute()) {
|
||||||
<form
|
<form
|
||||||
@@ -934,6 +994,25 @@ export class IssuePage {
|
|||||||
protected readonly linkType = signal<string | null>(IssueLinkType.RELATES_TO);
|
protected readonly linkType = signal<string | null>(IssueLinkType.RELATES_TO);
|
||||||
protected readonly linkTarget = signal('');
|
protected readonly linkTarget = signal('');
|
||||||
protected readonly commentBody = signal('');
|
protected readonly commentBody = signal('');
|
||||||
|
/** The comment being answered, and the answer being written. */
|
||||||
|
protected readonly replyingTo = signal<string | null>(null);
|
||||||
|
protected readonly replyBody = signal('');
|
||||||
|
/** Top-level comments, each with its replies (oldest first). */
|
||||||
|
protected readonly threads = computed(() => {
|
||||||
|
const comments = this.facade.comments();
|
||||||
|
const ids = new Set(comments.map((c) => c.id));
|
||||||
|
const replies = new Map<string, Comment[]>();
|
||||||
|
for (const c of comments)
|
||||||
|
if (c.replyTo && ids.has(c.replyTo))
|
||||||
|
replies.set(c.replyTo, [...(replies.get(c.replyTo) ?? []), c]);
|
||||||
|
// A reply whose comment did not load stands on its own.
|
||||||
|
return comments
|
||||||
|
.filter((c) => !c.replyTo || !ids.has(c.replyTo))
|
||||||
|
.map((comment) => ({ comment, replies: replies.get(comment.id) ?? [] }));
|
||||||
|
});
|
||||||
|
protected readonly commentCount = computed(
|
||||||
|
() => this.facade.comments().filter((c) => !c.deleted).length,
|
||||||
|
);
|
||||||
protected readonly commentFiles = signal<File[]>([]);
|
protected readonly commentFiles = signal<File[]>([]);
|
||||||
protected readonly segments = mentionSegments;
|
protected readonly segments = mentionSegments;
|
||||||
/** Members matching what is typed after an `@` at the end of the comment. */
|
/** Members matching what is typed after an `@` at the end of the comment. */
|
||||||
@@ -1126,6 +1205,19 @@ export class IssuePage {
|
|||||||
this.commentFiles.set([]);
|
this.commentFiles.set([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected startReply(comment: Comment): void {
|
||||||
|
this.replyBody.set('');
|
||||||
|
this.replyingTo.set(comment.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected reply(comment: Comment): void {
|
||||||
|
const body = this.replyBody().trim();
|
||||||
|
if (!body) return;
|
||||||
|
this.facade.addComment(body, [], comment.id);
|
||||||
|
this.replyBody.set('');
|
||||||
|
this.replyingTo.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
protected mention(user: { id: string; name: string }): void {
|
protected mention(user: { id: string; name: string }): void {
|
||||||
this.commentBody.update((text) => insertMention(text, user));
|
this.commentBody.update((text) => insertMention(text, user));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const ICON: Record<NotificationKind, string> = {
|
|||||||
[NotificationKind.QA_FAILED]: 'exclamation',
|
[NotificationKind.QA_FAILED]: 'exclamation',
|
||||||
[NotificationKind.COMMENTED]: 'chat',
|
[NotificationKind.COMMENTED]: 'chat',
|
||||||
[NotificationKind.MENTIONED]: 'chat',
|
[NotificationKind.MENTIONED]: 'chat',
|
||||||
|
[NotificationKind.REPLIED]: 'chat',
|
||||||
[NotificationKind.WATCHED]: 'focus',
|
[NotificationKind.WATCHED]: 'focus',
|
||||||
[NotificationKind.ADDED_TO_PROJECT]: 'briefcase',
|
[NotificationKind.ADDED_TO_PROJECT]: 'briefcase',
|
||||||
[NotificationKind.REMINDER]: 'calendar',
|
[NotificationKind.REMINDER]: 'calendar',
|
||||||
|
|||||||
Reference in New Issue
Block a user