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:
@@ -15,6 +15,7 @@ import {
|
||||
StatusCategory,
|
||||
DevLinkKind,
|
||||
DevLinkState,
|
||||
ReviewVerdict,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
|
||||
export enum LinkDirection {
|
||||
@@ -23,6 +24,10 @@ export enum LinkDirection {
|
||||
}
|
||||
|
||||
registerEnumType(IssuePriority, { name: 'IssuePriority' });
|
||||
registerEnumType(ReviewVerdict, {
|
||||
name: 'ReviewVerdict',
|
||||
description: 'APPROVED: all good. CHANGES: something to change',
|
||||
});
|
||||
registerEnumType(CustomFieldType, {
|
||||
name: 'CustomFieldType',
|
||||
description: 'What a custom field holds (DATE: YYYY-MM-DD)',
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import {
|
||||
IssuePriority,
|
||||
IssueReadModel,
|
||||
ReviewVerdict,
|
||||
StatusCategory,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import {
|
||||
@@ -168,6 +169,11 @@ export class IssueResolver {
|
||||
description: 'Only the tickets I follow',
|
||||
})
|
||||
watching: boolean,
|
||||
@Args('reviewing', {
|
||||
defaultValue: false,
|
||||
description: 'Only the tickets I was asked to review (unanswered)',
|
||||
})
|
||||
reviewing: boolean,
|
||||
@Args('statuses', { type: () => [String], nullable: true })
|
||||
statuses: string[] | undefined,
|
||||
@Args('categories', { type: () => [StatusCategory], nullable: true })
|
||||
@@ -204,6 +210,7 @@ export class IssueResolver {
|
||||
return this.issuesService.mine(me.id, {
|
||||
assignedToMe,
|
||||
watching,
|
||||
reviewing,
|
||||
statuses: statuses ?? undefined,
|
||||
categories: categories ?? undefined,
|
||||
search: search ?? undefined,
|
||||
@@ -508,6 +515,54 @@ export class IssueResolver {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql, {
|
||||
description:
|
||||
'Ask a member to review a ticket (with an optional note): they are told and see it in their work',
|
||||
})
|
||||
requestReview(
|
||||
@CurrentUser() me: User,
|
||||
@Args('issueId', { type: () => ID }) issueId: string,
|
||||
@Args('reviewerId', { type: () => ID }) reviewerId: string,
|
||||
@Args('note', { nullable: true }) note: string | null,
|
||||
) {
|
||||
return this.issuesService.requestReview(
|
||||
me.id,
|
||||
issueId,
|
||||
reviewerId,
|
||||
note ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql, {
|
||||
description: 'Answer a review I was asked for: all good, or changes',
|
||||
})
|
||||
answerReview(
|
||||
@CurrentUser() me: User,
|
||||
@Args('issueId', { type: () => ID }) issueId: string,
|
||||
@Args('reviewId', { type: () => ID }) reviewId: string,
|
||||
@Args('verdict', { type: () => ReviewVerdict }) verdict: ReviewVerdict,
|
||||
@Args('answer', { nullable: true }) answer: string | null,
|
||||
) {
|
||||
return this.issuesService.answerReview(
|
||||
me.id,
|
||||
issueId,
|
||||
reviewId,
|
||||
verdict,
|
||||
answer ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql, {
|
||||
description: 'Withdraw a review request I made (or, as admin, any)',
|
||||
})
|
||||
cancelReview(
|
||||
@CurrentUser() me: User,
|
||||
@Args('issueId', { type: () => ID }) issueId: string,
|
||||
@Args('reviewId', { type: () => ID }) reviewId: string,
|
||||
) {
|
||||
return this.issuesService.cancelReview(me.id, issueId, reviewId);
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql, { description: 'Stop following a ticket' })
|
||||
unwatchIssue(
|
||||
@CurrentUser() me: User,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
StatusCategory,
|
||||
DevLinkKind,
|
||||
DevLinkState,
|
||||
ReviewVerdict,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import {
|
||||
AutomationActionKind,
|
||||
@@ -371,6 +372,24 @@ export class IssueParentGql {
|
||||
@Field() title!: string;
|
||||
}
|
||||
|
||||
@ObjectType('IssueReview', {
|
||||
description: 'A review someone asked on the ticket',
|
||||
})
|
||||
export class IssueReviewGql {
|
||||
@Field(() => ID) id!: string;
|
||||
@Field(() => UserRefGql) requester!: UserRefGql;
|
||||
@Field(() => UserRefGql) reviewer!: UserRefGql;
|
||||
@Field(() => String, { nullable: true }) note!: string | null;
|
||||
@Field() requestedAt!: string;
|
||||
@Field(() => ReviewVerdict, {
|
||||
nullable: true,
|
||||
description: 'Null while waiting for an answer',
|
||||
})
|
||||
verdict!: ReviewVerdict | null;
|
||||
@Field(() => String, { nullable: true }) answer!: string | null;
|
||||
@Field(() => String, { nullable: true }) answeredAt!: string | null;
|
||||
}
|
||||
|
||||
@ObjectType('Issue')
|
||||
export class IssueGql {
|
||||
@Field(() => ID) id!: string;
|
||||
@@ -408,6 +427,10 @@ export class IssueGql {
|
||||
@Field(() => [AttachmentGql]) attachments!: AttachmentGql[];
|
||||
@Field(() => [UserRefGql], { description: 'People following the ticket' })
|
||||
watchers!: UserRefGql[];
|
||||
@Field(() => [IssueReviewGql], {
|
||||
description: 'Reviews asked, open and answered, oldest first',
|
||||
})
|
||||
reviews!: IssueReviewGql[];
|
||||
@Field(() => IssueParentGql, {
|
||||
nullable: true,
|
||||
description: 'The epic or ticket this one breaks down',
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
IssuePriority,
|
||||
ProjectRole,
|
||||
REACTIONS,
|
||||
ReviewVerdict,
|
||||
SETTING_COLOR_PATTERN,
|
||||
StatusCategory,
|
||||
TYPE_ICONS,
|
||||
@@ -555,6 +556,14 @@ export class MyIssuesQueryDto extends PageQueryDto {
|
||||
@IsBoolean()
|
||||
watching?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Only the tickets I was asked to review and have not answered',
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(toBoolean)
|
||||
@IsBoolean()
|
||||
reviewing?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: [String],
|
||||
description: 'Status ids. Repeat the parameter or separate with commas',
|
||||
@@ -1552,6 +1561,30 @@ export class PushEndpointDto {
|
||||
endpoint!: string;
|
||||
}
|
||||
|
||||
export class RequestReviewDto {
|
||||
@ApiProperty({ description: "Reviewer's user id (a member of the project)" })
|
||||
@IsString()
|
||||
reviewer!: string;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 1000, description: 'What to look at' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(1000)
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export class AnswerReviewDto {
|
||||
@ApiProperty({ enum: ReviewVerdict, enumName: 'ReviewVerdict' })
|
||||
@IsEnum(ReviewVerdict)
|
||||
verdict!: ReviewVerdict;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 1000 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(1000)
|
||||
answer?: string;
|
||||
}
|
||||
|
||||
export class MyDayQueryDto {
|
||||
@ApiProperty({ example: '2026-10-20', description: 'Today, in my time zone' })
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
CustomFieldType,
|
||||
IssueLinkType,
|
||||
IssuePriority,
|
||||
ReviewVerdict,
|
||||
NotificationKind,
|
||||
ProjectRole,
|
||||
ReleaseStatus,
|
||||
@@ -481,6 +482,11 @@ export class IssueDto {
|
||||
@ApiProperty({ type: [AttachmentDto] }) attachments!: AttachmentDto[];
|
||||
@ApiProperty({ type: [UserRefDto], description: 'People following it' })
|
||||
watchers!: UserRefDto[];
|
||||
@ApiProperty({
|
||||
type: () => [IssueReviewDto],
|
||||
description: 'Reviews asked, open and answered, oldest first',
|
||||
})
|
||||
reviews!: IssueReviewDto[];
|
||||
@ApiProperty({
|
||||
type: IssueParentDto,
|
||||
nullable: true,
|
||||
@@ -1439,3 +1445,21 @@ export class MyDayDto {
|
||||
@ApiProperty({ type: MyDayLeftoverDto, nullable: true })
|
||||
leftover!: MyDayLeftoverDto | null;
|
||||
}
|
||||
|
||||
export class IssueReviewDto {
|
||||
@ApiProperty({ format: 'uuid' }) id!: string;
|
||||
@ApiProperty({ type: UserRefDto }) requester!: UserRefDto;
|
||||
@ApiProperty({ type: UserRefDto }) reviewer!: UserRefDto;
|
||||
@ApiProperty({ type: String, nullable: true }) note!: string | null;
|
||||
@ApiProperty({ format: 'date-time' }) requestedAt!: string;
|
||||
@ApiProperty({
|
||||
enum: ReviewVerdict,
|
||||
enumName: 'ReviewVerdict',
|
||||
nullable: true,
|
||||
description: 'Null while waiting for an answer',
|
||||
})
|
||||
verdict!: ReviewVerdict | null;
|
||||
@ApiProperty({ type: String, nullable: true }) answer!: string | null;
|
||||
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
|
||||
answeredAt!: string | null;
|
||||
}
|
||||
|
||||
@@ -50,6 +50,8 @@ import {
|
||||
CreateIssueDto,
|
||||
DecisionsQueryDto,
|
||||
SetReminderDto,
|
||||
RequestReviewDto,
|
||||
AnswerReviewDto,
|
||||
MyDayQueryDto,
|
||||
IssueSearchQueryDto,
|
||||
PresenceBodyDto,
|
||||
@@ -316,6 +318,70 @@ export class IssuesController {
|
||||
await this.reminders.clear(me.id, await this.refs.issueId(me.id, issue));
|
||||
}
|
||||
|
||||
@Post('issues/:issue/reviews')
|
||||
@ISSUE
|
||||
@ApiOperation({
|
||||
summary: '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.',
|
||||
})
|
||||
@ApiCreatedResponse({ type: CommandResultDto })
|
||||
@ApiCommandErrors()
|
||||
async requestReview(
|
||||
@CurrentUser() me: User,
|
||||
@Param('issue') issue: string,
|
||||
@Body() body: RequestReviewDto,
|
||||
) {
|
||||
return this.issues.requestReview(
|
||||
me.id,
|
||||
await this.refs.issueId(me.id, issue),
|
||||
body.reviewer,
|
||||
body.note ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('issues/:issue/reviews/:review/answer')
|
||||
@HttpCode(200)
|
||||
@ISSUE
|
||||
@ApiParam({ name: 'review', format: 'uuid' })
|
||||
@ApiOperation({
|
||||
summary: 'Answer a review I was asked for (all good, or changes)',
|
||||
})
|
||||
@ApiOkResponse({ type: CommandResultDto })
|
||||
@ApiCommandErrors()
|
||||
async answerReview(
|
||||
@CurrentUser() me: User,
|
||||
@Param('issue') issue: string,
|
||||
@Param('review') review: string,
|
||||
@Body() body: AnswerReviewDto,
|
||||
) {
|
||||
return this.issues.answerReview(
|
||||
me.id,
|
||||
await this.refs.issueId(me.id, issue),
|
||||
review,
|
||||
body.verdict,
|
||||
body.answer ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
@Delete('issues/:issue/reviews/:review')
|
||||
@ISSUE
|
||||
@ApiParam({ name: 'review', format: 'uuid' })
|
||||
@ApiOperation({ summary: 'Withdraw a review request I made' })
|
||||
@ApiOkResponse({ type: CommandResultDto })
|
||||
@ApiCommandErrors()
|
||||
async cancelReview(
|
||||
@CurrentUser() me: User,
|
||||
@Param('issue') issue: string,
|
||||
@Param('review') review: string,
|
||||
) {
|
||||
return this.issues.cancelReview(
|
||||
me.id,
|
||||
await this.refs.issueId(me.id, issue),
|
||||
review,
|
||||
);
|
||||
}
|
||||
|
||||
@Put('issues/:issue/follow-tree')
|
||||
@HttpCode(204)
|
||||
@ISSUE
|
||||
@@ -390,6 +456,7 @@ export class IssuesController {
|
||||
const page = await this.issues.mine(me.id, {
|
||||
assignedToMe: query.assignedToMe,
|
||||
watching: query.watching,
|
||||
reviewing: query.reviewing,
|
||||
statuses: query.status,
|
||||
categories: query.category,
|
||||
search: query.search,
|
||||
|
||||
Reference in New Issue
Block a user