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:
2026-10-19 22:35:46 +02:00
parent c0b0417b6d
commit 199b60e4ce
51 changed files with 1965 additions and 126 deletions

View File

@@ -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": {
"get": {
"operationId": "ReleasesController_upcoming",
@@ -16162,6 +16369,25 @@
"nullable": true,
"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": {
"type": "string",
"format": "date-time"
@@ -16186,6 +16412,9 @@
"decision",
"decidedBy",
"decidedAt",
"resolved",
"resolvedBy",
"resolvedAt",
"createdAt",
"editedAt",
"version"
@@ -16328,6 +16557,16 @@
"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": {
"type": "string",
"enum": [

View File

@@ -310,6 +310,13 @@ type Comment {
"""The top-level comment it answers; null for a top-level one"""
replyTo: ID
"""A conversation (top-level comment and its replies) that was settled"""
resolved: Boolean!
resolvedAt: String
"""Who settled it"""
resolvedBy: UserRef
version: Int!
}
@@ -1049,6 +1056,11 @@ type Mutation {
removeGitRepository(projectId: ID!, repositoryId: ID!): Boolean!
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"""
restoreIssue(issueId: ID!): CommandResult!
@@ -1105,6 +1117,16 @@ type Mutation {
"""
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)"""
unarchiveProject(projectId: ID!): CommandResult!
unlinkIssues(input: LinkIssuesInput!): CommandResult!
@@ -1988,6 +2010,11 @@ type Subscription {
projectChanged(projectId: ID!): ProjectChange!
releaseChanged(projectId: ID!): ReleaseChange!
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 {
@@ -2038,6 +2065,16 @@ type Throughput {
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 {
done: Boolean
issueId: ID!

View File

@@ -1050,6 +1050,27 @@ export interface paths {
patch?: 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": {
parameters: {
query?: never;
@@ -3080,6 +3101,12 @@ export interface components {
decidedBy: components["schemas"]["UserRefDto"] | null;
/** Format: date-time */
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 */
createdAt: string;
/** Format: date-time */
@@ -3144,6 +3171,10 @@ export interface components {
/** @description true adds the reaction, false takes it back */
on: boolean;
};
ResolveThreadDto: {
/** @description What was settled: added as a reply marked as a decision before it closes */
conclusion?: string;
};
/** @enum {string} */
ReleaseStatus: "PLANNED" | "RELEASED" | "ARCHIVED";
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: {
parameters: {
query?: {

View File

@@ -290,6 +290,7 @@ export class IssuesResolver {
by: r.users.map((u) => u.name),
})),
decision: c.decision || undefined,
resolved: c.resolved || undefined,
}))
: undefined;
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({
name: 'search_decisions',
description: