feat: conversations on a ticket are settled, and you see who is typing
A top-level comment and its replies can be resolved by whoever can
change the ticket (CommentThreadResolvedEvent), with an optional
conclusion added as a reply marked as a decision, so it shows above the
ticket. A settled conversation folds to one line (topic, who settled it
and the conclusion) and can be unfolded or reopened; the header counts
the open ones, and finishing the ticket with one open asks first.
«Ana está escribiendo…» shows in the conversation being answered, at
once: a `typing` mutation publishes to a Redis channel per ticket
(`typing` subscription), never stored. In replies, Enter sends and
Shift+Enter breaks the line, except inside lists and code.
API: resolveThread / PUT-DELETE /api/comments/{id}/resolution, Comment
.resolved/resolvedBy/resolvedAt, typing mutation and subscription. MCP:
resolve_thread.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7113,6 +7113,213 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/comments/{commentId}/resolution": {
|
||||||
|
"put": {
|
||||||
|
"description": "Whoever can change the ticket. A conclusion is added as a reply marked as a decision. The web folds a settled conversation.",
|
||||||
|
"operationId": "CommentsController_resolveThread",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "commentId",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ResolveThreadDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/CommandResultDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid input",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or rejected Pireon token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Not a member of the project (or not allowed)",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not found",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"description": "Someone changed it at the same time: retry",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Business rule (e.g. a transition the workflow forbids)",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"pireon-oauth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pireon": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "Settle a conversation (a top-level comment and its replies)",
|
||||||
|
"tags": [
|
||||||
|
"Comments"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"operationId": "CommentsController_reopenThread",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "commentId",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/CommandResultDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid input",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or rejected Pireon token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Not a member of the project (or not allowed)",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not found",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"description": "Someone changed it at the same time: retry",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Business rule (e.g. a transition the workflow forbids)",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"pireon-oauth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pireon": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "Reopen a conversation",
|
||||||
|
"tags": [
|
||||||
|
"Comments"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/me/releases": {
|
"/api/me/releases": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "ReleasesController_upcoming",
|
"operationId": "ReleasesController_upcoming",
|
||||||
@@ -16162,6 +16369,25 @@
|
|||||||
"nullable": true,
|
"nullable": true,
|
||||||
"format": "date-time"
|
"format": "date-time"
|
||||||
},
|
},
|
||||||
|
"resolved": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "A conversation (top-level comment and its replies) that was settled"
|
||||||
|
},
|
||||||
|
"resolvedBy": {
|
||||||
|
"nullable": true,
|
||||||
|
"description": "Who settled it",
|
||||||
|
"type": "object",
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/UserRefDto"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"resolvedAt": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true,
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
"createdAt": {
|
"createdAt": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "date-time"
|
"format": "date-time"
|
||||||
@@ -16186,6 +16412,9 @@
|
|||||||
"decision",
|
"decision",
|
||||||
"decidedBy",
|
"decidedBy",
|
||||||
"decidedAt",
|
"decidedAt",
|
||||||
|
"resolved",
|
||||||
|
"resolvedBy",
|
||||||
|
"resolvedAt",
|
||||||
"createdAt",
|
"createdAt",
|
||||||
"editedAt",
|
"editedAt",
|
||||||
"version"
|
"version"
|
||||||
@@ -16328,6 +16557,16 @@
|
|||||||
"on"
|
"on"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"ResolveThreadDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"conclusion": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "What was settled: added as a reply marked as a decision before it closes",
|
||||||
|
"maxLength": 20000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"ReleaseStatus": {
|
"ReleaseStatus": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
|
|||||||
@@ -310,6 +310,13 @@ type Comment {
|
|||||||
|
|
||||||
"""The top-level comment it answers; null for a top-level one"""
|
"""The top-level comment it answers; null for a top-level one"""
|
||||||
replyTo: ID
|
replyTo: ID
|
||||||
|
|
||||||
|
"""A conversation (top-level comment and its replies) that was settled"""
|
||||||
|
resolved: Boolean!
|
||||||
|
resolvedAt: String
|
||||||
|
|
||||||
|
"""Who settled it"""
|
||||||
|
resolvedBy: UserRef
|
||||||
version: Int!
|
version: Int!
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1049,6 +1056,11 @@ type Mutation {
|
|||||||
removeGitRepository(projectId: ID!, repositoryId: ID!): Boolean!
|
removeGitRepository(projectId: ID!, repositoryId: ID!): Boolean!
|
||||||
removeProjectMember(projectId: ID!, userId: ID!): CommandResult!
|
removeProjectMember(projectId: ID!, userId: ID!): CommandResult!
|
||||||
|
|
||||||
|
"""
|
||||||
|
Settle a conversation (a top-level comment and its replies) or reopen it (resolved: false). A conclusion is added as a reply marked as a decision. Whoever can change the ticket
|
||||||
|
"""
|
||||||
|
resolveThread(commentId: ID!, conclusion: String, resolved: Boolean! = true): CommandResult!
|
||||||
|
|
||||||
"""Back from the trash"""
|
"""Back from the trash"""
|
||||||
restoreIssue(issueId: ID!): CommandResult!
|
restoreIssue(issueId: ID!): CommandResult!
|
||||||
|
|
||||||
@@ -1105,6 +1117,16 @@ type Mutation {
|
|||||||
"""
|
"""
|
||||||
trashIssue(issueId: ID!): CommandResult!
|
trashIssue(issueId: ID!): CommandResult!
|
||||||
|
|
||||||
|
"""
|
||||||
|
I am writing on this ticket (send every few seconds while typing): those with it open see it at once via `typing`
|
||||||
|
"""
|
||||||
|
typing(
|
||||||
|
issueId: ID!
|
||||||
|
|
||||||
|
"""The conversation being answered; null: a new comment"""
|
||||||
|
threadId: ID
|
||||||
|
): Boolean!
|
||||||
|
|
||||||
"""Bring an archived project back (owner only)"""
|
"""Bring an archived project back (owner only)"""
|
||||||
unarchiveProject(projectId: ID!): CommandResult!
|
unarchiveProject(projectId: ID!): CommandResult!
|
||||||
unlinkIssues(input: LinkIssuesInput!): CommandResult!
|
unlinkIssues(input: LinkIssuesInput!): CommandResult!
|
||||||
@@ -1988,6 +2010,11 @@ type Subscription {
|
|||||||
projectChanged(projectId: ID!): ProjectChange!
|
projectChanged(projectId: ID!): ProjectChange!
|
||||||
releaseChanged(projectId: ID!): ReleaseChange!
|
releaseChanged(projectId: ID!): ReleaseChange!
|
||||||
sprintChanged(projectId: ID!): SprintChange!
|
sprintChanged(projectId: ID!): SprintChange!
|
||||||
|
|
||||||
|
"""
|
||||||
|
Who is writing on a ticket, as they type (one's own come too: leave them out)
|
||||||
|
"""
|
||||||
|
typing(issueId: ID!): Typing!
|
||||||
}
|
}
|
||||||
|
|
||||||
type Suggestion {
|
type Suggestion {
|
||||||
@@ -2038,6 +2065,16 @@ type Throughput {
|
|||||||
week: String!
|
week: String!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"""Someone writing on a ticket right now (not stored)"""
|
||||||
|
type Typing {
|
||||||
|
at: String!
|
||||||
|
issueId: ID!
|
||||||
|
|
||||||
|
"""The conversation being answered; null: a new comment"""
|
||||||
|
threadId: ID
|
||||||
|
user: UserRef!
|
||||||
|
}
|
||||||
|
|
||||||
input UpdateChecklistItemInput {
|
input UpdateChecklistItemInput {
|
||||||
done: Boolean
|
done: Boolean
|
||||||
issueId: ID!
|
issueId: ID!
|
||||||
|
|||||||
@@ -1050,6 +1050,27 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/comments/{commentId}/resolution": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
/**
|
||||||
|
* Settle a conversation (a top-level comment and its replies)
|
||||||
|
* @description Whoever can change the ticket. A conclusion is added as a reply marked as a decision. The web folds a settled conversation.
|
||||||
|
*/
|
||||||
|
put: operations["CommentsController_resolveThread"];
|
||||||
|
post?: never;
|
||||||
|
/** Reopen a conversation */
|
||||||
|
delete: operations["CommentsController_reopenThread"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/me/releases": {
|
"/api/me/releases": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -3080,6 +3101,12 @@ export interface components {
|
|||||||
decidedBy: components["schemas"]["UserRefDto"] | null;
|
decidedBy: components["schemas"]["UserRefDto"] | null;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
decidedAt: string | null;
|
decidedAt: string | null;
|
||||||
|
/** @description A conversation (top-level comment and its replies) that was settled */
|
||||||
|
resolved: boolean;
|
||||||
|
/** @description Who settled it */
|
||||||
|
resolvedBy: components["schemas"]["UserRefDto"] | null;
|
||||||
|
/** Format: date-time */
|
||||||
|
resolvedAt: string | null;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
@@ -3144,6 +3171,10 @@ export interface components {
|
|||||||
/** @description true adds the reaction, false takes it back */
|
/** @description true adds the reaction, false takes it back */
|
||||||
on: boolean;
|
on: boolean;
|
||||||
};
|
};
|
||||||
|
ResolveThreadDto: {
|
||||||
|
/** @description What was settled: added as a reply marked as a decision before it closes */
|
||||||
|
conclusion?: string;
|
||||||
|
};
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
ReleaseStatus: "PLANNED" | "RELEASED" | "ARCHIVED";
|
ReleaseStatus: "PLANNED" | "RELEASED" | "ARCHIVED";
|
||||||
ReleaseDto: {
|
ReleaseDto: {
|
||||||
@@ -8596,6 +8627,160 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
CommentsController_resolveThread: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
commentId: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ResolveThreadDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["CommandResultDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Invalid input */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Missing or rejected Pireon token */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Not a member of the project (or not allowed) */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Not found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Someone changed it at the same time: retry */
|
||||||
|
409: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Business rule (e.g. a transition the workflow forbids) */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
CommentsController_reopenThread: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
commentId: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["CommandResultDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Invalid input */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Missing or rejected Pireon token */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Not a member of the project (or not allowed) */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Not found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Someone changed it at the same time: retry */
|
||||||
|
409: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Business rule (e.g. a transition the workflow forbids) */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
ReleasesController_upcoming: {
|
ReleasesController_upcoming: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
|
|||||||
@@ -290,6 +290,7 @@ export class IssuesResolver {
|
|||||||
by: r.users.map((u) => u.name),
|
by: r.users.map((u) => u.name),
|
||||||
})),
|
})),
|
||||||
decision: c.decision || undefined,
|
decision: c.decision || undefined,
|
||||||
|
resolved: c.resolved || undefined,
|
||||||
}))
|
}))
|
||||||
: undefined;
|
: undefined;
|
||||||
const decisions = (
|
const decisions = (
|
||||||
@@ -772,6 +773,37 @@ export class IssuesResolver {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'resolve_thread',
|
||||||
|
description:
|
||||||
|
'Settle a conversation (a top-level comment and its replies), optionally with a conclusion that is added as a reply marked as a decision; or reopen it (resolved: false). Whoever can change the ticket. Comment ids come with get_issue.',
|
||||||
|
paramsSchema: z.object({
|
||||||
|
commentId: z.string().min(1),
|
||||||
|
resolved: z.boolean().default(true),
|
||||||
|
conclusion: z.string().max(20000).optional(),
|
||||||
|
}),
|
||||||
|
annotations: { ...WRITE, idempotentHint: true },
|
||||||
|
})
|
||||||
|
resolveThread(
|
||||||
|
params: { commentId: string; resolved: boolean; conclusion?: string },
|
||||||
|
ctx: McpContext,
|
||||||
|
) {
|
||||||
|
const client = this.api.as(ctx);
|
||||||
|
const path = { commentId: params.commentId };
|
||||||
|
return toolResult(() =>
|
||||||
|
this.api.data(
|
||||||
|
params.resolved
|
||||||
|
? client.PUT('/api/comments/{commentId}/resolution', {
|
||||||
|
params: { path },
|
||||||
|
body: { conclusion: params.conclusion },
|
||||||
|
})
|
||||||
|
: client.DELETE('/api/comments/{commentId}/resolution', {
|
||||||
|
params: { path },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Tool({
|
@Tool({
|
||||||
name: 'search_decisions',
|
name: 'search_decisions',
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
## Qué resuelve
|
## Qué resuelve
|
||||||
|
|
||||||
| Necesidad | Cómo se resuelve |
|
| Necesidad | Cómo se resuelve |
|
||||||
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas |
|
| 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas |
|
||||||
| 🌍 Públicos | Un proyecto público se puede leer sin iniciar sesión (web, GraphQL y REST): tablero, tickets, comentarios, releases, actividad y estadísticas; «Explorar» los lista |
|
| 🌍 Públicos | Un proyecto público se puede leer sin iniciar sesión (web, GraphQL y REST): tablero, tickets, comentarios, releases, actividad y estadísticas; «Explorar» los lista |
|
||||||
| 🗑️ Borrar | El propietario borra un proyecto confirmando su clave; tickets, comentarios, releases y enlaces desde otros proyectos se borran en cascada (sagas) y la clave queda libre |
|
| 🗑️ Borrar | El propietario borra un proyecto confirmando su clave; tickets, comentarios, releases y enlaces desde otros proyectos se borran en cascada (sagas) y la clave queda libre |
|
||||||
@@ -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 formato (editor visual, guardado en Markdown), capturas pegadas, respuestas, reacciones (👍 🎉…), decisiones (un comentario marcado sale encima del ticket y se encuentra al buscar) y `@menciones`; el autor edita, el admin modera |
|
| 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con formato (editor visual, guardado en Markdown), capturas pegadas, respuestas, reacciones (👍 🎉…), decisiones (un comentario marcado sale encima del ticket y se encuentra al buscar), conversaciones que se resuelven con su conclusión, «está escribiendo…» en vivo 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 |
|
||||||
| 📋 Lista | Todos los tickets en filas: ordenar por cualquier columna, filtrar, cambiar estado, prioridad o persona sin abrirlos, editar en bloque y duplicar |
|
| 📋 Lista | Todos los tickets en filas: ordenar por cualquier columna, filtrar, cambiar estado, prioridad o persona sin abrirlos, editar en bloque y duplicar |
|
||||||
| ↩️ Deshacer | Mover, reasignar o mandar a la papelera ofrece «Deshacer» unos segundos (también Ctrl/⌘ Z), con la orden contraria |
|
| ↩️ Deshacer | Mover, reasignar o mandar a la papelera ofrece «Deshacer» unos segundos (también Ctrl/⌘ Z), con la orden contraria |
|
||||||
|
|||||||
@@ -239,6 +239,13 @@ stateDiagram-v2
|
|||||||
- **Reacciones**: 👍 👎 😄 🎉 😕 ❤️ 🚀 👀 en los comentarios (`reactToComment(commentId, emoji, on)`,
|
- **Reacciones**: 👍 👎 😄 🎉 😕 ❤️ 🚀 👀 en los comentarios (`reactToComment(commentId, emoji, on)`,
|
||||||
`POST /api/comments/{id}/reactions`); los miembros, también los lectores. Cada comentario da `reactions` con quién
|
`POST /api/comments/{id}/reactions`); los miembros, también los lectores. Cada comentario da `reactions` con quién
|
||||||
eligió cada una. No generan actividad ni avisos.
|
eligió cada una. No generan actividad ni avisos.
|
||||||
|
- **Conversaciones**: un comentario raíz y sus respuestas se resuelven (`resolveThread(commentId, resolved, conclusion)`,
|
||||||
|
`PUT/DELETE /api/comments/{id}/resolution`; evento `CommentThreadResolvedEvent`) por quien puede cambiar el ticket;
|
||||||
|
una respuesta no se resuelve sola. Si trae `conclusion`, antes de cerrarla se añade como respuesta marcada como
|
||||||
|
decisión. El comentario da `resolved`, `resolvedBy` y `resolvedAt`. **Escribiendo**: `typing(issueId, threadId)`
|
||||||
|
(mutation, cada pocos segundos mientras se escribe) avisa al momento por la subscription `typing(issueId)` a quien
|
||||||
|
tenga el ticket abierto; no se guarda (canal de Redis `tracker.typing.<ticket>`), y cada cliente descarta sus propios
|
||||||
|
avisos.
|
||||||
- **Decisiones**: quien puede cambiar el ticket marca un comentario como decisión (`markDecision(commentId, on)`,
|
- **Decisiones**: quien puede cambiar el ticket marca un comentario como decisión (`markDecision(commentId, on)`,
|
||||||
`PUT/DELETE /api/comments/{id}/decision`; evento `CommentDecisionMarkedEvent`). El comentario da `decision`,
|
`PUT/DELETE /api/comments/{id}/decision`; evento `CommentDecisionMarkedEvent`). El comentario da `decision`,
|
||||||
`decidedBy` y `decidedAt`; el ticket, `decisions` (más antiguas primero, `GET /api/issues/{issue}/decisions`), que la
|
`decidedBy` y `decidedAt`; el ticket, `decisions` (más antiguas primero, `GET /api/issues/{issue}/decisions`), que la
|
||||||
@@ -536,7 +543,7 @@ Todas devuelven `CommandResult { id, key, version }`.
|
|||||||
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
|
| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
|
||||||
| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `rankIssue`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `pinIssue`, `unpinIssue` (devuelven `true`), `removeAttachment` (subir: por REST), `duplicateIssue`, `voteIssue` |
|
| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `rankIssue`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `pinIssue`, `unpinIssue` (devuelven `true`), `removeAttachment` (subir: por REST), `duplicateIssue`, `voteIssue` |
|
||||||
| Comentarios | `addComment(replyTo)`, `editComment`, `deleteComment`, `reactToComment`, `markDecision(commentId, on)`; consulta `decisions(projectId, search)` |
|
| Comentarios | `addComment(replyTo)`, `editComment`, `deleteComment`, `reactToComment`, `markDecision(commentId, on)`, `resolveThread(commentId, resolved, conclusion)`, `typing(issueId, threadId)`; consulta `decisions(projectId, search)` |
|
||||||
| Webhooks | `createWebhook` (devuelve el secreto), `updateWebhook`, `deleteWebhook`, `testWebhook` |
|
| Webhooks | `createWebhook` (devuelve el secreto), `updateWebhook`, `deleteWebhook`, `testWebhook` |
|
||||||
| Git | `addGitRepository` (devuelve su secreto), `rotateGitRepositorySecret`, `removeGitRepository` |
|
| Git | `addGitRepository` (devuelve su secreto), `rotateGitRepositorySecret`, `removeGitRepository` |
|
||||||
| Sprints | `createSprint`, `updateSprint`, `startSprint`, `closeSprint(carryOverToSprintId)`, `deleteSprint`, `setIssueSprint` |
|
| Sprints | `createSprint`, `updateSprint`, `startSprint`, `closeSprint(carryOverToSprintId)`, `deleteSprint`, `setIssueSprint` |
|
||||||
@@ -547,9 +554,10 @@ 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, o el que queda sin texto si tenía respuestas) |
|
| `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) |
|
||||||
|
| `typing(issueId)` | Alguien escribe en el ticket (`threadId`: la conversación que responde; `null`, un comentario nuevo). Llegan también los propios |
|
||||||
| `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) |
|
||||||
@@ -744,7 +752,7 @@ Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
|
|||||||
| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` |
|
| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` |
|
||||||
| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `POST …/archive`, `POST …/unarchive`, `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/insights`, `…/activity`, `…/members` (POST, PATCH, DELETE) |
|
| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `POST …/archive`, `POST …/unarchive`, `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/insights`, `…/activity`, `…/members` (POST, PATCH, DELETE) |
|
||||||
| Tickets | `GET /api/me/home` (portada), `GET /api/me/portfolio` (cartera), `GET /api/me/issues` (mi trabajo; filtros `project`, `priority`, `bugsOnly`, `overdue`), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `GET /api/me/pins` y `PUT/DELETE …/pin` (fijados), `PUT/DELETE …/presence`, `PUT …/parent`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history`, `POST /api/issues/{issue}/duplicate`, `PUT/DELETE …/vote` |
|
| Tickets | `GET /api/me/home` (portada), `GET /api/me/portfolio` (cartera), `GET /api/me/issues` (mi trabajo; filtros `project`, `priority`, `bugsOnly`, `overdue`), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `GET /api/me/pins` y `PUT/DELETE …/pin` (fijados), `PUT/DELETE …/presence`, `PUT …/parent`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history`, `POST /api/issues/{issue}/duplicate`, `PUT/DELETE …/vote` |
|
||||||
| Comentarios | `PATCH/DELETE /api/comments/{commentId}`, `POST …/reactions`, `PUT/DELETE …/decision`; `GET /api/issues/{issue}/decisions`, `GET /api/projects/{project}/decisions`, `GET /api/me/decisions` |
|
| Comentarios | `PATCH/DELETE /api/comments/{commentId}`, `POST …/reactions`, `PUT/DELETE …/decision`, `PUT/DELETE …/resolution`; `GET /api/issues/{issue}/decisions`, `GET /api/projects/{project}/decisions`, `GET /api/me/decisions` |
|
||||||
| Sprints | `GET/POST /api/projects/{project}/sprints`, `GET/PATCH/DELETE /api/sprints/{sprint}`, `POST …/start`, `POST …/close`, `GET …/progress`, `…/burndown`, `…/issues`, `PUT /api/issues/{issue}/sprint` |
|
| Sprints | `GET/POST /api/projects/{project}/sprints`, `GET/PATCH/DELETE /api/sprints/{sprint}`, `POST …/start`, `POST …/close`, `GET …/progress`, `…/burndown`, `…/issues`, `PUT /api/issues/{issue}/sprint` |
|
||||||
| Vistas | `GET/POST /api/projects/{project}/filters`, `DELETE /api/filters/{id}` |
|
| Vistas | `GET/POST /api/projects/{project}/filters`, `DELETE /api/filters/{id}` |
|
||||||
| Avisos | `GET /api/me/notifications`, `POST /api/me/notifications/read`, `GET/PUT /api/me/notification-settings` |
|
| Avisos | `GET /api/me/notifications`, `POST /api/me/notifications/read`, `GET/PUT /api/me/notification-settings` |
|
||||||
|
|||||||
@@ -308,7 +308,7 @@ visualización de la plataforma:
|
|||||||
| `/portfolio` | Cartera (con sesión): una tarjeta por proyecto activo con lo abierto y en riesgo, el sprint en curso (¿llega?), las próximas releases con su previsión y las épicas abiertas; en directo: se vuelve a leer sola cuando cambia un ticket, sprint o release de esos proyectos |
|
| `/portfolio` | Cartera (con sesión): una tarjeta por proyecto activo con lo abierto y en riesgo, el sprint en curso (¿llega?), las próximas releases con su previsión y las épicas abiertas; en directo: se vuelve a leer sola cuando cambia un ticket, sprint o release de esos proyectos |
|
||||||
| `/explore` | Proyectos públicos (sin sesión) |
|
| `/explore` | Proyectos públicos (sin sesión) |
|
||||||
| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros). Quien puede contribuir cambia prioridad, estimación y persona desde la propia tarjeta (selectores nativos sobre sus distintivos; el título es el enlace). Mover una tarjeta o cambiar su persona ofrece «Deshacer» unos segundos (o Ctrl/⌘ Z): la orden contraria, que el flujo puede rechazar |
|
| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros). Quien puede contribuir cambia prioridad, estimación y persona desde la propia tarjeta (selectores nativos sobre sus distintivos; el título es el enlace). Mover una tarjeta o cambiar su persona ofrece «Deshacer» unos segundos (o Ctrl/⌘ Z): la orden contraria, que el flujo puede rechazar |
|
||||||
| `/p/:key/issues/:issueKey` | Ticket. Encima del título, quién más lo está viendo o editando ahora (`PresenceFacade`: late cada 10 s; editar es tener abierto el formulario del ticket o de un comentario, o estar escribiendo uno). «Fijar» lo deja a mano en el inicio y en la paleta de búsqueda. Arriba, sus decisiones (comentarios marcados con la bandera por quien puede cambiar el ticket); `?comment=<id>` lleva a un comentario y lo señala. Cambiar el estado o la persona y mandarlo a la papelera (sin confirmación) ofrecen «Deshacer» |
|
| `/p/:key/issues/:issueKey` | Ticket. Encima del título, quién más lo está viendo o editando ahora (`PresenceFacade`: late cada 10 s; editar es tener abierto el formulario del ticket o de un comentario, o estar escribiendo uno). «Fijar» lo deja a mano en el inicio y en la paleta de búsqueda. Arriba, sus decisiones (comentarios marcados con la bandera por quien puede cambiar el ticket); `?comment=<id>` lleva a un comentario y lo señala. Cambiar el estado o la persona y mandarlo a la papelera (sin confirmación) ofrecen «Deshacer». Cada conversación con respuestas se resuelve («✓ Resolver», con una conclusión opcional que queda como decisión) y se pliega en una línea; la cabecera cuenta las abiertas y terminar el ticket con alguna pregunta antes. Se ve quién está escribiendo en cada conversación («Ana está escribiendo…», `PresenceFacade.typists`), y en las respuestas Enter envía (Shift+Enter, otra línea) |
|
||||||
| `/p/:key/releases` | Releases |
|
| `/p/:key/releases` | Releases |
|
||||||
| `/p/:key/report/:kind/:id` | Informe para imprimir de un sprint (`sprint`) o una release (`release`): datos, totales, notas y tickets por etapa. «Imprimir o guardar en PDF» abre el diálogo del navegador; al imprimir se quitan la cabecera, las pestañas y los fondos (`styles.scss`) |
|
| `/p/:key/report/:kind/:id` | Informe para imprimir de un sprint (`sprint`) o una release (`release`): datos, totales, notas y tickets por etapa. «Imprimir o guardar en PDF» abre el diálogo del navegador; al imprimir se quitan la cabecera, las pestañas y los fondos (`styles.scss`) |
|
||||||
| `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea |
|
| `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea |
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ flowchart LR
|
|||||||
| | `duplicate_issue` | Crear otro ticket con lo mismo (sin estado, persona ni comentarios) |
|
| | `duplicate_issue` | Crear otro ticket con lo mismo (sin estado, persona ni comentarios) |
|
||||||
| | `react_to_comment` | Reaccionar a un comentario (👍 🎉…) o quitar la reacción |
|
| | `react_to_comment` | Reaccionar a un comentario (👍 🎉…) o quitar la reacción |
|
||||||
| | `mark_decision` | Marcar un comentario como decisión del ticket, o quitarla |
|
| | `mark_decision` | Marcar un comentario como decisión del ticket, o quitarla |
|
||||||
|
| | `resolve_thread` | Resolver una conversación (con conclusión opcional, que queda como decisión) o reabrirla |
|
||||||
| | `search_decisions` | Qué se decidió: decisiones por su texto, de un proyecto o de todos los míos |
|
| | `search_decisions` | Qué se decidió: decisiones por su texto, de un proyecto o de todos los míos |
|
||||||
| | `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 |
|
||||||
| Organización | `list_epics` | Épicas con el progreso de lo que cuelga de cada una |
|
| Organización | `list_epics` | Épicas con el progreso de lo que cuelga de cada una |
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
ProjectChange,
|
ProjectChange,
|
||||||
ReleaseChange,
|
ReleaseChange,
|
||||||
SprintChange,
|
SprintChange,
|
||||||
|
Typing,
|
||||||
} from './ports/change-feed';
|
} from './ports/change-feed';
|
||||||
import { CanViewProjectQuery } from './queries/can-view-project.query';
|
import { CanViewProjectQuery } from './queries/can-view-project.query';
|
||||||
import { GetIssueQuery } from './queries/get-issue.query';
|
import { GetIssueQuery } from './queries/get-issue.query';
|
||||||
@@ -53,6 +54,15 @@ export class ChangesService {
|
|||||||
return this.changeFeed.comments(issueId);
|
return this.changeFeed.comments(issueId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Who is writing on the ticket (the client leaves itself out). */
|
||||||
|
async watchTyping(
|
||||||
|
viewerId: string,
|
||||||
|
issueId: string,
|
||||||
|
): Promise<AsyncIterableIterator<Typing>> {
|
||||||
|
await this.queryBus.execute(new GetIssueQuery(viewerId, issueId)); // access check
|
||||||
|
return this.changeFeed.typing(issueId);
|
||||||
|
}
|
||||||
|
|
||||||
async watchActivity(
|
async watchActivity(
|
||||||
viewerId: string,
|
viewerId: string,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ReactToCommentCommandHandler } from './react-to-comment.command-handler';
|
import { ReactToCommentCommandHandler } from './react-to-comment.command-handler';
|
||||||
import { MarkDecisionCommandHandler } from './mark-decision.command-handler';
|
import { MarkDecisionCommandHandler } from './mark-decision.command-handler';
|
||||||
|
import { ResolveThreadCommandHandler } from './resolve-thread.command-handler';
|
||||||
import { VoteIssueCommandHandler } from './vote-issue.command-handler';
|
import { VoteIssueCommandHandler } from './vote-issue.command-handler';
|
||||||
import { CreateProjectCommandHandler } from './create-project.command-handler';
|
import { CreateProjectCommandHandler } from './create-project.command-handler';
|
||||||
import { UpdateProjectCommandHandler } from './update-project.command-handler';
|
import { UpdateProjectCommandHandler } from './update-project.command-handler';
|
||||||
@@ -48,6 +49,7 @@ export const COMMAND_HANDLERS = [
|
|||||||
VoteIssueCommandHandler,
|
VoteIssueCommandHandler,
|
||||||
ReactToCommentCommandHandler,
|
ReactToCommentCommandHandler,
|
||||||
MarkDecisionCommandHandler,
|
MarkDecisionCommandHandler,
|
||||||
|
ResolveThreadCommandHandler,
|
||||||
CreateProjectCommandHandler,
|
CreateProjectCommandHandler,
|
||||||
UpdateProjectCommandHandler,
|
UpdateProjectCommandHandler,
|
||||||
UpdateProjectSettingsCommandHandler,
|
UpdateProjectSettingsCommandHandler,
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ import { AttachmentStorage } from '../ports/attachment-storage';
|
|||||||
import { AddCommentCommand } from './add-comment.command';
|
import { AddCommentCommand } from './add-comment.command';
|
||||||
import { ReactToCommentCommand } from './react-to-comment.command';
|
import { ReactToCommentCommand } from './react-to-comment.command';
|
||||||
import { MarkDecisionCommand } from './mark-decision.command';
|
import { MarkDecisionCommand } from './mark-decision.command';
|
||||||
|
import { ResolveThreadCommand } from './resolve-thread.command';
|
||||||
import { UpdateProjectCommand } from './update-project.command';
|
import { UpdateProjectCommand } from './update-project.command';
|
||||||
import { VoteIssueCommand } from './vote-issue.command';
|
import { VoteIssueCommand } from './vote-issue.command';
|
||||||
import { AddProjectMemberCommand } from './add-project-member.command';
|
import { AddProjectMemberCommand } from './add-project-member.command';
|
||||||
@@ -943,6 +944,51 @@ describe('tracker commands (event sourced)', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('settles a conversation, keeping its conclusion as a decision', async () => {
|
||||||
|
const { run, rehydrator, store } = await setup();
|
||||||
|
const projectId = await projectWithTeam(run);
|
||||||
|
const issue = await run(
|
||||||
|
new CreateIssueCommand('dev', projectId, IssueType.TASK, 'Pagos', {}),
|
||||||
|
);
|
||||||
|
const thread = await run(
|
||||||
|
new AddCommentCommand('qa', issue.id, '¿Stripe o Redsys?'),
|
||||||
|
);
|
||||||
|
const reply = await run(
|
||||||
|
new AddCommentCommand('dev', issue.id, 'Stripe', thread.id),
|
||||||
|
);
|
||||||
|
// A reply is not a conversation; a viewer does not settle one.
|
||||||
|
await expect(
|
||||||
|
run(new ResolveThreadCommand('dev', reply.id, true)),
|
||||||
|
).rejects.toThrow(BusinessRuleError);
|
||||||
|
await expect(
|
||||||
|
run(new ResolveThreadCommand('pm', thread.id, true)),
|
||||||
|
).rejects.toThrow(ForbiddenError);
|
||||||
|
|
||||||
|
await run(
|
||||||
|
new ResolveThreadCommand('dev', thread.id, true, ' Vamos con Stripe '),
|
||||||
|
);
|
||||||
|
expect((await rehydrator.rehydrate(thread.id, Comment)).resolved).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const conclusion = store.events.find(
|
||||||
|
(e) =>
|
||||||
|
e.type === 'CommentAddedEvent' &&
|
||||||
|
(e.data as { comment: { body: string } }).comment.body ===
|
||||||
|
'Vamos con Stripe',
|
||||||
|
);
|
||||||
|
expect(conclusion).toBeDefined();
|
||||||
|
const added = await rehydrator.rehydrate(
|
||||||
|
conclusion?.streamId ?? '',
|
||||||
|
Comment,
|
||||||
|
);
|
||||||
|
expect(added).toMatchObject({ replyTo: thread.id, decision: true });
|
||||||
|
|
||||||
|
await run(new ResolveThreadCommand('owner', thread.id, false));
|
||||||
|
expect((await rehydrator.rehydrate(thread.id, Comment)).resolved).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('takes votes from members, and from anyone once it is public', async () => {
|
it('takes votes from members, and from anyone once it is public', async () => {
|
||||||
const { run, rehydrator } = await setup();
|
const { run, rehydrator } = await setup();
|
||||||
const projectId = await projectWithTeam(run);
|
const projectId = await projectWithTeam(run);
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs';
|
||||||
|
import { BusinessRuleError } from '@task-stream/api/shared/domain';
|
||||||
|
import { CommentFactory } from '@task-stream/api/tracker/domain';
|
||||||
|
import { CommandResult, ack } from './command-result';
|
||||||
|
import { ResolveThreadCommand } from './resolve-thread.command';
|
||||||
|
import { WriteAccess } from './write-access';
|
||||||
|
|
||||||
|
/** The team settles its conversations: whoever can change the ticket. */
|
||||||
|
@CommandHandler(ResolveThreadCommand)
|
||||||
|
export class ResolveThreadCommandHandler
|
||||||
|
implements ICommandHandler<ResolveThreadCommand>
|
||||||
|
{
|
||||||
|
constructor(
|
||||||
|
private readonly access: WriteAccess,
|
||||||
|
private readonly commentFactory: CommentFactory,
|
||||||
|
private readonly eventPublisher: EventPublisher,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(command: ResolveThreadCommand): Promise<CommandResult> {
|
||||||
|
const thread = await this.access.comment(command.commentId);
|
||||||
|
if (thread.replyTo)
|
||||||
|
throw new BusinessRuleError(
|
||||||
|
'Only a conversation is resolved, not a reply',
|
||||||
|
);
|
||||||
|
await this.access.issueForContributor(thread.issueId, command.actorId);
|
||||||
|
const conclusion = command.conclusion?.trim();
|
||||||
|
if (command.resolved && conclusion && !thread.resolved) {
|
||||||
|
// What was settled stays as a decision, shown above the ticket.
|
||||||
|
const reply = this.commentFactory.create({
|
||||||
|
issueId: thread.issueId,
|
||||||
|
projectId: thread.projectId,
|
||||||
|
authorId: command.actorId,
|
||||||
|
body: conclusion,
|
||||||
|
replyTo: thread.id,
|
||||||
|
});
|
||||||
|
reply.markDecision(command.actorId, true);
|
||||||
|
this.eventPublisher.mergeObjectContext(reply);
|
||||||
|
await reply.commitAndWait();
|
||||||
|
}
|
||||||
|
thread.resolveThread(command.actorId, command.resolved);
|
||||||
|
await thread.commitAndWait();
|
||||||
|
return ack(thread);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Command } from '@nestjs/cqrs';
|
||||||
|
import { CommandResult } from './command-result';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settles a conversation (`resolved`) or reopens it. A conclusion, if
|
||||||
|
* given, is added as a reply marked as a decision before it closes.
|
||||||
|
*/
|
||||||
|
export class ResolveThreadCommand extends Command<CommandResult> {
|
||||||
|
constructor(
|
||||||
|
public readonly actorId: string,
|
||||||
|
public readonly commentId: string,
|
||||||
|
public readonly resolved: boolean,
|
||||||
|
public readonly conclusion: string | null = null,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import { DeleteCommentCommand } from './commands/delete-comment.command';
|
|||||||
import { EditCommentCommand } from './commands/edit-comment.command';
|
import { EditCommentCommand } from './commands/edit-comment.command';
|
||||||
import { ReactToCommentCommand } from './commands/react-to-comment.command';
|
import { ReactToCommentCommand } from './commands/react-to-comment.command';
|
||||||
import { MarkDecisionCommand } from './commands/mark-decision.command';
|
import { MarkDecisionCommand } from './commands/mark-decision.command';
|
||||||
|
import { ResolveThreadCommand } from './commands/resolve-thread.command';
|
||||||
import { Page } from './ports/find-issues.repository';
|
import { Page } from './ports/find-issues.repository';
|
||||||
import { GetCommentsQuery } from './queries/get-comments.query';
|
import { GetCommentsQuery } from './queries/get-comments.query';
|
||||||
import {
|
import {
|
||||||
@@ -69,6 +70,21 @@ export class CommentsService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settles a conversation or reopens it; a conclusion stays as a reply
|
||||||
|
* marked as a decision.
|
||||||
|
*/
|
||||||
|
resolveThread(
|
||||||
|
actorId: string,
|
||||||
|
commentId: string,
|
||||||
|
resolved: boolean,
|
||||||
|
conclusion: string | null = null,
|
||||||
|
): Promise<CommandResult> {
|
||||||
|
return this.commands.execute(
|
||||||
|
new ResolveThreadCommand(actorId, commentId, resolved, conclusion),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** A ticket's decisions, oldest first. */
|
/** A ticket's decisions, oldest first. */
|
||||||
decisions(
|
decisions(
|
||||||
viewerId: string | null,
|
viewerId: string | null,
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ export class CommentAddedEventHandler
|
|||||||
decision: false,
|
decision: false,
|
||||||
decidedBy: null,
|
decidedBy: null,
|
||||||
decidedAt: null,
|
decidedAt: null,
|
||||||
|
resolved: false,
|
||||||
|
resolvedBy: null,
|
||||||
|
resolvedAt: null,
|
||||||
createdAt: comment.createdAt,
|
createdAt: comment.createdAt,
|
||||||
editedAt: null,
|
editedAt: null,
|
||||||
version: versionOf(event),
|
version: versionOf(event),
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||||
|
import { CommentThreadResolvedEvent } from '@task-stream/api/tracker/domain';
|
||||||
|
import { UpsertMaterializedCommentRepository } from '../ports/upsert-materialized-comment.repository';
|
||||||
|
import { UserDirectory } from '../ports/user-directory';
|
||||||
|
import { ReadModelNotifier, versionOf } from './read-model-notifier';
|
||||||
|
|
||||||
|
@EventsHandler(CommentThreadResolvedEvent)
|
||||||
|
export class CommentThreadResolvedEventHandler
|
||||||
|
implements IEventHandler<CommentThreadResolvedEvent>
|
||||||
|
{
|
||||||
|
constructor(
|
||||||
|
private readonly upsertMaterializedCommentRepository: UpsertMaterializedCommentRepository,
|
||||||
|
private readonly users: UserDirectory,
|
||||||
|
private readonly notifier: ReadModelNotifier,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async handle(event: CommentThreadResolvedEvent) {
|
||||||
|
const refs = await this.users.refs([event.actorId]);
|
||||||
|
await this.upsertMaterializedCommentRepository.upsert({
|
||||||
|
id: event.aggregateId,
|
||||||
|
resolved: event.resolved,
|
||||||
|
resolvedBy: event.resolved ? refs.of(event.actorId) : null,
|
||||||
|
resolvedAt: event.resolved ? event.occurredAt : null,
|
||||||
|
version: versionOf(event),
|
||||||
|
});
|
||||||
|
await this.notifier.comment(event.aggregateId, event.issueId, event);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { CommentDeletedEventHandler } from './comment-deleted.event-handler';
|
|||||||
import { CommentEditedEventHandler } from './comment-edited.event-handler';
|
import { CommentEditedEventHandler } from './comment-edited.event-handler';
|
||||||
import { CommentReactedEventHandler } from './comment-reacted.event-handler';
|
import { CommentReactedEventHandler } from './comment-reacted.event-handler';
|
||||||
import { CommentDecisionMarkedEventHandler } from './comment-decision-marked.event-handler';
|
import { CommentDecisionMarkedEventHandler } from './comment-decision-marked.event-handler';
|
||||||
|
import { CommentThreadResolvedEventHandler } from './comment-thread-resolved.event-handler';
|
||||||
import { IssueVotedEventHandler } from './issue-voted.event-handler';
|
import { IssueVotedEventHandler } from './issue-voted.event-handler';
|
||||||
import { IssueAssignedEventHandler } from './issue-assigned.event-handler';
|
import { IssueAssignedEventHandler } from './issue-assigned.event-handler';
|
||||||
import { IssueCreatedEventHandler } from './issue-created.event-handler';
|
import { IssueCreatedEventHandler } from './issue-created.event-handler';
|
||||||
@@ -52,6 +53,7 @@ export const EVENT_HANDLERS = [
|
|||||||
CommentEditedEventHandler,
|
CommentEditedEventHandler,
|
||||||
CommentReactedEventHandler,
|
CommentReactedEventHandler,
|
||||||
CommentDecisionMarkedEventHandler,
|
CommentDecisionMarkedEventHandler,
|
||||||
|
CommentThreadResolvedEventHandler,
|
||||||
IssueVotedEventHandler,
|
IssueVotedEventHandler,
|
||||||
IssueAssignedEventHandler,
|
IssueAssignedEventHandler,
|
||||||
IssueCreatedEventHandler,
|
IssueCreatedEventHandler,
|
||||||
|
|||||||
@@ -46,6 +46,15 @@ export interface ProjectChange extends Change {
|
|||||||
* Live stream of read-model changes: event handlers publish once the read
|
* Live stream of read-model changes: event handlers publish once the read
|
||||||
* model is up to date, GraphQL subscriptions watch.
|
* model is up to date, GraphQL subscriptions watch.
|
||||||
*/
|
*/
|
||||||
|
/** Someone is writing on a ticket: never stored, gone in a few seconds. */
|
||||||
|
export interface Typing {
|
||||||
|
issueId: string;
|
||||||
|
user: { id: string; name: string };
|
||||||
|
/** The conversation being answered; null: a new comment. */
|
||||||
|
threadId: string | null;
|
||||||
|
at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export abstract class ChangeFeed {
|
export abstract class ChangeFeed {
|
||||||
abstract publishIssue(change: IssueChange): Promise<void>;
|
abstract publishIssue(change: IssueChange): Promise<void>;
|
||||||
abstract publishComment(change: CommentChange): Promise<void>;
|
abstract publishComment(change: CommentChange): Promise<void>;
|
||||||
@@ -64,6 +73,8 @@ export abstract class ChangeFeed {
|
|||||||
|
|
||||||
abstract issues(projectId: string): AsyncIterableIterator<IssueChange>;
|
abstract issues(projectId: string): AsyncIterableIterator<IssueChange>;
|
||||||
abstract comments(issueId: string): AsyncIterableIterator<CommentChange>;
|
abstract comments(issueId: string): AsyncIterableIterator<CommentChange>;
|
||||||
|
abstract publishTyping(typing: Typing): Promise<void>;
|
||||||
|
abstract typing(issueId: string): AsyncIterableIterator<Typing>;
|
||||||
abstract activity(
|
abstract activity(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
): AsyncIterableIterator<ActivityReadModel>;
|
): AsyncIterableIterator<ActivityReadModel>;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ForbiddenError, NotFoundError } from '@task-stream/api/shared/domain';
|
import { ForbiddenError, NotFoundError } from '@task-stream/api/shared/domain';
|
||||||
import { IssueReadModel } from '@task-stream/api/tracker/domain';
|
import { IssueReadModel } from '@task-stream/api/tracker/domain';
|
||||||
|
import { ChangeFeed, Typing } from './ports/change-feed';
|
||||||
import { FindIssuesRepository } from './ports/find-issues.repository';
|
import { FindIssuesRepository } from './ports/find-issues.repository';
|
||||||
import { PresenceEntry, PresenceStore } from './ports/presence.store';
|
import { PresenceEntry, PresenceStore } from './ports/presence.store';
|
||||||
import { PRESENCE_TTL_MS, PresenceService } from './presence.service';
|
import { PRESENCE_TTL_MS, PresenceService } from './presence.service';
|
||||||
@@ -40,10 +41,23 @@ const access = {
|
|||||||
},
|
},
|
||||||
} as unknown as ReadAccess;
|
} as unknown as ReadAccess;
|
||||||
|
|
||||||
|
/** What would go out to the ticket's open pages. */
|
||||||
|
const feed = () => {
|
||||||
|
const published: Typing[] = [];
|
||||||
|
return {
|
||||||
|
published,
|
||||||
|
feed: {
|
||||||
|
publishTyping: async (typing: Typing) => {
|
||||||
|
published.push(typing);
|
||||||
|
},
|
||||||
|
} as unknown as ChangeFeed,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
describe('PresenceService', () => {
|
describe('PresenceService', () => {
|
||||||
it('tells who else is on a ticket, the ones editing first', async () => {
|
it('tells who else is on a ticket, the ones editing first', async () => {
|
||||||
const store = new MemoryPresence();
|
const store = new MemoryPresence();
|
||||||
const presence = new PresenceService(store, issues, access);
|
const presence = new PresenceService(store, issues, access, feed().feed);
|
||||||
await presence.touch({ id: 'ana', name: 'Ana' }, 'i1', false);
|
await presence.touch({ id: 'ana', name: 'Ana' }, 'i1', false);
|
||||||
await presence.touch({ id: 'cris', name: 'Cris' }, 'i1', true);
|
await presence.touch({ id: 'cris', name: 'Cris' }, 'i1', true);
|
||||||
const seen = await presence.touch({ id: 'bob', name: 'Bob' }, 'i1', false);
|
const seen = await presence.touch({ id: 'bob', name: 'Bob' }, 'i1', false);
|
||||||
@@ -62,8 +76,33 @@ describe('PresenceService', () => {
|
|||||||
).toEqual([]);
|
).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('tells at once who is writing, and where, to whoever can read it', async () => {
|
||||||
|
const { published, feed: changeFeed } = feed();
|
||||||
|
const presence = new PresenceService(
|
||||||
|
new MemoryPresence(),
|
||||||
|
issues,
|
||||||
|
access,
|
||||||
|
changeFeed,
|
||||||
|
);
|
||||||
|
await presence.typing({ id: 'ana', name: 'Ana' }, 'i1', 'c1');
|
||||||
|
await presence.typing({ id: 'bob', name: 'Bob' }, 'i1', null);
|
||||||
|
expect(published.map((t) => [t.issueId, t.user.name, t.threadId])).toEqual([
|
||||||
|
['i1', 'Ana', 'c1'],
|
||||||
|
['i1', 'Bob', null],
|
||||||
|
]);
|
||||||
|
await expect(
|
||||||
|
presence.typing({ id: 'eve', name: 'Eve' }, 'i1', null),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenError);
|
||||||
|
expect(published).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
it('only for whoever can read the ticket', async () => {
|
it('only for whoever can read the ticket', async () => {
|
||||||
const presence = new PresenceService(new MemoryPresence(), issues, access);
|
const presence = new PresenceService(
|
||||||
|
new MemoryPresence(),
|
||||||
|
issues,
|
||||||
|
access,
|
||||||
|
feed().feed,
|
||||||
|
);
|
||||||
await expect(
|
await expect(
|
||||||
presence.touch({ id: 'eve', name: 'Eve' }, 'i1', false),
|
presence.touch({ id: 'eve', name: 'Eve' }, 'i1', false),
|
||||||
).rejects.toBeInstanceOf(ForbiddenError);
|
).rejects.toBeInstanceOf(ForbiddenError);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { NotFoundError } from '@task-stream/api/shared/domain';
|
import { NotFoundError } from '@task-stream/api/shared/domain';
|
||||||
|
import { ChangeFeed } from './ports/change-feed';
|
||||||
import { FindIssuesRepository } from './ports/find-issues.repository';
|
import { FindIssuesRepository } from './ports/find-issues.repository';
|
||||||
import { PresenceEntry, PresenceStore } from './ports/presence.store';
|
import { PresenceEntry, PresenceStore } from './ports/presence.store';
|
||||||
import { ReadAccess } from './queries/read-access';
|
import { ReadAccess } from './queries/read-access';
|
||||||
@@ -17,6 +18,7 @@ export class PresenceService {
|
|||||||
private readonly store: PresenceStore,
|
private readonly store: PresenceStore,
|
||||||
private readonly findIssues: FindIssuesRepository,
|
private readonly findIssues: FindIssuesRepository,
|
||||||
private readonly access: ReadAccess,
|
private readonly access: ReadAccess,
|
||||||
|
private readonly changeFeed: ChangeFeed,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -54,6 +56,24 @@ export class PresenceService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* I am writing on the ticket (an answer to `threadId`, or a new comment):
|
||||||
|
* told at once to whoever has it open, and forgotten.
|
||||||
|
*/
|
||||||
|
async typing(
|
||||||
|
viewer: { id: string; name: string },
|
||||||
|
issueId: string,
|
||||||
|
threadId: string | null,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.assertCanView(viewer.id, issueId);
|
||||||
|
await this.changeFeed.publishTyping({
|
||||||
|
issueId,
|
||||||
|
user: { id: viewer.id, name: viewer.name },
|
||||||
|
threadId,
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** I left the ticket's page. */
|
/** I left the ticket's page. */
|
||||||
leave(viewerId: string, issueId: string): Promise<void> {
|
leave(viewerId: string, issueId: string): Promise<void> {
|
||||||
return this.store.leave(issueId, viewerId);
|
return this.store.leave(issueId, viewerId);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
} from '@task-stream/api/shared/domain';
|
} from '@task-stream/api/shared/domain';
|
||||||
import { CommentAddedEvent } from '../events/comment-added.event';
|
import { CommentAddedEvent } from '../events/comment-added.event';
|
||||||
import { CommentDecisionMarkedEvent } from '../events/comment-decision-marked.event';
|
import { CommentDecisionMarkedEvent } from '../events/comment-decision-marked.event';
|
||||||
|
import { CommentThreadResolvedEvent } from '../events/comment-thread-resolved.event';
|
||||||
import { CommentDeletedEvent } from '../events/comment-deleted.event';
|
import { CommentDeletedEvent } from '../events/comment-deleted.event';
|
||||||
import { CommentEditedEvent } from '../events/comment-edited.event';
|
import { CommentEditedEvent } from '../events/comment-edited.event';
|
||||||
import { CommentReactedEvent } from '../events/comment-reacted.event';
|
import { CommentReactedEvent } from '../events/comment-reacted.event';
|
||||||
@@ -44,6 +45,8 @@ export class Comment extends VersionedAggregateRoot implements CommentProps {
|
|||||||
public reactions: Record<string, string[]> = {};
|
public reactions: Record<string, string[]> = {};
|
||||||
/** What was decided on the ticket: shown above it and searchable. */
|
/** What was decided on the ticket: shown above it and searchable. */
|
||||||
public decision = false;
|
public decision = false;
|
||||||
|
/** A top-level comment whose conversation was settled. */
|
||||||
|
public resolved = false;
|
||||||
public deleted = false;
|
public deleted = false;
|
||||||
public createdAt!: string;
|
public createdAt!: string;
|
||||||
public editedAt: string | null = null;
|
public editedAt: string | null = null;
|
||||||
@@ -114,6 +117,28 @@ export class Comment extends VersionedAggregateRoot implements CommentProps {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settles the conversation this comment opens (`on`) or reopens it;
|
||||||
|
* repeating it changes nothing. Only a top-level comment opens one.
|
||||||
|
*/
|
||||||
|
resolveThread(actorId: string, on: boolean): void {
|
||||||
|
this.assertAlive();
|
||||||
|
if (this.replyTo)
|
||||||
|
throw new BusinessRuleError(
|
||||||
|
'Only a conversation is resolved, not a reply',
|
||||||
|
);
|
||||||
|
if (on === this.resolved) return;
|
||||||
|
this.apply(
|
||||||
|
new CommentThreadResolvedEvent(
|
||||||
|
actorId,
|
||||||
|
this.id,
|
||||||
|
this.projectId,
|
||||||
|
this.issueId,
|
||||||
|
on,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** @param canModerate whether the actor manages the project (may delete others' comments). */
|
/** @param canModerate whether the actor manages the project (may delete others' comments). */
|
||||||
delete(actorId: string, canModerate: boolean): void {
|
delete(actorId: string, canModerate: boolean): void {
|
||||||
this.assertAlive();
|
this.assertAlive();
|
||||||
@@ -146,6 +171,10 @@ export class Comment extends VersionedAggregateRoot implements CommentProps {
|
|||||||
this.decision = event.decision;
|
this.decision = event.decision;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onCommentThreadResolvedEvent(event: CommentThreadResolvedEvent): void {
|
||||||
|
this.resolved = event.resolved;
|
||||||
|
}
|
||||||
|
|
||||||
onCommentDeletedEvent(): void {
|
onCommentDeletedEvent(): void {
|
||||||
this.deleted = true;
|
this.deleted = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { AutowiredEvent } from '@task-stream/api/shared/domain';
|
||||||
|
import { TrackerEvent } from './tracker.event';
|
||||||
|
|
||||||
|
/** A conversation (a top-level comment and its replies) was settled, or reopened. */
|
||||||
|
@AutowiredEvent
|
||||||
|
export class CommentThreadResolvedEvent extends TrackerEvent {
|
||||||
|
constructor(
|
||||||
|
actorId: string,
|
||||||
|
commentId: string,
|
||||||
|
projectId: string,
|
||||||
|
readonly issueId: string,
|
||||||
|
/** False: reopened. */
|
||||||
|
readonly resolved: boolean,
|
||||||
|
) {
|
||||||
|
super(actorId, commentId, projectId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ export * from './comment-added.event';
|
|||||||
export * from './comment-edited.event';
|
export * from './comment-edited.event';
|
||||||
export * from './comment-reacted.event';
|
export * from './comment-reacted.event';
|
||||||
export * from './comment-decision-marked.event';
|
export * from './comment-decision-marked.event';
|
||||||
|
export * from './comment-thread-resolved.event';
|
||||||
export * from './comment-deleted.event';
|
export * from './comment-deleted.event';
|
||||||
export * from './release-created.event';
|
export * from './release-created.event';
|
||||||
export * from './release-updated.event';
|
export * from './release-updated.event';
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ export class CommentReadModel {
|
|||||||
/** Who marked it and when (null when it is not a decision). */
|
/** Who marked it and when (null when it is not a decision). */
|
||||||
decidedBy!: UserRefReadModel | null;
|
decidedBy!: UserRefReadModel | null;
|
||||||
decidedAt!: string | null;
|
decidedAt!: string | null;
|
||||||
|
/** A top-level comment whose conversation was settled. */
|
||||||
|
resolved!: boolean;
|
||||||
|
/** Who settled it and when (null while open). */
|
||||||
|
resolvedBy!: UserRefReadModel | null;
|
||||||
|
resolvedAt!: string | null;
|
||||||
createdAt!: string;
|
createdAt!: string;
|
||||||
editedAt!: string | null;
|
editedAt!: string | null;
|
||||||
version!: number;
|
version!: number;
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ const commentFromDoc = (doc: CommentDoc): CommentReadModel => ({
|
|||||||
decision: doc.decision ?? false,
|
decision: doc.decision ?? false,
|
||||||
decidedBy: doc.decidedBy ?? null,
|
decidedBy: doc.decidedBy ?? null,
|
||||||
decidedAt: doc.decidedAt ?? null,
|
decidedAt: doc.decidedAt ?? null,
|
||||||
|
resolved: doc.resolved ?? false,
|
||||||
|
resolvedBy: doc.resolvedBy ?? null,
|
||||||
|
resolvedAt: doc.resolvedAt ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const escapeRegex = (text: string) =>
|
const escapeRegex = (text: string) =>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
ProjectChange,
|
ProjectChange,
|
||||||
ReleaseChange,
|
ReleaseChange,
|
||||||
SprintChange,
|
SprintChange,
|
||||||
|
Typing,
|
||||||
} from '@task-stream/api/tracker/application';
|
} from '@task-stream/api/tracker/application';
|
||||||
import {
|
import {
|
||||||
ActivityReadModel,
|
ActivityReadModel,
|
||||||
@@ -18,6 +19,7 @@ import { RedisPubSub } from 'graphql-redis-subscriptions';
|
|||||||
const Channels = {
|
const Channels = {
|
||||||
issue: (projectId: string) => `tracker.issue.${projectId}`,
|
issue: (projectId: string) => `tracker.issue.${projectId}`,
|
||||||
comment: (issueId: string) => `tracker.comment.${issueId}`,
|
comment: (issueId: string) => `tracker.comment.${issueId}`,
|
||||||
|
typing: (issueId: string) => `tracker.typing.${issueId}`,
|
||||||
activity: (projectId: string) => `tracker.activity.${projectId}`,
|
activity: (projectId: string) => `tracker.activity.${projectId}`,
|
||||||
release: (projectId: string) => `tracker.release.${projectId}`,
|
release: (projectId: string) => `tracker.release.${projectId}`,
|
||||||
sprint: (projectId: string) => `tracker.sprint.${projectId}`,
|
sprint: (projectId: string) => `tracker.sprint.${projectId}`,
|
||||||
@@ -86,6 +88,14 @@ export class RedisChangeFeed extends ChangeFeed {
|
|||||||
return this.pubSub.asyncIterableIterator(Channels.comment(issueId));
|
return this.pubSub.asyncIterableIterator(Channels.comment(issueId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
publishTyping(typing: Typing): Promise<void> {
|
||||||
|
return this.pubSub.publish(Channels.typing(typing.issueId), typing);
|
||||||
|
}
|
||||||
|
|
||||||
|
typing(issueId: string): AsyncIterableIterator<Typing> {
|
||||||
|
return this.pubSub.asyncIterableIterator(Channels.typing(issueId));
|
||||||
|
}
|
||||||
|
|
||||||
activity(projectId: string): AsyncIterableIterator<ActivityReadModel> {
|
activity(projectId: string): AsyncIterableIterator<ActivityReadModel> {
|
||||||
return this.pubSub.asyncIterableIterator(Channels.activity(projectId));
|
return this.pubSub.asyncIterableIterator(Channels.activity(projectId));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,24 @@ export class CommentResolver {
|
|||||||
return this.comments.markDecision(me.id, commentId, on);
|
return this.comments.markDecision(me.id, commentId, on);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Mutation(() => CommandResultGql, {
|
||||||
|
description:
|
||||||
|
'Settle a conversation (a top-level comment and its replies) or reopen it (resolved: false). A conclusion is added as a reply marked as a decision. Whoever can change the ticket',
|
||||||
|
})
|
||||||
|
resolveThread(
|
||||||
|
@CurrentUser() me: User,
|
||||||
|
@Args('commentId', { type: () => ID }) commentId: string,
|
||||||
|
@Args('resolved', { defaultValue: true }) resolved: boolean,
|
||||||
|
@Args('conclusion', { nullable: true }) conclusion: string | null,
|
||||||
|
) {
|
||||||
|
return this.comments.resolveThread(
|
||||||
|
me.id,
|
||||||
|
commentId,
|
||||||
|
resolved,
|
||||||
|
conclusion ?? null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@OptionalAuth()
|
@OptionalAuth()
|
||||||
@Query(() => [DecisionGql], {
|
@Query(() => [DecisionGql], {
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -77,6 +77,28 @@ export class IssueResolver {
|
|||||||
return this.presence.touch({ id: me.id, name: me.name }, issueId, editing);
|
return this.presence.touch({ id: me.id, name: me.name }, issueId, editing);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Mutation(() => Boolean, {
|
||||||
|
description:
|
||||||
|
'I am writing on this ticket (send every few seconds while typing): those with it open see it at once via `typing`',
|
||||||
|
})
|
||||||
|
async typing(
|
||||||
|
@CurrentUser() me: User,
|
||||||
|
@Args('issueId', { type: () => ID }) issueId: string,
|
||||||
|
@Args('threadId', {
|
||||||
|
type: () => ID,
|
||||||
|
nullable: true,
|
||||||
|
description: 'The conversation being answered; null: a new comment',
|
||||||
|
})
|
||||||
|
threadId: string | null,
|
||||||
|
) {
|
||||||
|
await this.presence.typing(
|
||||||
|
{ id: me.id, name: me.name },
|
||||||
|
issueId,
|
||||||
|
threadId ?? null,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
@Mutation(() => Boolean, { description: "I left this ticket's page" })
|
@Mutation(() => Boolean, { description: "I left this ticket's page" })
|
||||||
async leavePresence(
|
async leavePresence(
|
||||||
@CurrentUser() me: User,
|
@CurrentUser() me: User,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
ProjectChangeGql,
|
ProjectChangeGql,
|
||||||
ReleaseChangeGql,
|
ReleaseChangeGql,
|
||||||
SprintChangeGql,
|
SprintChangeGql,
|
||||||
|
TypingGql,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
const asIs = { resolve: <T>(payload: T) => payload };
|
const asIs = { resolve: <T>(payload: T) => payload };
|
||||||
@@ -48,6 +49,18 @@ export class SubscriptionResolver {
|
|||||||
return this.changes.watchComments(me.id, issueId);
|
return this.changes.watchComments(me.id, issueId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Subscription(() => TypingGql, {
|
||||||
|
...asIs,
|
||||||
|
description:
|
||||||
|
"Who is writing on a ticket, as they type (one's own come too: leave them out)",
|
||||||
|
})
|
||||||
|
typing(
|
||||||
|
@CurrentUser() me: User,
|
||||||
|
@Args('issueId', { type: () => ID }) issueId: string,
|
||||||
|
) {
|
||||||
|
return this.changes.watchTyping(me.id, issueId);
|
||||||
|
}
|
||||||
|
|
||||||
@Subscription(() => ActivityGql, {
|
@Subscription(() => ActivityGql, {
|
||||||
...asIs,
|
...asIs,
|
||||||
description: 'Live activity feed',
|
description: 'Live activity feed',
|
||||||
|
|||||||
@@ -562,6 +562,17 @@ export class CommentGql {
|
|||||||
})
|
})
|
||||||
decidedBy!: UserRefGql | null;
|
decidedBy!: UserRefGql | null;
|
||||||
@Field(() => String, { nullable: true }) decidedAt!: string | null;
|
@Field(() => String, { nullable: true }) decidedAt!: string | null;
|
||||||
|
@Field({
|
||||||
|
description:
|
||||||
|
'A conversation (top-level comment and its replies) that was settled',
|
||||||
|
})
|
||||||
|
resolved!: boolean;
|
||||||
|
@Field(() => UserRefGql, {
|
||||||
|
nullable: true,
|
||||||
|
description: 'Who settled it',
|
||||||
|
})
|
||||||
|
resolvedBy!: UserRefGql | null;
|
||||||
|
@Field(() => String, { nullable: true }) resolvedAt!: string | null;
|
||||||
@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;
|
||||||
@@ -1157,6 +1168,20 @@ export class ProjectInsightsGql {
|
|||||||
proposals!: ProposalsFunnelGql | null;
|
proposals!: ProposalsFunnelGql | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ObjectType('Typing', {
|
||||||
|
description: 'Someone writing on a ticket right now (not stored)',
|
||||||
|
})
|
||||||
|
export class TypingGql {
|
||||||
|
@Field(() => ID) issueId!: string;
|
||||||
|
@Field(() => UserRefGql) user!: UserRefGql;
|
||||||
|
@Field(() => ID, {
|
||||||
|
nullable: true,
|
||||||
|
description: 'The conversation being answered; null: a new comment',
|
||||||
|
})
|
||||||
|
threadId!: string | null;
|
||||||
|
@Field() at!: string;
|
||||||
|
}
|
||||||
|
|
||||||
@ObjectType('Presence', {
|
@ObjectType('Presence', {
|
||||||
description: 'Someone on a ticket right now (heartbeat in the last 30 s)',
|
description: 'Someone on a ticket right now (heartbeat in the last 30 s)',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -17,7 +17,11 @@ import {
|
|||||||
import { ApiCommandErrors } from '@task-stream/api/shared/presenters';
|
import { ApiCommandErrors } from '@task-stream/api/shared/presenters';
|
||||||
import { CommentsService } from '@task-stream/api/tracker/application';
|
import { CommentsService } from '@task-stream/api/tracker/application';
|
||||||
import { REACTIONS } from '@task-stream/api/tracker/domain';
|
import { REACTIONS } from '@task-stream/api/tracker/domain';
|
||||||
import { CommentBodyDto, ReactToCommentDto } from './dto/requests.dto';
|
import {
|
||||||
|
CommentBodyDto,
|
||||||
|
ReactToCommentDto,
|
||||||
|
ResolveThreadDto,
|
||||||
|
} from './dto/requests.dto';
|
||||||
import { CommandResultDto } from './dto/responses.dto';
|
import { CommandResultDto } from './dto/responses.dto';
|
||||||
|
|
||||||
/** REST adapter of the comment use cases (listing and adding live under /issues). */
|
/** REST adapter of the comment use cases (listing and adding live under /issues). */
|
||||||
@@ -78,6 +82,35 @@ export class CommentsController {
|
|||||||
return this.comments.markDecision(me.id, commentId, false);
|
return this.comments.markDecision(me.id, commentId, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Put(':commentId/resolution')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Settle a conversation (a top-level comment and its replies)',
|
||||||
|
description:
|
||||||
|
'Whoever can change the ticket. A conclusion is added as a reply marked as a decision. The web folds a settled conversation.',
|
||||||
|
})
|
||||||
|
@ApiOkResponse({ type: CommandResultDto })
|
||||||
|
@ApiCommandErrors()
|
||||||
|
resolveThread(
|
||||||
|
@CurrentUser() me: User,
|
||||||
|
@Param('commentId') commentId: string,
|
||||||
|
@Body() body: ResolveThreadDto,
|
||||||
|
) {
|
||||||
|
return this.comments.resolveThread(
|
||||||
|
me.id,
|
||||||
|
commentId,
|
||||||
|
true,
|
||||||
|
body.conclusion ?? null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':commentId/resolution')
|
||||||
|
@ApiOperation({ summary: 'Reopen a conversation' })
|
||||||
|
@ApiOkResponse({ type: CommandResultDto })
|
||||||
|
@ApiCommandErrors()
|
||||||
|
reopenThread(@CurrentUser() me: User, @Param('commentId') commentId: string) {
|
||||||
|
return this.comments.resolveThread(me.id, commentId, false);
|
||||||
|
}
|
||||||
|
|
||||||
@Delete(':commentId')
|
@Delete(':commentId')
|
||||||
@ApiOperation({ summary: 'Delete a comment (author or project admin)' })
|
@ApiOperation({ summary: 'Delete a comment (author or project admin)' })
|
||||||
@ApiOkResponse({ type: CommandResultDto })
|
@ApiOkResponse({ type: CommandResultDto })
|
||||||
|
|||||||
@@ -1552,6 +1552,18 @@ export class PushEndpointDto {
|
|||||||
endpoint!: string;
|
endpoint!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class ResolveThreadDto {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'What was settled: added as a reply marked as a decision before it closes',
|
||||||
|
maxLength: 20000,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20000)
|
||||||
|
conclusion?: string;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Alerts → bugs ----------------------------------------------------------
|
// ---- Alerts → bugs ----------------------------------------------------------
|
||||||
|
|
||||||
export class AlertSettingsDto {
|
export class AlertSettingsDto {
|
||||||
|
|||||||
@@ -642,6 +642,19 @@ export class CommentDto {
|
|||||||
decidedBy!: UserRefDto | null;
|
decidedBy!: UserRefDto | null;
|
||||||
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
|
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
|
||||||
decidedAt!: string | null;
|
decidedAt!: string | null;
|
||||||
|
@ApiProperty({
|
||||||
|
description:
|
||||||
|
'A conversation (top-level comment and its replies) that was settled',
|
||||||
|
})
|
||||||
|
resolved!: boolean;
|
||||||
|
@ApiProperty({
|
||||||
|
type: UserRefDto,
|
||||||
|
nullable: true,
|
||||||
|
description: 'Who settled it',
|
||||||
|
})
|
||||||
|
resolvedBy!: UserRefDto | null;
|
||||||
|
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
|
||||||
|
resolvedAt!: string | null;
|
||||||
@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;
|
||||||
|
|||||||
@@ -81,6 +81,11 @@ export const issuePageEvents = eventGroup({
|
|||||||
commentEditRequested: type<{ commentId: string; body: string }>(),
|
commentEditRequested: type<{ commentId: string; body: string }>(),
|
||||||
reactRequested: type<{ commentId: string; emoji: string; on: boolean }>(),
|
reactRequested: type<{ commentId: string; emoji: string; on: boolean }>(),
|
||||||
decisionRequested: type<{ commentId: string; on: boolean }>(),
|
decisionRequested: type<{ commentId: string; on: boolean }>(),
|
||||||
|
threadResolveRequested: type<{
|
||||||
|
commentId: string;
|
||||||
|
resolved: boolean;
|
||||||
|
conclusion: string | null;
|
||||||
|
}>(),
|
||||||
voteRequested: type<{ issueId: string; on: boolean }>(),
|
voteRequested: type<{ issueId: string; on: boolean }>(),
|
||||||
/** An image pasted into a text was uploaded (it joins the ticket's files). */
|
/** An image pasted into a text was uploaded (it joins the ticket's files). */
|
||||||
imagePasted: type<{ issueId: string }>(),
|
imagePasted: type<{ issueId: string }>(),
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { type } from '@ngrx/signals';
|
import { type } from '@ngrx/signals';
|
||||||
import { eventGroup } from '@ngrx/signals/events';
|
import { eventGroup } from '@ngrx/signals/events';
|
||||||
import { Presence } from '@task-stream/web/tracker/domain';
|
import { Presence, Typing } from '@task-stream/web/tracker/domain';
|
||||||
|
|
||||||
export const presencePageEvents = eventGroup({
|
export const presencePageEvents = eventGroup({
|
||||||
source: 'Issue Presence',
|
source: 'Issue Presence',
|
||||||
events: {
|
events: {
|
||||||
opened: type<string>(),
|
opened: type<string>(),
|
||||||
editingChanged: type<boolean>(),
|
editingChanged: type<boolean>(),
|
||||||
|
/** I typed in an answer to this conversation (null: a new comment). */
|
||||||
|
typed: type<string | null>(),
|
||||||
left: type<void>(),
|
left: type<void>(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -15,5 +17,9 @@ export const presenceApiEvents = eventGroup({
|
|||||||
source: 'Presence API',
|
source: 'Presence API',
|
||||||
events: {
|
events: {
|
||||||
seen: type<Presence[]>(),
|
seen: type<Presence[]>(),
|
||||||
|
/** Someone else is writing. */
|
||||||
|
typing: type<Typing>(),
|
||||||
|
/** …and stopped a while ago (the `at` it was last seen). */
|
||||||
|
typingStopped: type<{ userId: string; at: string }>(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -231,6 +231,22 @@ export class IssueFacade {
|
|||||||
this.dispatch.reactRequested({ commentId, emoji, on });
|
this.dispatch.reactRequested({ commentId, emoji, on });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settles a conversation (or reopens it); a conclusion is added as a
|
||||||
|
* reply marked as a decision.
|
||||||
|
*/
|
||||||
|
resolveThread(
|
||||||
|
commentId: string,
|
||||||
|
resolved: boolean,
|
||||||
|
conclusion: string | null = null,
|
||||||
|
): void {
|
||||||
|
this.dispatch.threadResolveRequested({
|
||||||
|
commentId,
|
||||||
|
resolved,
|
||||||
|
conclusion: conclusion?.trim() || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Marks a comment as a decision of the ticket (`on`) or unmarks it. */
|
/** Marks a comment as a decision of the ticket (`on`) or unmarks it. */
|
||||||
markDecision(commentId: string, on: boolean): void {
|
markDecision(commentId: string, on: boolean): void {
|
||||||
this.dispatch.decisionRequested({ commentId, on });
|
this.dispatch.decisionRequested({ commentId, on });
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export class PresenceFacade {
|
|||||||
|
|
||||||
/** The others, the ones editing first. */
|
/** The others, the ones editing first. */
|
||||||
readonly others = this.store.others;
|
readonly others = this.store.others;
|
||||||
|
/** Who else is writing now, and where (`threadId` null: a new comment). */
|
||||||
|
readonly typists = this.store.typists;
|
||||||
|
|
||||||
/** I am on this ticket: beats until the page closes. */
|
/** I am on this ticket: beats until the page closes. */
|
||||||
open(issueId: string): void {
|
open(issueId: string): void {
|
||||||
@@ -20,6 +22,11 @@ export class PresenceFacade {
|
|||||||
setEditing(editing: boolean): void {
|
setEditing(editing: boolean): void {
|
||||||
this.dispatch.editingChanged(editing);
|
this.dispatch.editingChanged(editing);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** I am writing an answer to `threadId` (null: a new comment). */
|
||||||
|
typed(threadId: string | null): void {
|
||||||
|
this.dispatch.typed(threadId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Store + facade of a ticket's presence, for the ticket's page. */
|
/** Store + facade of a ticket's presence, for the ticket's page. */
|
||||||
|
|||||||
@@ -479,6 +479,26 @@ export const IssueStore = signalStore(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
events
|
||||||
|
.on(issuePageEvents.threadResolveRequested)
|
||||||
|
.pipe(
|
||||||
|
mergeMap(({ payload }) =>
|
||||||
|
runCommand(
|
||||||
|
comments.resolveThread(
|
||||||
|
payload.commentId,
|
||||||
|
payload.resolved,
|
||||||
|
payload.conclusion,
|
||||||
|
),
|
||||||
|
!payload.resolved
|
||||||
|
? 'Conversación reabierta'
|
||||||
|
: payload.conclusion
|
||||||
|
? 'Resuelta: la conclusión queda como decisión'
|
||||||
|
: 'Conversación resuelta',
|
||||||
|
done,
|
||||||
|
failed,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
events.on(issuePageEvents.reactRequested).pipe(
|
events.on(issuePageEvents.reactRequested).pipe(
|
||||||
mergeMap(({ payload }) =>
|
mergeMap(({ payload }) =>
|
||||||
comments.react(payload.commentId, payload.emoji, payload.on).pipe(
|
comments.react(payload.commentId, payload.emoji, payload.on).pipe(
|
||||||
|
|||||||
@@ -6,18 +6,26 @@ import {
|
|||||||
withEventHandlers,
|
withEventHandlers,
|
||||||
withReducer,
|
withReducer,
|
||||||
} from '@ngrx/signals/events';
|
} from '@ngrx/signals/events';
|
||||||
import { Presence, PresenceGateway } from '@task-stream/web/tracker/domain';
|
import { AuthFacade } from '@task-stream/web/identity/application';
|
||||||
|
import {
|
||||||
|
Presence,
|
||||||
|
PresenceGateway,
|
||||||
|
Typing,
|
||||||
|
} from '@task-stream/web/tracker/domain';
|
||||||
import {
|
import {
|
||||||
catchError,
|
catchError,
|
||||||
combineLatest,
|
combineLatest,
|
||||||
distinctUntilChanged,
|
distinctUntilChanged,
|
||||||
EMPTY,
|
EMPTY,
|
||||||
|
filter,
|
||||||
|
groupBy,
|
||||||
map,
|
map,
|
||||||
mergeMap,
|
mergeMap,
|
||||||
pairwise,
|
pairwise,
|
||||||
startWith,
|
startWith,
|
||||||
switchMap,
|
switchMap,
|
||||||
takeUntil,
|
takeUntil,
|
||||||
|
throttleTime,
|
||||||
timer,
|
timer,
|
||||||
} from 'rxjs';
|
} from 'rxjs';
|
||||||
import {
|
import {
|
||||||
@@ -27,10 +35,16 @@ import {
|
|||||||
|
|
||||||
/** A heartbeat every so often; the API forgets a page after 30 s without one. */
|
/** A heartbeat every so often; the API forgets a page after 30 s without one. */
|
||||||
export const PRESENCE_BEAT_MS = 10_000;
|
export const PRESENCE_BEAT_MS = 10_000;
|
||||||
|
/** While typing, I say so at most this often… */
|
||||||
|
export const TYPING_EVERY_MS = 3_000;
|
||||||
|
/** …and someone silent this long has stopped. */
|
||||||
|
export const TYPING_GONE_MS = 6_000;
|
||||||
|
|
||||||
interface PresenceState {
|
interface PresenceState {
|
||||||
issueId: string | null;
|
issueId: string | null;
|
||||||
others: Presence[];
|
others: Presence[];
|
||||||
|
/** Who else is writing now, one entry each. */
|
||||||
|
typists: Typing[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -39,17 +53,75 @@ interface PresenceState {
|
|||||||
* to the ticket's page.
|
* to the ticket's page.
|
||||||
*/
|
*/
|
||||||
export const PresenceStore = signalStore(
|
export const PresenceStore = signalStore(
|
||||||
withState<PresenceState>({ issueId: null, others: [] }),
|
withState<PresenceState>({ issueId: null, others: [], typists: [] }),
|
||||||
withReducer(
|
withReducer(
|
||||||
on(presencePageEvents.opened, ({ payload }) => ({
|
on(presencePageEvents.opened, ({ payload }) => ({
|
||||||
issueId: payload,
|
issueId: payload,
|
||||||
others: [],
|
others: [],
|
||||||
|
typists: [],
|
||||||
})),
|
})),
|
||||||
on(presenceApiEvents.seen, ({ payload }) => ({ others: payload })),
|
on(presenceApiEvents.seen, ({ payload }) => ({ others: payload })),
|
||||||
on(presencePageEvents.left, () => ({ issueId: null, others: [] })),
|
on(presenceApiEvents.typing, ({ payload }, state) => ({
|
||||||
|
typists: [
|
||||||
|
...state.typists.filter((t) => t.user.id !== payload.user.id),
|
||||||
|
payload,
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
on(presenceApiEvents.typingStopped, ({ payload }, state) => ({
|
||||||
|
typists: state.typists.filter(
|
||||||
|
(t) => !(t.user.id === payload.userId && t.at === payload.at),
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
on(presencePageEvents.left, () => ({
|
||||||
|
issueId: null,
|
||||||
|
others: [],
|
||||||
|
typists: [],
|
||||||
|
})),
|
||||||
),
|
),
|
||||||
withEventHandlers(
|
withEventHandlers(
|
||||||
(store, events = inject(Events), presence = inject(PresenceGateway)) => ({
|
(
|
||||||
|
store,
|
||||||
|
events = inject(Events),
|
||||||
|
presence = inject(PresenceGateway),
|
||||||
|
auth = inject(AuthFacade),
|
||||||
|
) => ({
|
||||||
|
// Each answer (or the new comment) tells the others at most every 3 s.
|
||||||
|
typed$: events.on(presencePageEvents.typed).pipe(
|
||||||
|
map(({ payload }) => payload),
|
||||||
|
groupBy((threadId) => threadId ?? ''),
|
||||||
|
mergeMap((thread$) => thread$.pipe(throttleTime(TYPING_EVERY_MS))),
|
||||||
|
mergeMap((threadId) => {
|
||||||
|
const issueId = store.issueId();
|
||||||
|
// Telling is a nicety: nothing to say if it fails.
|
||||||
|
return issueId
|
||||||
|
? presence.typing(issueId, threadId).pipe(catchError(() => EMPTY))
|
||||||
|
: EMPTY;
|
||||||
|
}),
|
||||||
|
switchMap(() => EMPTY),
|
||||||
|
),
|
||||||
|
typists$: events.on(presencePageEvents.opened).pipe(
|
||||||
|
switchMap(({ payload: issueId }) =>
|
||||||
|
presence.typists(issueId).pipe(
|
||||||
|
filter((typing) => typing.user.id !== auth.user()?.id),
|
||||||
|
map((typing) => presenceApiEvents.typing(typing)),
|
||||||
|
catchError(() => EMPTY),
|
||||||
|
takeUntil(events.on(presencePageEvents.left)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Silent for a while: stopped (unless a newer one came meanwhile).
|
||||||
|
typingStopped$: events.on(presenceApiEvents.typing).pipe(
|
||||||
|
mergeMap(({ payload }) =>
|
||||||
|
timer(TYPING_GONE_MS).pipe(
|
||||||
|
map(() =>
|
||||||
|
presenceApiEvents.typingStopped({
|
||||||
|
userId: payload.user.id,
|
||||||
|
at: payload.at,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
beat$: events.on(presencePageEvents.opened).pipe(
|
beat$: events.on(presencePageEvents.opened).pipe(
|
||||||
switchMap(({ payload: issueId }) =>
|
switchMap(({ payload: issueId }) =>
|
||||||
combineLatest([
|
combineLatest([
|
||||||
|
|||||||
@@ -202,6 +202,14 @@ export interface Issue extends IssueSummary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Someone else on a ticket's page right now. */
|
/** Someone else on a ticket's page right now. */
|
||||||
|
/** Someone writing on a ticket right now. */
|
||||||
|
export interface Typing {
|
||||||
|
user: UserRef;
|
||||||
|
/** The conversation being answered; null: a new comment. */
|
||||||
|
threadId: string | null;
|
||||||
|
at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Presence {
|
export interface Presence {
|
||||||
user: UserRef;
|
user: UserRef;
|
||||||
/** Editing the ticket or writing a comment. */
|
/** Editing the ticket or writing a comment. */
|
||||||
@@ -342,6 +350,10 @@ export interface Comment {
|
|||||||
decision: boolean;
|
decision: boolean;
|
||||||
/** Who marked it as a decision (null when it is not one). */
|
/** Who marked it as a decision (null when it is not one). */
|
||||||
decidedBy: UserRef | null;
|
decidedBy: UserRef | null;
|
||||||
|
/** A conversation (top-level comment and its replies) that was settled. */
|
||||||
|
resolved: boolean;
|
||||||
|
/** Who settled it (null while open). */
|
||||||
|
resolvedBy: UserRef | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
editedAt: string | null;
|
editedAt: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
AlertIntegration,
|
AlertIntegration,
|
||||||
Comment,
|
Comment,
|
||||||
Decision,
|
Decision,
|
||||||
|
Typing,
|
||||||
Epic,
|
Epic,
|
||||||
GitIntegration,
|
GitIntegration,
|
||||||
GitRepository,
|
GitRepository,
|
||||||
@@ -313,6 +314,15 @@ export abstract class CommentsGateway {
|
|||||||
on: boolean,
|
on: boolean,
|
||||||
): Observable<CommandResult>;
|
): Observable<CommandResult>;
|
||||||
abstract remove(commentId: string): Observable<CommandResult>;
|
abstract remove(commentId: string): Observable<CommandResult>;
|
||||||
|
/**
|
||||||
|
* Settles a conversation (or reopens it); a conclusion is added as a
|
||||||
|
* reply marked as a decision.
|
||||||
|
*/
|
||||||
|
abstract resolveThread(
|
||||||
|
commentId: string,
|
||||||
|
resolved: boolean,
|
||||||
|
conclusion: string | null,
|
||||||
|
): Observable<CommandResult>;
|
||||||
/** Marks it as a decision of its ticket (`on`) or unmarks it. */
|
/** Marks it as a decision of its ticket (`on`) or unmarks it. */
|
||||||
abstract markDecision(
|
abstract markDecision(
|
||||||
commentId: string,
|
commentId: string,
|
||||||
@@ -496,4 +506,8 @@ export abstract class TrackerRealtimeGateway {
|
|||||||
export abstract class PresenceGateway {
|
export abstract class PresenceGateway {
|
||||||
abstract touch(issueId: string, editing: boolean): Observable<Presence[]>;
|
abstract touch(issueId: string, editing: boolean): Observable<Presence[]>;
|
||||||
abstract leave(issueId: string): Observable<void>;
|
abstract leave(issueId: string): Observable<void>;
|
||||||
|
/** I am writing (an answer to `threadId`, or a new comment). */
|
||||||
|
abstract typing(issueId: string, threadId: string | null): Observable<void>;
|
||||||
|
/** Who is writing on the ticket, as they type (me included). */
|
||||||
|
abstract typists(issueId: string): Observable<Typing>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
IssueCommentsDocument,
|
IssueCommentsDocument,
|
||||||
ReactToCommentDocument,
|
ReactToCommentDocument,
|
||||||
MarkDecisionDocument,
|
MarkDecisionDocument,
|
||||||
|
ResolveThreadDocument,
|
||||||
PublishReleaseDocument,
|
PublishReleaseDocument,
|
||||||
ReleaseBurnupDocument,
|
ReleaseBurnupDocument,
|
||||||
ReleaseNotesDraftDocument,
|
ReleaseNotesDraftDocument,
|
||||||
@@ -77,6 +78,16 @@ export class GraphqlCommentsGateway extends CommentsGateway {
|
|||||||
.pipe(map((d) => toCommandResult(d.deleteComment)));
|
.pipe(map((d) => toCommandResult(d.deleteComment)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resolveThread(
|
||||||
|
commentId: string,
|
||||||
|
resolved: boolean,
|
||||||
|
conclusion: string | null,
|
||||||
|
): Observable<CommandResult> {
|
||||||
|
return this.gql
|
||||||
|
.mutate(ResolveThreadDocument, { commentId, resolved, conclusion })
|
||||||
|
.pipe(map((d) => toCommandResult(d.resolveThread)));
|
||||||
|
}
|
||||||
|
|
||||||
markDecision(commentId: string, on: boolean): Observable<CommandResult> {
|
markDecision(commentId: string, on: boolean): Observable<CommandResult> {
|
||||||
return this.gql
|
return this.gql
|
||||||
.mutate(MarkDecisionDocument, { commentId, on })
|
.mutate(MarkDecisionDocument, { commentId, on })
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { inject, Injectable } from '@angular/core';
|
import { inject, Injectable } from '@angular/core';
|
||||||
import { Presence, PresenceGateway } from '@task-stream/web/tracker/domain';
|
import {
|
||||||
|
Presence,
|
||||||
|
PresenceGateway,
|
||||||
|
Typing,
|
||||||
|
} from '@task-stream/web/tracker/domain';
|
||||||
import { map, Observable } from 'rxjs';
|
import { map, Observable } from 'rxjs';
|
||||||
import {
|
import {
|
||||||
LeavePresenceDocument,
|
LeavePresenceDocument,
|
||||||
TouchPresenceDocument,
|
TouchPresenceDocument,
|
||||||
|
TypingDocument,
|
||||||
|
TypistsDocument,
|
||||||
} from '../graphql/generated';
|
} from '../graphql/generated';
|
||||||
import { GraphqlOperations } from './graphql-operations';
|
import { GraphqlOperations } from './graphql-operations';
|
||||||
|
|
||||||
@@ -17,6 +23,18 @@ export class GraphqlPresenceGateway extends PresenceGateway {
|
|||||||
.pipe(map((d) => d.touchPresence));
|
.pipe(map((d) => d.touchPresence));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
typing(issueId: string, threadId: string | null): Observable<void> {
|
||||||
|
return this.gql
|
||||||
|
.mutate(TypingDocument, { issueId, threadId })
|
||||||
|
.pipe(map(() => undefined));
|
||||||
|
}
|
||||||
|
|
||||||
|
typists(issueId: string): Observable<Typing> {
|
||||||
|
return this.gql
|
||||||
|
.subscribe(TypistsDocument, { issueId })
|
||||||
|
.pipe(map((d) => d.typing));
|
||||||
|
}
|
||||||
|
|
||||||
leave(issueId: string): Observable<void> {
|
leave(issueId: string): Observable<void> {
|
||||||
return this.gql
|
return this.gql
|
||||||
.mutate(LeavePresenceDocument, { issueId })
|
.mutate(LeavePresenceDocument, { issueId })
|
||||||
|
|||||||
@@ -185,6 +185,10 @@ fragment CommentFields on Comment {
|
|||||||
decidedBy {
|
decidedBy {
|
||||||
...UserRefFields
|
...UserRefFields
|
||||||
}
|
}
|
||||||
|
resolved
|
||||||
|
resolvedBy {
|
||||||
|
...UserRefFields
|
||||||
|
}
|
||||||
createdAt
|
createdAt
|
||||||
editedAt
|
editedAt
|
||||||
author {
|
author {
|
||||||
|
|||||||
@@ -720,6 +720,7 @@ export type IssueFieldsFragment = {
|
|||||||
replyTo: string | null;
|
replyTo: string | null;
|
||||||
deleted: boolean;
|
deleted: boolean;
|
||||||
decision: boolean;
|
decision: boolean;
|
||||||
|
resolved: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
editedAt: string | null;
|
editedAt: string | null;
|
||||||
reactions: Array<{
|
reactions: Array<{
|
||||||
@@ -727,6 +728,7 @@ export type IssueFieldsFragment = {
|
|||||||
users: Array<{ id: string; name: string }>;
|
users: Array<{ id: string; name: string }>;
|
||||||
}>;
|
}>;
|
||||||
decidedBy: { id: string; name: string } | null;
|
decidedBy: { id: string; name: string } | null;
|
||||||
|
resolvedBy: { id: string; name: string } | null;
|
||||||
author: { id: string; name: string };
|
author: { id: string; name: string };
|
||||||
}>;
|
}>;
|
||||||
reporter: { id: string; name: string };
|
reporter: { id: string; name: string };
|
||||||
@@ -773,6 +775,7 @@ export type CommentFieldsFragment = {
|
|||||||
replyTo: string | null;
|
replyTo: string | null;
|
||||||
deleted: boolean;
|
deleted: boolean;
|
||||||
decision: boolean;
|
decision: boolean;
|
||||||
|
resolved: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
editedAt: string | null;
|
editedAt: string | null;
|
||||||
reactions: Array<{
|
reactions: Array<{
|
||||||
@@ -780,6 +783,7 @@ export type CommentFieldsFragment = {
|
|||||||
users: Array<{ id: string; name: string }>;
|
users: Array<{ id: string; name: string }>;
|
||||||
}>;
|
}>;
|
||||||
decidedBy: { id: string; name: string } | null;
|
decidedBy: { id: string; name: string } | null;
|
||||||
|
resolvedBy: { id: string; name: string } | null;
|
||||||
author: { id: string; name: string };
|
author: { id: string; name: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -961,6 +965,7 @@ export type IssueQuery = {
|
|||||||
replyTo: string | null;
|
replyTo: string | null;
|
||||||
deleted: boolean;
|
deleted: boolean;
|
||||||
decision: boolean;
|
decision: boolean;
|
||||||
|
resolved: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
editedAt: string | null;
|
editedAt: string | null;
|
||||||
reactions: Array<{
|
reactions: Array<{
|
||||||
@@ -968,6 +973,7 @@ export type IssueQuery = {
|
|||||||
users: Array<{ id: string; name: string }>;
|
users: Array<{ id: string; name: string }>;
|
||||||
}>;
|
}>;
|
||||||
decidedBy: { id: string; name: string } | null;
|
decidedBy: { id: string; name: string } | null;
|
||||||
|
resolvedBy: { id: string; name: string } | null;
|
||||||
author: { id: string; name: string };
|
author: { id: string; name: string };
|
||||||
}>;
|
}>;
|
||||||
reporter: { id: string; name: string };
|
reporter: { id: string; name: string };
|
||||||
@@ -1181,6 +1187,7 @@ export type IssueCommentsQuery = {
|
|||||||
replyTo: string | null;
|
replyTo: string | null;
|
||||||
deleted: boolean;
|
deleted: boolean;
|
||||||
decision: boolean;
|
decision: boolean;
|
||||||
|
resolved: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
editedAt: string | null;
|
editedAt: string | null;
|
||||||
reactions: Array<{
|
reactions: Array<{
|
||||||
@@ -1188,6 +1195,7 @@ export type IssueCommentsQuery = {
|
|||||||
users: Array<{ id: string; name: string }>;
|
users: Array<{ id: string; name: string }>;
|
||||||
}>;
|
}>;
|
||||||
decidedBy: { id: string; name: string } | null;
|
decidedBy: { id: string; name: string } | null;
|
||||||
|
resolvedBy: { id: string; name: string } | null;
|
||||||
author: { id: string; name: string };
|
author: { id: string; name: string };
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
@@ -1589,6 +1597,16 @@ export type ReactToCommentMutation = {
|
|||||||
reactToComment: { id: string; key: string | null; version: number };
|
reactToComment: { id: string; key: string | null; version: number };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ResolveThreadMutationVariables = Exact<{
|
||||||
|
commentId: string;
|
||||||
|
resolved: boolean;
|
||||||
|
conclusion?: string | null | undefined;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type ResolveThreadMutation = {
|
||||||
|
resolveThread: { id: string; key: string | null; version: number };
|
||||||
|
};
|
||||||
|
|
||||||
export type MarkDecisionMutationVariables = Exact<{
|
export type MarkDecisionMutationVariables = Exact<{
|
||||||
commentId: string;
|
commentId: string;
|
||||||
on: boolean;
|
on: boolean;
|
||||||
@@ -1612,6 +1630,7 @@ export type SearchDecisionsQuery = {
|
|||||||
replyTo: string | null;
|
replyTo: string | null;
|
||||||
deleted: boolean;
|
deleted: boolean;
|
||||||
decision: boolean;
|
decision: boolean;
|
||||||
|
resolved: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
editedAt: string | null;
|
editedAt: string | null;
|
||||||
reactions: Array<{
|
reactions: Array<{
|
||||||
@@ -1619,6 +1638,7 @@ export type SearchDecisionsQuery = {
|
|||||||
users: Array<{ id: string; name: string }>;
|
users: Array<{ id: string; name: string }>;
|
||||||
}>;
|
}>;
|
||||||
decidedBy: { id: string; name: string } | null;
|
decidedBy: { id: string; name: string } | null;
|
||||||
|
resolvedBy: { id: string; name: string } | null;
|
||||||
author: { id: string; name: string };
|
author: { id: string; name: string };
|
||||||
};
|
};
|
||||||
issue: { id: string; key: string; title: string };
|
issue: { id: string; key: string; title: string };
|
||||||
@@ -1758,6 +1778,25 @@ export type LeavePresenceMutationVariables = Exact<{
|
|||||||
|
|
||||||
export type LeavePresenceMutation = { leavePresence: boolean };
|
export type LeavePresenceMutation = { leavePresence: boolean };
|
||||||
|
|
||||||
|
export type TypingMutationVariables = Exact<{
|
||||||
|
issueId: string;
|
||||||
|
threadId?: string | null | undefined;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type TypingMutation = { typing: boolean };
|
||||||
|
|
||||||
|
export type TypistsSubscriptionVariables = Exact<{
|
||||||
|
issueId: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type TypistsSubscription = {
|
||||||
|
typing: {
|
||||||
|
threadId: string | null;
|
||||||
|
at: string;
|
||||||
|
user: { id: string; name: string };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export type MyProjectsQueryVariables = Exact<{ [key: string]: never }>;
|
export type MyProjectsQueryVariables = Exact<{ [key: string]: never }>;
|
||||||
|
|
||||||
export type MyProjectsQuery = {
|
export type MyProjectsQuery = {
|
||||||
@@ -2916,6 +2955,7 @@ export type CommentChangedSubscription = {
|
|||||||
replyTo: string | null;
|
replyTo: string | null;
|
||||||
deleted: boolean;
|
deleted: boolean;
|
||||||
decision: boolean;
|
decision: boolean;
|
||||||
|
resolved: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
editedAt: string | null;
|
editedAt: string | null;
|
||||||
reactions: Array<{
|
reactions: Array<{
|
||||||
@@ -2923,6 +2963,7 @@ export type CommentChangedSubscription = {
|
|||||||
users: Array<{ id: string; name: string }>;
|
users: Array<{ id: string; name: string }>;
|
||||||
}>;
|
}>;
|
||||||
decidedBy: { id: string; name: string } | null;
|
decidedBy: { id: string; name: string } | null;
|
||||||
|
resolvedBy: { id: string; name: string } | null;
|
||||||
author: { id: string; name: string };
|
author: { id: string; name: string };
|
||||||
} | null;
|
} | null;
|
||||||
};
|
};
|
||||||
@@ -3712,6 +3753,20 @@ export const CommentFieldsFragmentDoc = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'resolved' } },
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'resolvedBy' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'FragmentSpread',
|
||||||
|
name: { kind: 'Name', value: 'UserRefFields' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
{ 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' } },
|
||||||
{
|
{
|
||||||
@@ -4056,6 +4111,20 @@ export const IssueFieldsFragmentDoc = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'resolved' } },
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'resolvedBy' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'FragmentSpread',
|
||||||
|
name: { kind: 'Name', value: 'UserRefFields' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
{ 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' } },
|
||||||
{
|
{
|
||||||
@@ -6304,6 +6373,20 @@ export const IssueDocument = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'resolved' } },
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'resolvedBy' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'FragmentSpread',
|
||||||
|
name: { kind: 'Name', value: 'UserRefFields' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
{ 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' } },
|
||||||
{
|
{
|
||||||
@@ -7684,6 +7767,20 @@ export const IssueCommentsDocument = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'resolved' } },
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'resolvedBy' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'FragmentSpread',
|
||||||
|
name: { kind: 'Name', value: 'UserRefFields' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
{ 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' } },
|
||||||
{
|
{
|
||||||
@@ -10172,6 +10269,114 @@ export const ReactToCommentDocument = {
|
|||||||
ReactToCommentMutation,
|
ReactToCommentMutation,
|
||||||
ReactToCommentMutationVariables
|
ReactToCommentMutationVariables
|
||||||
>;
|
>;
|
||||||
|
export const ResolveThreadDocument = {
|
||||||
|
kind: 'Document',
|
||||||
|
definitions: [
|
||||||
|
{
|
||||||
|
kind: 'OperationDefinition',
|
||||||
|
operation: 'mutation',
|
||||||
|
name: { kind: 'Name', value: 'ResolveThread' },
|
||||||
|
variableDefinitions: [
|
||||||
|
{
|
||||||
|
kind: 'VariableDefinition',
|
||||||
|
variable: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'commentId' },
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
kind: 'NonNullType',
|
||||||
|
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'VariableDefinition',
|
||||||
|
variable: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'resolved' },
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
kind: 'NonNullType',
|
||||||
|
type: {
|
||||||
|
kind: 'NamedType',
|
||||||
|
name: { kind: 'Name', value: 'Boolean' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'VariableDefinition',
|
||||||
|
variable: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'conclusion' },
|
||||||
|
},
|
||||||
|
type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'resolveThread' },
|
||||||
|
arguments: [
|
||||||
|
{
|
||||||
|
kind: 'Argument',
|
||||||
|
name: { kind: 'Name', value: 'commentId' },
|
||||||
|
value: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'commentId' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Argument',
|
||||||
|
name: { kind: 'Name', value: 'resolved' },
|
||||||
|
value: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'resolved' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Argument',
|
||||||
|
name: { kind: 'Name', value: 'conclusion' },
|
||||||
|
value: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'conclusion' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'FragmentSpread',
|
||||||
|
name: { kind: 'Name', value: 'CommandResultFields' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'FragmentDefinition',
|
||||||
|
name: { kind: 'Name', value: 'CommandResultFields' },
|
||||||
|
typeCondition: {
|
||||||
|
kind: 'NamedType',
|
||||||
|
name: { kind: 'Name', value: 'CommandResult' },
|
||||||
|
},
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'version' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as DocumentNode<
|
||||||
|
ResolveThreadMutation,
|
||||||
|
ResolveThreadMutationVariables
|
||||||
|
>;
|
||||||
export const MarkDecisionDocument = {
|
export const MarkDecisionDocument = {
|
||||||
kind: 'Document',
|
kind: 'Document',
|
||||||
definitions: [
|
definitions: [
|
||||||
@@ -10414,6 +10619,20 @@ export const SearchDecisionsDocument = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'resolved' } },
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'resolvedBy' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'FragmentSpread',
|
||||||
|
name: { kind: 'Name', value: 'UserRefFields' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
{ 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' } },
|
||||||
{
|
{
|
||||||
@@ -11196,6 +11415,141 @@ export const LeavePresenceDocument = {
|
|||||||
LeavePresenceMutation,
|
LeavePresenceMutation,
|
||||||
LeavePresenceMutationVariables
|
LeavePresenceMutationVariables
|
||||||
>;
|
>;
|
||||||
|
export const TypingDocument = {
|
||||||
|
kind: 'Document',
|
||||||
|
definitions: [
|
||||||
|
{
|
||||||
|
kind: 'OperationDefinition',
|
||||||
|
operation: 'mutation',
|
||||||
|
name: { kind: 'Name', value: 'Typing' },
|
||||||
|
variableDefinitions: [
|
||||||
|
{
|
||||||
|
kind: 'VariableDefinition',
|
||||||
|
variable: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'issueId' },
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
kind: 'NonNullType',
|
||||||
|
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'VariableDefinition',
|
||||||
|
variable: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'threadId' },
|
||||||
|
},
|
||||||
|
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'typing' },
|
||||||
|
arguments: [
|
||||||
|
{
|
||||||
|
kind: 'Argument',
|
||||||
|
name: { kind: 'Name', value: 'issueId' },
|
||||||
|
value: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'issueId' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Argument',
|
||||||
|
name: { kind: 'Name', value: 'threadId' },
|
||||||
|
value: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'threadId' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as DocumentNode<TypingMutation, TypingMutationVariables>;
|
||||||
|
export const TypistsDocument = {
|
||||||
|
kind: 'Document',
|
||||||
|
definitions: [
|
||||||
|
{
|
||||||
|
kind: 'OperationDefinition',
|
||||||
|
operation: 'subscription',
|
||||||
|
name: { kind: 'Name', value: 'Typists' },
|
||||||
|
variableDefinitions: [
|
||||||
|
{
|
||||||
|
kind: 'VariableDefinition',
|
||||||
|
variable: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'issueId' },
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
kind: 'NonNullType',
|
||||||
|
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'typing' },
|
||||||
|
arguments: [
|
||||||
|
{
|
||||||
|
kind: 'Argument',
|
||||||
|
name: { kind: 'Name', value: 'issueId' },
|
||||||
|
value: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'issueId' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'user' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'FragmentSpread',
|
||||||
|
name: { kind: 'Name', value: 'UserRefFields' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'threadId' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'at' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'FragmentDefinition',
|
||||||
|
name: { kind: 'Name', value: 'UserRefFields' },
|
||||||
|
typeCondition: {
|
||||||
|
kind: 'NamedType',
|
||||||
|
name: { kind: 'Name', value: 'UserRef' },
|
||||||
|
},
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as DocumentNode<TypistsSubscription, TypistsSubscriptionVariables>;
|
||||||
export const MyProjectsDocument = {
|
export const MyProjectsDocument = {
|
||||||
kind: 'Document',
|
kind: 'Document',
|
||||||
definitions: [
|
definitions: [
|
||||||
@@ -17206,6 +17560,20 @@ export const CommentChangedDocument = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'resolved' } },
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'resolvedBy' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'FragmentSpread',
|
||||||
|
name: { kind: 'Name', value: 'UserRefFields' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
{ 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' } },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -293,6 +293,20 @@ mutation ReactToComment($commentId: ID!, $emoji: String!, $on: Boolean!) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mutation ResolveThread(
|
||||||
|
$commentId: ID!
|
||||||
|
$resolved: Boolean!
|
||||||
|
$conclusion: String
|
||||||
|
) {
|
||||||
|
resolveThread(
|
||||||
|
commentId: $commentId
|
||||||
|
resolved: $resolved
|
||||||
|
conclusion: $conclusion
|
||||||
|
) {
|
||||||
|
...CommandResultFields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
mutation MarkDecision($commentId: ID!, $on: Boolean!) {
|
mutation MarkDecision($commentId: ID!, $on: Boolean!) {
|
||||||
markDecision(commentId: $commentId, on: $on) {
|
markDecision(commentId: $commentId, on: $on) {
|
||||||
...CommandResultFields
|
...CommandResultFields
|
||||||
|
|||||||
@@ -11,3 +11,17 @@ mutation TouchPresence($issueId: ID!, $editing: Boolean!) {
|
|||||||
mutation LeavePresence($issueId: ID!) {
|
mutation LeavePresence($issueId: ID!) {
|
||||||
leavePresence(issueId: $issueId)
|
leavePresence(issueId: $issueId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mutation Typing($issueId: ID!, $threadId: ID) {
|
||||||
|
typing(issueId: $issueId, threadId: $threadId)
|
||||||
|
}
|
||||||
|
|
||||||
|
subscription Typists($issueId: ID!) {
|
||||||
|
typing(issueId: $issueId) {
|
||||||
|
user {
|
||||||
|
...UserRefFields
|
||||||
|
}
|
||||||
|
threadId
|
||||||
|
at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -641,24 +641,104 @@ a.ts-key {
|
|||||||
|
|
||||||
// A text link, past @pi/ui's global button look: as a 40 px pill it sat on
|
// A text link, past @pi/ui's global button look: as a 40 px pill it sat on
|
||||||
// the line under the comment.
|
// the line under the comment.
|
||||||
.ts-reply__open {
|
.ts-thread__actions {
|
||||||
justify-self: start;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.25rem 1rem;
|
||||||
|
margin-top: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Small text buttons, not @pi/ui's sized ones. */
|
||||||
|
.ts-thread__action {
|
||||||
|
flex: none;
|
||||||
height: auto !important;
|
height: auto !important;
|
||||||
min-width: 0 !important;
|
min-width: 0 !important;
|
||||||
margin-top: 0.375rem;
|
|
||||||
padding: 0.125rem 0 !important;
|
padding: 0.125rem 0 !important;
|
||||||
border: 0;
|
border: 0;
|
||||||
background: none !important;
|
background: none !important;
|
||||||
color: var(--ts-brand);
|
color: var(--ts-brand);
|
||||||
|
font: inherit;
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
&:hover {
|
&:hover,
|
||||||
|
&:focus-visible {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A settled conversation, folded to one line. */
|
||||||
|
.ts-thread-done {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
border: 1px solid var(--ts-line);
|
||||||
|
border-radius: 0.625rem;
|
||||||
|
background: var(--ts-sunken);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-thread-done__check {
|
||||||
|
flex: none;
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: color-mix(in srgb, var(--ts-c-green) 18%, transparent);
|
||||||
|
color: var(--ts-c-green);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-thread-done__text {
|
||||||
|
display: grid;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
|
||||||
|
> span {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-thread-done__topic {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-open-threads {
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 0.0625rem 0.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--ts-c-amber) 18%, transparent);
|
||||||
|
color: var(--ts-ink);
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-typing {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ts-muted);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-resolve__label {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.375rem;
|
||||||
|
color: var(--ts-muted);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-resolve__text {
|
||||||
|
font: inherit;
|
||||||
|
color: var(--ts-ink);
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
.ts-reply__actions {
|
.ts-reply__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ import {
|
|||||||
isTyping,
|
isTyping,
|
||||||
POINT_SCALE,
|
POINT_SCALE,
|
||||||
ReleaseStatus,
|
ReleaseStatus,
|
||||||
|
StatusCategory,
|
||||||
} from '@task-stream/web/tracker/domain';
|
} from '@task-stream/web/tracker/domain';
|
||||||
import {
|
import {
|
||||||
clock,
|
clock,
|
||||||
@@ -654,6 +655,16 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|||||||
<h2 class="ts-section">
|
<h2 class="ts-section">
|
||||||
Comentarios
|
Comentarios
|
||||||
<span class="ts-mono">{{ commentCount() }}</span>
|
<span class="ts-mono">{{ commentCount() }}</span>
|
||||||
|
@if (openThreads()) {
|
||||||
|
<span class="ts-open-threads"
|
||||||
|
>{{ openThreads() }}
|
||||||
|
{{
|
||||||
|
openThreads() === 1
|
||||||
|
? 'conversación abierta'
|
||||||
|
: 'conversaciones abiertas'
|
||||||
|
}}</span
|
||||||
|
>
|
||||||
|
}
|
||||||
</h2>
|
</h2>
|
||||||
<ng-template #commentView let-comment>
|
<ng-template #commentView let-comment>
|
||||||
<div
|
<div
|
||||||
@@ -761,6 +772,41 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
@for (thread of threads(); track thread.comment.id) {
|
@for (thread of threads(); track thread.comment.id) {
|
||||||
|
@if (
|
||||||
|
thread.comment.resolved && !expanded().has(thread.comment.id)
|
||||||
|
) {
|
||||||
|
<!-- Settled: one line, the conversation a click away. -->
|
||||||
|
<div class="ts-thread-done">
|
||||||
|
<span class="ts-thread-done__check" aria-hidden="true"
|
||||||
|
>✓</span
|
||||||
|
>
|
||||||
|
<div class="ts-thread-done__text">
|
||||||
|
<span class="ts-thread-done__topic">{{
|
||||||
|
thread.comment.deleted
|
||||||
|
? 'Conversación'
|
||||||
|
: excerpt(thread.comment.body)
|
||||||
|
}}</span>
|
||||||
|
<span class="ts-muted"
|
||||||
|
>Resuelta{{
|
||||||
|
thread.comment.resolvedBy
|
||||||
|
? ' por ' + thread.comment.resolvedBy.name
|
||||||
|
: ''
|
||||||
|
}}{{
|
||||||
|
conclusionOf(thread)
|
||||||
|
? ': ' + excerpt(conclusionOf(thread) ?? '')
|
||||||
|
: ''
|
||||||
|
}}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ts-thread__action"
|
||||||
|
(click)="toggleThread(thread.comment.id)"
|
||||||
|
>
|
||||||
|
Ver conversación ({{ thread.replies.length + 1 }})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
<ng-container
|
<ng-container
|
||||||
*ngTemplateOutlet="
|
*ngTemplateOutlet="
|
||||||
commentView;
|
commentView;
|
||||||
@@ -776,7 +822,45 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@if (replyingTo() === thread.comment.id) {
|
@if (typistsIn(thread.comment.id); as typing) {
|
||||||
|
<p class="ts-typing" aria-live="polite">{{ typing }}</p>
|
||||||
|
}
|
||||||
|
@if (resolving() === thread.comment.id) {
|
||||||
|
<form
|
||||||
|
class="ts-form ts-reply"
|
||||||
|
(submit)="
|
||||||
|
$event.preventDefault(); resolve(thread.comment)
|
||||||
|
"
|
||||||
|
novalidate
|
||||||
|
>
|
||||||
|
<label class="ts-resolve__label">
|
||||||
|
Conclusión (opcional): quedará como decisión, arriba del
|
||||||
|
ticket
|
||||||
|
<textarea
|
||||||
|
class="ts-input ts-resolve__text"
|
||||||
|
rows="2"
|
||||||
|
maxlength="20000"
|
||||||
|
placeholder="Vamos con…"
|
||||||
|
[value]="conclusion()"
|
||||||
|
(input)="conclusion.set($any($event.target).value)"
|
||||||
|
></textarea>
|
||||||
|
</label>
|
||||||
|
<div class="ts-reply__actions">
|
||||||
|
<pi-action-button
|
||||||
|
color="primary"
|
||||||
|
text="Resolver"
|
||||||
|
(execute)="resolve(thread.comment)"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ts-plain-link"
|
||||||
|
(click)="resolving.set(null)"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
} @else if (replyingTo() === thread.comment.id) {
|
||||||
<form
|
<form
|
||||||
class="ts-form ts-reply"
|
class="ts-form ts-reply"
|
||||||
(submit)="$event.preventDefault(); reply(thread.comment)"
|
(submit)="$event.preventDefault(); reply(thread.comment)"
|
||||||
@@ -791,6 +875,7 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|||||||
[me]="facade.me()"
|
[me]="facade.me()"
|
||||||
[upload]="pasteImage"
|
[upload]="pasteImage"
|
||||||
[loader]="loadImage"
|
[loader]="loadImage"
|
||||||
|
[enterSends]="true"
|
||||||
(submitted)="reply(thread.comment)"
|
(submitted)="reply(thread.comment)"
|
||||||
/>
|
/>
|
||||||
<div class="ts-reply__actions">
|
<div class="ts-reply__actions">
|
||||||
@@ -809,10 +894,12 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
} @else if (project.canReport() && !thread.comment.deleted) {
|
} @else {
|
||||||
|
<div class="ts-thread__actions">
|
||||||
|
@if (project.canReport() && !thread.comment.deleted) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="ts-plain-link ts-reply__open"
|
class="ts-thread__action"
|
||||||
(click)="startReply(thread.comment)"
|
(click)="startReply(thread.comment)"
|
||||||
>
|
>
|
||||||
Responder{{
|
Responder{{
|
||||||
@@ -822,8 +909,52 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|||||||
}}
|
}}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
@if (project.canContribute()) {
|
||||||
|
@if (thread.comment.resolved) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ts-thread__action"
|
||||||
|
(click)="
|
||||||
|
facade.resolveThread(thread.comment.id, false)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Reabrir
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ts-thread__action"
|
||||||
|
(click)="toggleThread(thread.comment.id)"
|
||||||
|
>
|
||||||
|
Plegar
|
||||||
|
</button>
|
||||||
|
} @else if (
|
||||||
|
thread.replies.length && !thread.comment.deleted
|
||||||
|
) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ts-thread__action"
|
||||||
|
(click)="startResolve(thread.comment)"
|
||||||
|
>
|
||||||
|
✓ Resolver
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
} @else if (thread.comment.resolved) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ts-thread__action"
|
||||||
|
(click)="toggleThread(thread.comment.id)"
|
||||||
|
>
|
||||||
|
Plegar
|
||||||
|
</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@if (typistsIn(null); as typing) {
|
||||||
|
<p class="ts-typing" aria-live="polite">{{ typing }}</p>
|
||||||
|
}
|
||||||
@if (project.canReport()) {
|
@if (project.canReport()) {
|
||||||
<form
|
<form
|
||||||
class="ts-form"
|
class="ts-form"
|
||||||
@@ -1247,6 +1378,11 @@ export class IssuePage {
|
|||||||
/** The comment being answered, and the answer being written. */
|
/** The comment being answered, and the answer being written. */
|
||||||
protected readonly replyingTo = signal<string | null>(null);
|
protected readonly replyingTo = signal<string | null>(null);
|
||||||
protected readonly replyBody = signal('');
|
protected readonly replyBody = signal('');
|
||||||
|
/** The conversation being settled, and its conclusion. */
|
||||||
|
protected readonly resolving = signal<string | null>(null);
|
||||||
|
protected readonly conclusion = signal('');
|
||||||
|
/** Settled conversations unfolded to read. */
|
||||||
|
protected readonly expanded = signal<ReadonlySet<string>>(new Set());
|
||||||
/** Top-level comments, each with its replies (oldest first). */
|
/** Top-level comments, each with its replies (oldest first). */
|
||||||
protected readonly threads = computed(() => {
|
protected readonly threads = computed(() => {
|
||||||
const comments = this.facade.comments();
|
const comments = this.facade.comments();
|
||||||
@@ -1414,6 +1550,14 @@ export class IssuePage {
|
|||||||
if (id && this.facade.comments().some((c) => c.id === id))
|
if (id && this.facade.comments().some((c) => c.id === id))
|
||||||
untracked(() => setTimeout(() => this.goToComment(id)));
|
untracked(() => setTimeout(() => this.goToComment(id)));
|
||||||
});
|
});
|
||||||
|
// Writing an answer or a comment: the others see it as I type.
|
||||||
|
effect(() => {
|
||||||
|
if (this.replyBody().trim())
|
||||||
|
untracked(() => this.presence.typed(this.replyingTo()));
|
||||||
|
});
|
||||||
|
effect(() => {
|
||||||
|
if (this.commentBody().trim()) untracked(() => this.presence.typed(null));
|
||||||
|
});
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const editing = this.editingInDrawer() || !!this.commentBody().trim();
|
const editing = this.editingInDrawer() || !!this.commentBody().trim();
|
||||||
untracked(() => this.presence.setEditing(editing));
|
untracked(() => this.presence.setEditing(editing));
|
||||||
@@ -1473,6 +1617,17 @@ export class IssuePage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected move(to: string): void {
|
protected move(to: string): void {
|
||||||
|
const open = this.openThreads();
|
||||||
|
if (
|
||||||
|
open &&
|
||||||
|
this.project.workflow().status(to)?.category === StatusCategory.DONE &&
|
||||||
|
!confirm(
|
||||||
|
open === 1
|
||||||
|
? 'Queda una conversación sin resolver. ¿Terminar el ticket igualmente?'
|
||||||
|
: `Quedan ${open} conversaciones sin resolver. ¿Terminar el ticket igualmente?`,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
if (this.project.workflow().requiresReason(to)) {
|
if (this.project.workflow().requiresReason(to)) {
|
||||||
this.pendingStatus = to;
|
this.pendingStatus = to;
|
||||||
this.reason().reset({ text: '' });
|
this.reason().reset({ text: '' });
|
||||||
@@ -1565,6 +1720,68 @@ export class IssuePage {
|
|||||||
this.replyingTo.set(comment.id);
|
this.replyingTo.set(comment.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected startResolve(comment: Comment): void {
|
||||||
|
this.conclusion.set('');
|
||||||
|
this.replyingTo.set(null);
|
||||||
|
this.resolving.set(comment.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected resolve(comment: Comment): void {
|
||||||
|
this.facade.resolveThread(comment.id, true, this.conclusion());
|
||||||
|
this.resolving.set(null);
|
||||||
|
this.conclusion.set('');
|
||||||
|
// It folds once settled.
|
||||||
|
this.expanded.update((ids) => {
|
||||||
|
const next = new Set(ids);
|
||||||
|
next.delete(comment.id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected toggleThread(id: string): void {
|
||||||
|
this.expanded.update((ids) => {
|
||||||
|
const next = new Set(ids);
|
||||||
|
if (!next.delete(id)) next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Conversations with answers still to settle. */
|
||||||
|
protected readonly openThreads = computed(
|
||||||
|
() =>
|
||||||
|
this.threads().filter(
|
||||||
|
(t) => t.replies.length && !t.comment.resolved && !t.comment.deleted,
|
||||||
|
).length,
|
||||||
|
);
|
||||||
|
|
||||||
|
/** What a settled conversation concluded: its last reply marked as a decision. */
|
||||||
|
protected conclusionOf(thread: { replies: Comment[] }): string | null {
|
||||||
|
const decided = thread.replies.filter((r) => r.decision && !r.deleted);
|
||||||
|
return decided[decided.length - 1]?.body ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The first words of a comment, on one line. */
|
||||||
|
protected excerpt(body: string): string {
|
||||||
|
const text = body
|
||||||
|
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
|
||||||
|
.replace(/[*_`~#>]+/g, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
return text.length > 80 ? `${text.slice(0, 79)}…` : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «Ana está escribiendo…» in a conversation (null: a new comment). */
|
||||||
|
protected typistsIn(threadId: string | null): string | null {
|
||||||
|
const names = this.presence
|
||||||
|
.typists()
|
||||||
|
.filter((t) => t.threadId === threadId)
|
||||||
|
.map((t) => t.user.name);
|
||||||
|
if (!names.length) return null;
|
||||||
|
return names.length === 1
|
||||||
|
? `${names[0]} está escribiendo…`
|
||||||
|
: `${names.slice(0, -1).join(', ')} y ${names[names.length - 1]} están escribiendo…`;
|
||||||
|
}
|
||||||
|
|
||||||
protected reply(comment: Comment): void {
|
protected reply(comment: Comment): void {
|
||||||
const body = this.replyBody().trim();
|
const body = this.replyBody().trim();
|
||||||
if (!body) return;
|
if (!body) return;
|
||||||
|
|||||||
@@ -423,7 +423,9 @@ export class RichEditorComponent implements FormValueControl<string> {
|
|||||||
/** Turns pasting screenshots on. */
|
/** Turns pasting screenshots on. */
|
||||||
readonly upload = input<ImageUploader | null>(null);
|
readonly upload = input<ImageUploader | null>(null);
|
||||||
readonly loader = input<AttachmentLoader | null>(null);
|
readonly loader = input<AttachmentLoader | null>(null);
|
||||||
/** Ctrl/Cmd+Enter. */
|
/** Plain Enter sends too (Shift+Enter breaks the line), like a chat. */
|
||||||
|
readonly enterSends = input(false);
|
||||||
|
/** Ctrl/Cmd+Enter (and Enter with `enterSends`). */
|
||||||
readonly submitted = output<void>();
|
readonly submitted = output<void>();
|
||||||
readonly touch = output<void>();
|
readonly touch = output<void>();
|
||||||
|
|
||||||
@@ -440,6 +442,7 @@ export class RichEditorComponent implements FormValueControl<string> {
|
|||||||
});
|
});
|
||||||
protected readonly hint = computed(() =>
|
protected readonly hint = computed(() =>
|
||||||
[
|
[
|
||||||
|
this.enterSends() ? 'Enter envía, Shift+Enter cambia de línea' : '',
|
||||||
this.members().length ? 'Escribe @ para mencionar a alguien' : '',
|
this.members().length ? 'Escribe @ para mencionar a alguien' : '',
|
||||||
this.upload() ? 'pega capturas con Ctrl+V' : '',
|
this.upload() ? 'pega capturas con Ctrl+V' : '',
|
||||||
]
|
]
|
||||||
@@ -539,6 +542,7 @@ export class RichEditorComponent implements FormValueControl<string> {
|
|||||||
placeholder: () => this.placeholder(),
|
placeholder: () => this.placeholder(),
|
||||||
loader: this.loader(),
|
loader: this.loader(),
|
||||||
onSubmit: () => this.submitted.emit(),
|
onSubmit: () => this.submitted.emit(),
|
||||||
|
enterSends: () => this.enterSends(),
|
||||||
suggestion: {
|
suggestion: {
|
||||||
items: ({ query }) =>
|
items: ({ query }) =>
|
||||||
this.members()
|
this.members()
|
||||||
|
|||||||
@@ -106,6 +106,11 @@ export interface RichTextOptions {
|
|||||||
suggestion?: Omit<SuggestionOptions<Mentionable>, 'editor'> | null;
|
suggestion?: Omit<SuggestionOptions<Mentionable>, 'editor'> | null;
|
||||||
/** Ctrl/Cmd+Enter. */
|
/** Ctrl/Cmd+Enter. */
|
||||||
onSubmit?: () => void;
|
onSubmit?: () => void;
|
||||||
|
/**
|
||||||
|
* Plain Enter submits too (Shift+Enter breaks the line), like a chat;
|
||||||
|
* not inside a list or code, where Enter means a new item or line.
|
||||||
|
*/
|
||||||
|
enterSends?: () => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -152,6 +157,17 @@ export function richTextExtensions(
|
|||||||
options.onSubmit?.();
|
options.onSubmit?.();
|
||||||
return !!options.onSubmit;
|
return !!options.onSubmit;
|
||||||
},
|
},
|
||||||
|
Enter: ({ editor }) => {
|
||||||
|
if (!options.onSubmit || !options.enterSends?.()) return false;
|
||||||
|
if (
|
||||||
|
editor.isActive('codeBlock') ||
|
||||||
|
editor.isActive('listItem') ||
|
||||||
|
editor.isActive('taskItem')
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
options.onSubmit();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
Markdown.configure({ markedOptions: { gfm: true, breaks: true } }),
|
Markdown.configure({ markedOptions: { gfm: true, breaks: true } }),
|
||||||
|
|||||||
Reference in New Issue
Block a user