feat: what is new since my last visit, and turning a comment into a ticket

Opening a ticket notes the visit and answers the one before (issue_visits),
so the page marks others' comments and changes since. A comment can become
a ticket of its own, as a subtask or linked; it remembers where it went.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 21:05:44 +02:00
parent 429b52e6f5
commit 285bd5c454
50 changed files with 1953 additions and 79 deletions

View File

@@ -3683,6 +3683,108 @@
]
}
},
"/api/issues/{issue}/visit": {
"put": {
"description": "Answers when I had opened it before (null: never), to show what others did since. Private to me.",
"operationId": "IssuesController_visit",
"parameters": [
{
"name": "issue",
"required": true,
"in": "path",
"description": "Ticket id or key",
"schema": {
"example": "WEB-12",
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/IssueVisitDto"
}
}
}
},
"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": "I opened this ticket now",
"tags": [
"Issues"
]
}
},
"/api/issues/{issue}/follow-tree": {
"put": {
"description": "Told of its changes and of every ticket hanging from it, at any depth, also the ones added later. Private to me.",
@@ -8436,6 +8538,116 @@
]
}
},
"/api/comments/{commentId}/issue": {
"post": {
"description": "Its text becomes the description, as a subtask of its ticket or linked to it (RELATES_TO); the comment shows where it went. Answers the new ticket. Whoever can change tickets.",
"operationId": "CommentsController_turnIntoIssue",
"parameters": [
{
"name": "commentId",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TurnCommentIntoIssueDto"
}
}
}
},
"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": "Make a ticket of a comment",
"tags": [
"Comments"
]
}
},
"/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.",
@@ -17708,6 +17920,20 @@
"verdict"
]
},
"IssueVisitDto": {
"type": "object",
"properties": {
"previousVisit": {
"type": "string",
"format": "date-time",
"nullable": true,
"description": "My visit before this one (null: first time)"
}
},
"required": [
"previousVisit"
]
},
"IssuePageDto": {
"type": "object",
"properties": {
@@ -18190,6 +18416,22 @@
"winner"
]
},
"CommentSpawnDto": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"key": {
"type": "string",
"example": "WEB-45"
}
},
"required": [
"id",
"key"
]
},
"CommentDto": {
"type": "object",
"properties": {
@@ -18272,6 +18514,13 @@
"nullable": true,
"format": "date-time"
},
"spawned": {
"description": "Tickets made from it («Convertir en ticket»), oldest first",
"type": "array",
"items": {
"$ref": "#/components/schemas/CommentSpawnDto"
}
},
"createdAt": {
"type": "string",
"format": "date-time"
@@ -18300,6 +18549,7 @@
"resolved",
"resolvedBy",
"resolvedAt",
"spawned",
"createdAt",
"editedAt",
"version"
@@ -18476,6 +18726,29 @@
}
}
},
"TurnCommentIntoIssueDto": {
"type": "object",
"properties": {
"title": {
"type": "string",
"example": "También falla en Android",
"maxLength": 200
},
"type": {
"type": "string",
"description": "One of the project's ticket type ids"
},
"asSubtask": {
"type": "boolean",
"default": false,
"description": "Hang it from the ticket of the comment; otherwise it is linked to it (RELATES_TO)"
}
},
"required": [
"title",
"type"
]
},
"ResolveThreadDto": {
"type": "object",
"properties": {

View File

@@ -318,6 +318,9 @@ type Comment {
"""Who settled it"""
resolvedBy: UserRef
"""Tickets made from it («Convertir en ticket»), oldest first"""
spawned: [CommentSpawn!]!
version: Int!
}
@@ -354,6 +357,12 @@ type CommentReaction {
users: [UserRef!]!
}
"""A ticket made from a comment"""
type CommentSpawn {
id: ID!
key: String!
}
type CountByAssignee {
count: Int!
@@ -1209,6 +1218,18 @@ type Mutation {
"""
trashIssue(issueId: ID!): CommandResult!
"""
Make a ticket of a comment: its text becomes the description, as a subtask of its ticket or linked to it (RELATES_TO). Answers the new ticket. Whoever can change tickets
"""
turnCommentIntoIssue(
asSubtask: Boolean! = false
commentId: ID!
title: String!
"""One of the project's ticket type ids"""
type: String!
): CommandResult!
"""
I am writing on this ticket (send every few seconds while typing): those with it open see it at once via `typing`
"""
@@ -1258,6 +1279,11 @@ type Mutation {
updateSprint(input: UpdateSprintInput!): CommandResult!
updateWebhook(input: UpdateWebhookInput!): Webhook!
"""
I opened this ticket now: answers when I had opened it before (null: never), to show what is new since
"""
visitIssue(issueId: ID!): String
"""
Vote for a ticket (on) or take the vote back: anyone signed in on a public project, members on a private one
"""

View File

@@ -632,6 +632,26 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/issues/{issue}/visit": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
/**
* I opened this ticket now
* @description Answers when I had opened it before (null: never), to show what others did since. Private to me.
*/
put: operations["IssuesController_visit"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/issues/{issue}/follow-tree": {
parameters: {
query?: never;
@@ -1241,6 +1261,26 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/comments/{commentId}/issue": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Make a ticket of a comment
* @description Its text becomes the description, as a subtask of its ticket or linked to it (RELATES_TO); the comment shows where it went. Answers the new ticket. Whoever can change tickets.
*/
post: operations["CommentsController_turnIntoIssue"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/comments/{commentId}/resolution": {
parameters: {
query?: never;
@@ -3181,6 +3221,13 @@ export interface components {
verdict: components["schemas"]["ReviewVerdict"];
answer?: string;
};
IssueVisitDto: {
/**
* Format: date-time
* @description My visit before this one (null: first time)
*/
previousVisit: string | null;
};
IssuePageDto: {
items: components["schemas"]["IssueDto"][];
/** @description Total matches (for pagination) */
@@ -3377,6 +3424,11 @@ export interface components {
/** @description Index of the winning option once closed (null on a tie) */
winner: number | null;
};
CommentSpawnDto: {
id: string;
/** @example WEB-45 */
key: string;
};
CommentDto: {
/** Format: uuid */
id: string;
@@ -3407,6 +3459,8 @@ export interface components {
resolvedBy: components["schemas"]["UserRefDto"] | null;
/** Format: date-time */
resolvedAt: string | null;
/** @description Tickets made from it («Convertir en ticket»), oldest first */
spawned: components["schemas"]["CommentSpawnDto"][];
/** Format: date-time */
createdAt: string;
/** Format: date-time */
@@ -3490,6 +3544,17 @@ export interface components {
*/
decide: boolean;
};
TurnCommentIntoIssueDto: {
/** @example También falla en Android */
title: string;
/** @description One of the project's ticket type ids */
type: string;
/**
* @description Hang it from the ticket of the comment; otherwise it is linked to it (RELATES_TO)
* @default false
*/
asSubtask: boolean;
};
ResolveThreadDto: {
/** @description What was settled: added as a reply marked as a decision before it closes */
conclusion?: string;
@@ -6732,6 +6797,82 @@ export interface operations {
};
};
};
IssuesController_visit: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Ticket id or key */
issue: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["IssueVisitDto"];
};
};
/** @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"];
};
};
};
};
IssuesController_followTree: {
parameters: {
query?: never;
@@ -9935,6 +10076,85 @@ export interface operations {
};
};
};
CommentsController_turnIntoIssue: {
parameters: {
query?: never;
header?: never;
path: {
commentId: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["TurnCommentIntoIssueDto"];
};
};
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_resolveThread: {
parameters: {
query?: never;

View File

@@ -599,6 +599,37 @@ export class IssuesResolver {
);
}
@Tool({
name: 'turn_comment_into_issue',
description:
"Make a ticket of a comment that turned out to be work of its own: its text becomes the description, as a subtask of the comment's ticket (asSubtask) or linked to it. Read type ids with get_project. Answers the new ticket's key.",
paramsSchema: z.object({
commentId: z.string().min(1),
title: z.string().min(1).max(200),
type: z.string().min(1).describe("One of the project's ticket type ids"),
asSubtask: z.boolean().default(false),
}),
annotations: WRITE,
})
turnCommentIntoIssue(
{
commentId,
title,
type,
asSubtask,
}: { commentId: string; title: string; type: string; asSubtask: boolean },
ctx: McpContext,
) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).POST('/api/comments/{commentId}/issue', {
params: { path: { commentId } },
body: { title, type, asSubtask },
}),
),
);
}
@Tool({
name: 'link_issues',
description: