feat: ask a teammate to review a ticket

«Pedir revisión» on a ticket asks a member (with an optional note): they
are notified and see it under «Te piden revisión» in my work until they
answer «Todo bien» or «Pedir cambios» (with what to change) from the
ticket's Revisiones box; whoever asked hears the answer and can withdraw
an open request. Events of the ticket (requested, answered, cancelled),
so they show in its history and the activity feed. Viewers can be asked
and answer; not oneself, nor twice the same person with one pending.

API: requestReview / answerReview / cancelReview, Issue.reviews,
myIssues(reviewing); POST /api/issues/{issue}/reviews,
POST …/reviews/{id}/answer, DELETE …/reviews/{id}. MCP: request_review,
answer_review, my_issues reviewing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 20:38:30 +02:00
parent 172c53d21d
commit 93d028e870
53 changed files with 2836 additions and 54 deletions

View File

@@ -578,6 +578,60 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/issues/{issue}/reviews": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Ask a member to review the ticket
* @description Whoever can change the ticket asks; the reviewer is told and sees it in GET /api/me/issues?reviewing=true until they answer.
*/
post: operations["IssuesController_requestReview"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/issues/{issue}/reviews/{review}/answer": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Answer a review I was asked for (all good, or changes) */
post: operations["IssuesController_answerReview"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/issues/{issue}/reviews/{review}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
/** Withdraw a review request I made */
delete: operations["IssuesController_cancelReview"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/issues/{issue}/follow-tree": {
parameters: {
query?: never;
@@ -2421,6 +2475,25 @@ export interface components {
*/
commentId: string | null;
};
/**
* @description Null while waiting for an answer
* @enum {string}
*/
ReviewVerdict: "APPROVED" | "CHANGES";
IssueReviewDto: {
/** Format: uuid */
id: string;
requester: components["schemas"]["UserRefDto"];
reviewer: components["schemas"]["UserRefDto"];
note: string | null;
/** Format: date-time */
requestedAt: string;
/** @description Null while waiting for an answer */
verdict: components["schemas"]["ReviewVerdict"] | null;
answer: string | null;
/** Format: date-time */
answeredAt: string | null;
};
IssueParentDto: {
/** Format: uuid */
id: string;
@@ -2520,6 +2593,8 @@ export interface components {
attachments: components["schemas"]["AttachmentDto"][];
/** @description People following it */
watchers: components["schemas"]["UserRefDto"][];
/** @description Reviews asked, open and answered, oldest first */
reviews: components["schemas"]["IssueReviewDto"][];
/** @description The epic or ticket this one breaks down */
parent: components["schemas"]["IssueParentDto"] | null;
/** @description Open tickets it waits for: the ones that block it and the ones it depends on */
@@ -3017,6 +3092,16 @@ export interface components {
/** @description What to remember */
note?: string;
};
RequestReviewDto: {
/** @description Reviewer's user id (a member of the project) */
reviewer: string;
/** @description What to look at */
note?: string;
};
AnswerReviewDto: {
verdict: components["schemas"]["ReviewVerdict"];
answer?: string;
};
IssuePageDto: {
items: components["schemas"]["IssueDto"][];
/** @description Total matches (for pagination) */
@@ -3493,7 +3578,7 @@ export interface components {
path: string | null;
};
/** @enum {string} */
NotificationKind: "ASSIGNED" | "STATUS_CHANGED" | "QA_FAILED" | "COMMENTED" | "MENTIONED" | "REPLIED" | "PROPOSED" | "WATCHED" | "ADDED_TO_PROJECT" | "REMINDER";
NotificationKind: "ASSIGNED" | "STATUS_CHANGED" | "QA_FAILED" | "COMMENTED" | "MENTIONED" | "REPLIED" | "PROPOSED" | "WATCHED" | "ADDED_TO_PROJECT" | "REMINDER" | "REVIEW_REQUESTED" | "REVIEW_ANSWERED";
NotificationDto: {
id: string;
kind: components["schemas"]["NotificationKind"];
@@ -6278,6 +6363,244 @@ export interface operations {
};
};
};
IssuesController_requestReview: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Ticket id or key */
issue: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["RequestReviewDto"];
};
};
responses: {
201: {
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"];
};
};
};
};
IssuesController_answerReview: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Ticket id or key */
issue: string;
review: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["AnswerReviewDto"];
};
};
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"];
};
};
};
};
IssuesController_cancelReview: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Ticket id or key */
issue: string;
review: 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"];
};
};
};
};
IssuesController_followTree: {
parameters: {
query?: never;
@@ -6587,6 +6910,8 @@ export interface operations {
assignedToMe?: boolean;
/** @description Only the tickets I follow (with assignedToMe=false, whoever they are assigned to) */
watching?: boolean;
/** @description Only the tickets I was asked to review and have not answered */
reviewing?: boolean;
/** @description Status ids. Repeat the parameter or separate with commas */
status?: string[];
/** @description Whatever each project calls its statuses */

View File

@@ -89,6 +89,12 @@ export class IssuesResolver {
.describe(
'Only the tickets the user follows (use with assignedToMe false)',
),
reviewing: z
.boolean()
.default(false)
.describe(
'Only the tickets the user was asked to review and has not answered (use with assignedToMe false)',
),
categories: z
.array(CATEGORY)
.optional()
@@ -110,6 +116,7 @@ export class IssuesResolver {
params: {
assignedToMe: boolean;
watching: boolean;
reviewing: boolean;
categories?: z.infer<typeof CATEGORY>[];
priorities?: z.infer<typeof PRIORITY>[];
bugsOnly?: boolean;
@@ -125,6 +132,7 @@ export class IssuesResolver {
query: {
assignedToMe: params.assignedToMe,
watching: params.watching,
reviewing: params.reviewing,
category: params.categories ?? ['TODO', 'IN_PROGRESS', 'QA'],
priority: params.priorities,
bugsOnly: params.bugsOnly,
@@ -332,6 +340,14 @@ export class IssuesResolver {
url: d.url,
})),
watchers: issue.watchers.map((w) => w.name),
reviews: issue.reviews.map((r) => ({
id: r.id,
requester: r.requester.name,
reviewer: r.reviewer.name,
note: r.note,
verdict: r.verdict,
answer: r.answer,
})),
attachments: issue.attachments.length,
trashedAt: issue.trashedAt,
createdAt: issue.createdAt,
@@ -783,6 +799,62 @@ export class IssuesResolver {
);
}
@Tool({
name: 'request_review',
description:
'Ask a member of the project to review a ticket (with an optional note): they are told and see it in my_issues with reviewing. reviewer is a user id (get_project lists members).',
paramsSchema: z.object({
issue: issueRef,
reviewer: z.string().min(1),
note: z.string().max(1000).optional(),
}),
annotations: WRITE,
})
requestReview(
params: { issue: string; reviewer: string; note?: string },
ctx: McpContext,
) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).POST('/api/issues/{issue}/reviews', {
params: { path: { issue: params.issue } },
body: { reviewer: params.reviewer, note: params.note },
}),
),
);
}
@Tool({
name: 'answer_review',
description:
'Answer a review the user was asked for: APPROVED (all good) or CHANGES, with an optional answer. Review ids come with get_issue.',
paramsSchema: z.object({
issue: issueRef,
reviewId: z.string().min(1),
verdict: z.enum(['APPROVED', 'CHANGES']),
answer: z.string().max(1000).optional(),
}),
annotations: WRITE,
})
answerReview(
params: {
issue: string;
reviewId: string;
verdict: 'APPROVED' | 'CHANGES';
answer?: string;
},
ctx: McpContext,
) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).POST('/api/issues/{issue}/reviews/{review}/answer', {
params: { path: { issue: params.issue, review: params.reviewId } },
body: { verdict: params.verdict, answer: params.answer },
}),
),
);
}
@Tool({
name: 'my_day',
description: