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

@@ -18,6 +18,7 @@ import {
LinkIssuesInput,
MyDay,
MyIssuesFilter,
ReviewVerdict,
mergeBulkResults,
UpdateIssueInput,
} from '@task-stream/web/tracker/domain';
@@ -51,6 +52,9 @@ import {
UnwatchIssueDocument,
VoteIssueDocument,
WatchIssueDocument,
RequestReviewDocument,
AnswerReviewDocument,
CancelReviewDocument,
MyPinsDocument,
FollowTreeDocument,
SetReminderDocument,
@@ -115,6 +119,7 @@ export class GraphqlIssuesGateway extends IssuesGateway {
.query(MyIssuesDocument, {
assignedToMe: filter.assignedToMe,
watching: filter.watching ?? false,
reviewing: filter.reviewing ?? false,
categories: filter.categories as never,
limit: filter.limit,
search: filter.search?.trim() || null,
@@ -281,6 +286,38 @@ export class GraphqlIssuesGateway extends IssuesGateway {
return done$.pipe(map(() => undefined));
}
requestReview(
issueId: string,
reviewerId: string,
note: string | null,
): Observable<CommandResult> {
return this.gql
.mutate(RequestReviewDocument, { issueId, reviewerId, note })
.pipe(map((d) => toCommandResult(d.requestReview)));
}
answerReview(
issueId: string,
reviewId: string,
verdict: ReviewVerdict,
answer: string | null,
): Observable<CommandResult> {
return this.gql
.mutate(AnswerReviewDocument, {
issueId,
reviewId,
verdict: verdict as never,
answer,
})
.pipe(map((d) => toCommandResult(d.answerReview)));
}
cancelReview(issueId: string, reviewId: string): Observable<CommandResult> {
return this.gql
.mutate(CancelReviewDocument, { issueId, reviewId })
.pipe(map((d) => toCommandResult(d.cancelReview)));
}
watch(issueId: string, watching: boolean): Observable<CommandResult> {
return watching
? this.gql

View File

@@ -126,6 +126,20 @@ fragment IssueFields on Issue {
at
note
}
reviews {
id
requester {
...UserRefFields
}
reviewer {
...UserRefFields
}
note
requestedAt
verdict
answer
answeredAt
}
decisions {
...CommentFields
}

View File

@@ -269,6 +269,8 @@ export type NotificationKind =
| 'QA_FAILED'
| 'REMINDER'
| 'REPLIED'
| 'REVIEW_ANSWERED'
| 'REVIEW_REQUESTED'
| 'STATUS_CHANGED'
| 'WATCHED';
@@ -312,6 +314,9 @@ export type RankIssueInput = {
export type ReleaseStatus = 'ARCHIVED' | 'PLANNED' | 'RELEASED';
/** APPROVED: all good. CHANGES: something to change */
export type ReviewVerdict = 'APPROVED' | 'CHANGES';
export type SaveFilterInput = {
criteria: SavedFilterCriteriaInput;
name: string;
@@ -715,6 +720,16 @@ export type IssueFieldsFragment = {
dueDate: string | null;
trashedAt: string | null;
myReminder: { at: string; note: string | null } | null;
reviews: Array<{
id: string;
note: string | null;
requestedAt: string;
verdict: ReviewVerdict | null;
answer: string | null;
answeredAt: string | null;
requester: { id: string; name: string };
reviewer: { id: string; name: string };
}>;
decisions: Array<{
id: string;
issueId: string;
@@ -962,6 +977,16 @@ export type IssueQuery = {
dueDate: string | null;
trashedAt: string | null;
myReminder: { at: string; note: string | null } | null;
reviews: Array<{
id: string;
note: string | null;
requestedAt: string;
verdict: ReviewVerdict | null;
answer: string | null;
answeredAt: string | null;
requester: { id: string; name: string };
reviewer: { id: string; name: string };
}>;
decisions: Array<{
id: string;
issueId: string;
@@ -1105,6 +1130,36 @@ export type UnlinkIssuesMutation = {
unlinkIssues: { id: string; key: string | null; version: number };
};
export type RequestReviewMutationVariables = Exact<{
issueId: string;
reviewerId: string;
note?: string | null | undefined;
}>;
export type RequestReviewMutation = {
requestReview: { id: string; key: string | null; version: number };
};
export type AnswerReviewMutationVariables = Exact<{
issueId: string;
reviewId: string;
verdict: ReviewVerdict;
answer?: string | null | undefined;
}>;
export type AnswerReviewMutation = {
answerReview: { id: string; key: string | null; version: number };
};
export type CancelReviewMutationVariables = Exact<{
issueId: string;
reviewId: string;
}>;
export type CancelReviewMutation = {
cancelReview: { id: string; key: string | null; version: number };
};
export type WatchIssueMutationVariables = Exact<{
issueId: string;
}>;
@@ -1347,6 +1402,7 @@ export type DeleteCommentMutation = {
export type MyIssuesQueryVariables = Exact<{
assignedToMe: boolean;
watching?: boolean | null | undefined;
reviewing?: boolean | null | undefined;
categories?: Array<StatusCategory> | StatusCategory | null | undefined;
limit: number;
search?: string | null | undefined;
@@ -3948,6 +4004,47 @@ export const IssueFieldsFragmentDoc = {
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'reviews' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'requester' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'UserRefFields' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'reviewer' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'UserRefFields' },
},
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'note' } },
{ kind: 'Field', name: { kind: 'Name', value: 'requestedAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'verdict' } },
{ kind: 'Field', name: { kind: 'Name', value: 'answer' } },
{ kind: 'Field', name: { kind: 'Name', value: 'answeredAt' } },
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'decisions' },
@@ -6560,6 +6657,47 @@ export const IssueDocument = {
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'reviews' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'requester' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'UserRefFields' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'reviewer' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'UserRefFields' },
},
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'note' } },
{ kind: 'Field', name: { kind: 'Name', value: 'requestedAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'verdict' } },
{ kind: 'Field', name: { kind: 'Name', value: 'answer' } },
{ kind: 'Field', name: { kind: 'Name', value: 'answeredAt' } },
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'decisions' },
@@ -7308,6 +7446,324 @@ export const UnlinkIssuesDocument = {
UnlinkIssuesMutation,
UnlinkIssuesMutationVariables
>;
export const RequestReviewDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'RequestReview' },
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: 'reviewerId' },
},
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
{
kind: 'VariableDefinition',
variable: { kind: 'Variable', name: { kind: 'Name', value: 'note' } },
type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } },
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'requestReview' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'issueId' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'issueId' },
},
},
{
kind: 'Argument',
name: { kind: 'Name', value: 'reviewerId' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'reviewerId' },
},
},
{
kind: 'Argument',
name: { kind: 'Name', value: 'note' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'note' },
},
},
],
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<
RequestReviewMutation,
RequestReviewMutationVariables
>;
export const AnswerReviewDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'AnswerReview' },
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: 'reviewId' },
},
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'verdict' },
},
type: {
kind: 'NonNullType',
type: {
kind: 'NamedType',
name: { kind: 'Name', value: 'ReviewVerdict' },
},
},
},
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'answer' },
},
type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } },
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'answerReview' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'issueId' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'issueId' },
},
},
{
kind: 'Argument',
name: { kind: 'Name', value: 'reviewId' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'reviewId' },
},
},
{
kind: 'Argument',
name: { kind: 'Name', value: 'verdict' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'verdict' },
},
},
{
kind: 'Argument',
name: { kind: 'Name', value: 'answer' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'answer' },
},
},
],
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<
AnswerReviewMutation,
AnswerReviewMutationVariables
>;
export const CancelReviewDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'CancelReview' },
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: 'reviewId' },
},
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'cancelReview' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'issueId' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'issueId' },
},
},
{
kind: 'Argument',
name: { kind: 'Name', value: 'reviewId' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'reviewId' },
},
},
],
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<
CancelReviewMutation,
CancelReviewMutationVariables
>;
export const WatchIssueDocument = {
kind: 'Document',
definitions: [
@@ -8781,6 +9237,14 @@ export const MyIssuesDocument = {
},
type: { kind: 'NamedType', name: { kind: 'Name', value: 'Boolean' } },
},
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'reviewing' },
},
type: { kind: 'NamedType', name: { kind: 'Name', value: 'Boolean' } },
},
{
kind: 'VariableDefinition',
variable: {
@@ -8888,6 +9352,14 @@ export const MyIssuesDocument = {
name: { kind: 'Name', value: 'watching' },
},
},
{
kind: 'Argument',
name: { kind: 'Name', value: 'reviewing' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'reviewing' },
},
},
{
kind: 'Argument',
name: { kind: 'Name', value: 'categories' },

View File

@@ -83,6 +83,34 @@ mutation UnlinkIssues($input: LinkIssuesInput!) {
}
}
mutation RequestReview($issueId: ID!, $reviewerId: ID!, $note: String) {
requestReview(issueId: $issueId, reviewerId: $reviewerId, note: $note) {
...CommandResultFields
}
}
mutation AnswerReview(
$issueId: ID!
$reviewId: ID!
$verdict: ReviewVerdict!
$answer: String
) {
answerReview(
issueId: $issueId
reviewId: $reviewId
verdict: $verdict
answer: $answer
) {
...CommandResultFields
}
}
mutation CancelReview($issueId: ID!, $reviewId: ID!) {
cancelReview(issueId: $issueId, reviewId: $reviewId) {
...CommandResultFields
}
}
mutation WatchIssue($issueId: ID!) {
watchIssue(issueId: $issueId) {
...CommandResultFields
@@ -188,6 +216,7 @@ mutation DeleteComment($commentId: ID!) {
query MyIssues(
$assignedToMe: Boolean!
$watching: Boolean
$reviewing: Boolean
$categories: [StatusCategory!]
$limit: Int!
$search: String
@@ -199,6 +228,7 @@ query MyIssues(
myIssues(
assignedToMe: $assignedToMe
watching: $watching
reviewing: $reviewing
categories: $categories
limit: $limit
search: $search