feat: «Recordármelo», a reminder of a ticket only for me

On a ticket, «Recordármelo» offers in an hour, this evening, tomorrow or
next Monday at 9, in a week, or any date and time, with an optional note.
The ticket shows the waiting one («Te lo recordaré el mié 21 a las 9:00»)
to change or drop. Every minute a scheduler tells the ones whose time
came as a REMINDER notification (bell, push, e-mail), once, and forgets
those whose ticket went to the trash or out of reach. One per person and
ticket, private, no history (Mongo `issue_reminders`).

API: setReminder / clearReminder, Issue.myReminder, myReminders;
PUT/DELETE /api/issues/{issue}/reminder, GET /api/me/reminders. MCP:
remind_me.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 20:08:50 +02:00
parent d79ef418d3
commit b88296c22b
37 changed files with 2020 additions and 50 deletions

View File

@@ -2776,6 +2776,283 @@
]
}
},
"/api/me/reminders": {
"get": {
"operationId": "IssuesController_myReminders",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ReminderOnIssueDto"
}
}
}
}
},
"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"
}
}
}
}
},
"security": [
{
"pireon-oauth": []
},
{
"pireon": []
}
],
"summary": "My waiting reminders on tickets, soonest first",
"tags": [
"Issues"
]
}
},
"/api/issues/{issue}/reminder": {
"put": {
"description": "A notification (bell, push, e-mail) when the time comes. One per ticket: it replaces the waiting one. Private to me.",
"operationId": "IssuesController_setReminder",
"parameters": [
{
"name": "issue",
"required": true,
"in": "path",
"description": "Ticket id or key",
"schema": {
"example": "WEB-12",
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SetReminderDto"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/IssueReminderDto"
}
}
}
},
"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": "Remind me of a ticket at a date and time",
"tags": [
"Issues"
]
},
"delete": {
"operationId": "IssuesController_clearReminder",
"parameters": [
{
"name": "issue",
"required": true,
"in": "path",
"description": "Ticket id or key",
"schema": {
"example": "WEB-12",
"type": "string"
}
}
],
"responses": {
"204": {
"description": ""
},
"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": "Drop my reminder on a ticket",
"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.",
@@ -16062,6 +16339,56 @@
"at"
]
},
"IssueReminderDto": {
"type": "object",
"properties": {
"at": {
"type": "string",
"format": "date-time"
},
"note": {
"type": "string",
"nullable": true
}
},
"required": [
"at",
"note"
]
},
"ReminderOnIssueDto": {
"type": "object",
"properties": {
"reminder": {
"$ref": "#/components/schemas/IssueReminderDto"
},
"issue": {
"$ref": "#/components/schemas/IssueDto"
}
},
"required": [
"reminder",
"issue"
]
},
"SetReminderDto": {
"type": "object",
"properties": {
"at": {
"type": "string",
"format": "date-time",
"description": "When to tell me; in the future, at most a year ahead"
},
"note": {
"type": "string",
"maxLength": 280,
"description": "What to remember"
}
},
"required": [
"at"
]
},
"IssuePageDto": {
"type": "object",
"properties": {

View File

@@ -705,6 +705,9 @@ type Issue {
"""Linked tickets I can see"""
linkedIssues: [Issue!]!
links: [IssueLink!]!
"""My waiting reminder on it (null without one or a token)"""
myReminder: IssueReminder
number: Int!
"""The epic or ticket this one breaks down"""
@@ -845,6 +848,13 @@ enum IssuePriority {
MEDIUM
}
"""My own reminder on a ticket («remind me on Thursday»)"""
type IssueReminder {
"""When it tells me (ISO, UTC)"""
at: String!
note: String
}
"""RECENT: last updated first. RANK: backlog order."""
enum IssueSort {
RANK
@@ -971,6 +981,9 @@ type Mutation {
changeIssueStatus(input: ChangeIssueStatusInput!): CommandResult!
changeProjectMemberRole(input: ProjectMemberInput!): CommandResult!
"""Drop my reminder on a ticket"""
clearReminder(issueId: ID!): Boolean!
"""
Close the sprint under way. Unfinished tickets move to carryOverToSprintId, or back to the backlog when it is null.
"""
@@ -1103,6 +1116,16 @@ type Mutation {
"""Put a ticket in a sprint (null: back to the backlog)"""
setIssueSprint(issueId: ID!, sprintId: ID): CommandResult!
"""
Remind me of a ticket at a date and time (a notification), with an optional note. One per ticket: it replaces the waiting one
"""
setReminder(
"""ISO date-time, in the future"""
at: String!
issueId: ID!
note: String
): IssueReminder!
"""Turn the link on or change its blocks; renew for a new secret"""
shareDashboard(input: ShareDashboardInput!): DashboardShare!
@@ -1645,6 +1668,9 @@ type Query {
"""Projects where I am a member"""
myProjects: [Project!]!
"""My waiting reminders on tickets, soonest first"""
myReminders: [ReminderOnIssue!]!
"""My saved views of my work, across my projects"""
myViews: [MyView!]!
notificationSettings: NotificationSettings!
@@ -1779,6 +1805,12 @@ enum ReleaseStatus {
RELEASED
}
"""One of my waiting reminders, with its ticket"""
type ReminderOnIssue {
issue: Issue!
reminder: IssueReminder!
}
"""Tickets to look at now (worst first)"""
type Risks {
blocked: [InsightIssue!]!

View File

@@ -502,6 +502,44 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/me/reminders": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** My waiting reminders on tickets, soonest first */
get: operations["IssuesController_myReminders"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/issues/{issue}/reminder": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
/**
* Remind me of a ticket at a date and time
* @description A notification (bell, push, e-mail) when the time comes. One per ticket: it replaces the waiting one. Private to me.
*/
put: operations["IssuesController_setReminder"];
post?: never;
/** Drop my reminder on a ticket */
delete: operations["IssuesController_clearReminder"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/issues/{issue}/follow-tree": {
parameters: {
query?: never;
@@ -2913,6 +2951,24 @@ export interface components {
*/
at: string;
};
IssueReminderDto: {
/** Format: date-time */
at: string;
note: string | null;
};
ReminderOnIssueDto: {
reminder: components["schemas"]["IssueReminderDto"];
issue: components["schemas"]["IssueDto"];
};
SetReminderDto: {
/**
* Format: date-time
* @description When to tell me; in the future, at most a year ahead
*/
at: string;
/** @description What to remember */
note?: string;
};
IssuePageDto: {
items: components["schemas"]["IssueDto"][];
/** @description Total matches (for pagination) */
@@ -5753,6 +5809,215 @@ export interface operations {
};
};
};
IssuesController_myReminders: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ReminderOnIssueDto"][];
};
};
/** @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"];
};
};
};
};
IssuesController_setReminder: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Ticket id or key */
issue: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SetReminderDto"];
};
};
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["IssueReminderDto"];
};
};
/** @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_clearReminder: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Ticket id or key */
issue: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @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;

View File

@@ -783,6 +783,41 @@ export class IssuesResolver {
);
}
@Tool({
name: 'remind_me',
description:
'Remind me of a ticket at a date and time (a notification), with an optional note; one per ticket, it replaces the waiting one. clear: true drops it. Times are ISO with offset, e.g. 2026-10-23T09:00:00+02:00.',
paramsSchema: z.object({
issue: issueRef,
at: z.string().optional(),
note: z.string().max(280).optional(),
clear: z.boolean().default(false),
}),
annotations: { ...WRITE, idempotentHint: true },
})
remindMe(
params: { issue: string; at?: string; note?: string; clear: boolean },
ctx: McpContext,
) {
const client = this.api.as(ctx);
const path = { issue: params.issue };
return toolResult(async () => {
if (params.clear) {
await this.api.data(
client.DELETE('/api/issues/{issue}/reminder', { params: { path } }),
);
return { issue: params.issue, reminder: null };
}
if (!params.at) throw new Error('at is required unless clear is true');
return this.api.data(
client.PUT('/api/issues/{issue}/reminder', {
params: { path },
body: { at: params.at, note: params.note },
}),
);
});
}
@Tool({
name: 'resolve_thread',
description: