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:
@@ -8,6 +8,7 @@ export * from './lib/issue/issue-hierarchy';
|
||||
export * from './lib/issue/issue-checklist';
|
||||
export * from './lib/issue/issue-rank';
|
||||
export * from './lib/issue/dev-link';
|
||||
export * from './lib/issue/review';
|
||||
export * from './lib/project/project-settings';
|
||||
export * from './lib/comment/comment';
|
||||
export * from './lib/release/release';
|
||||
|
||||
@@ -17,6 +17,7 @@ export * from './comment-edited.event';
|
||||
export * from './comment-reacted.event';
|
||||
export * from './comment-decision-marked.event';
|
||||
export * from './comment-thread-resolved.event';
|
||||
export * from './issue-review.events';
|
||||
export * from './comment-deleted.event';
|
||||
export * from './release-created.event';
|
||||
export * from './release-updated.event';
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { AutowiredEvent } from '@task-stream/api/shared/domain';
|
||||
import { ReviewVerdict } from '../issue/review';
|
||||
import { TrackerEvent } from './tracker.event';
|
||||
|
||||
/** Someone asked someone else to review a ticket. */
|
||||
@AutowiredEvent
|
||||
export class IssueReviewRequestedEvent extends TrackerEvent {
|
||||
constructor(
|
||||
actorId: string,
|
||||
issueId: string,
|
||||
projectId: string,
|
||||
readonly reviewId: string,
|
||||
readonly reviewerId: string,
|
||||
readonly note: string | null,
|
||||
) {
|
||||
super(actorId, issueId, projectId);
|
||||
}
|
||||
}
|
||||
|
||||
/** The reviewer answered: all good, or changes wanted. */
|
||||
@AutowiredEvent
|
||||
export class IssueReviewAnsweredEvent extends TrackerEvent {
|
||||
constructor(
|
||||
actorId: string,
|
||||
issueId: string,
|
||||
projectId: string,
|
||||
readonly reviewId: string,
|
||||
readonly verdict: ReviewVerdict,
|
||||
readonly answer: string | null,
|
||||
) {
|
||||
super(actorId, issueId, projectId);
|
||||
}
|
||||
}
|
||||
|
||||
/** A review request was withdrawn before it was answered. */
|
||||
@AutowiredEvent
|
||||
export class IssueReviewCancelledEvent extends TrackerEvent {
|
||||
constructor(
|
||||
actorId: string,
|
||||
issueId: string,
|
||||
projectId: string,
|
||||
readonly reviewId: string,
|
||||
) {
|
||||
super(actorId, issueId, projectId);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
BusinessRuleError,
|
||||
ForbiddenError,
|
||||
NotFoundError,
|
||||
VersionedAggregateRoot,
|
||||
assertThat,
|
||||
} from '@task-stream/api/shared/domain';
|
||||
@@ -41,6 +42,17 @@ import { ParentCandidate, assertCanNest } from './issue-hierarchy';
|
||||
import { DevLink, MAX_DEV_LINKS, normalizeDevLink } from './dev-link';
|
||||
import { implicitRank, rankBetween } from './issue-rank';
|
||||
import { IssueLinkType, IssuePriority } from './issue.enums';
|
||||
import {
|
||||
IssueReview,
|
||||
MAX_OPEN_REVIEWS,
|
||||
MAX_REVIEW_NOTE,
|
||||
ReviewVerdict,
|
||||
} from './review';
|
||||
import {
|
||||
IssueReviewAnsweredEvent,
|
||||
IssueReviewCancelledEvent,
|
||||
IssueReviewRequestedEvent,
|
||||
} from '../events/issue-review.events';
|
||||
|
||||
export interface IssueLink {
|
||||
type: IssueLinkType;
|
||||
@@ -136,6 +148,8 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
||||
public attachments: IssueAttachment[] = [];
|
||||
/** People who asked to be told about this ticket. */
|
||||
public watchers: string[] = [];
|
||||
/** Asked reviews, open and answered, oldest first. */
|
||||
public reviews: IssueReview[] = [];
|
||||
/** People who want it done (a public roadmap's way to prioritise). */
|
||||
public voterIds: string[] = [];
|
||||
/** The epic or ticket this one breaks down. */
|
||||
@@ -404,6 +418,96 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks `reviewerId` to review it. Not oneself, and not twice to the same
|
||||
* person while the first is unanswered. Who may be asked is the caller's
|
||||
* check (a member of the team).
|
||||
*/
|
||||
requestReview(
|
||||
actorId: string,
|
||||
reviewId: string,
|
||||
reviewerId: string,
|
||||
note: string | null,
|
||||
): void {
|
||||
if (this.trashedAt)
|
||||
throw new BusinessRuleError(
|
||||
`${this.key} is in the trash: restore it to change it`,
|
||||
);
|
||||
assertThat(reviewerId !== actorId, 'Ask someone else to review it');
|
||||
const text = note?.trim() || null;
|
||||
assertThat(
|
||||
!text || text.length <= MAX_REVIEW_NOTE,
|
||||
`A review request takes ${MAX_REVIEW_NOTE} characters at most`,
|
||||
);
|
||||
const open = this.reviews.filter((r) => !r.verdict);
|
||||
if (open.some((r) => r.reviewerId === reviewerId))
|
||||
throw new BusinessRuleError(
|
||||
'That person already has an unanswered review request on this ticket',
|
||||
);
|
||||
assertThat(
|
||||
open.length < MAX_OPEN_REVIEWS,
|
||||
`A ticket takes ${MAX_OPEN_REVIEWS} open review requests at most`,
|
||||
);
|
||||
this.apply(
|
||||
new IssueReviewRequestedEvent(
|
||||
actorId,
|
||||
this.id,
|
||||
this.projectId,
|
||||
reviewId,
|
||||
reviewerId,
|
||||
text,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** The reviewer answers an open request of theirs. */
|
||||
answerReview(
|
||||
actorId: string,
|
||||
reviewId: string,
|
||||
verdict: ReviewVerdict,
|
||||
answer: string | null,
|
||||
): void {
|
||||
const review = this.openReview(reviewId);
|
||||
if (review.reviewerId !== actorId)
|
||||
throw new ForbiddenError('Only the person asked can answer the review');
|
||||
const text = answer?.trim() || null;
|
||||
assertThat(
|
||||
!text || text.length <= MAX_REVIEW_NOTE,
|
||||
`An answer takes ${MAX_REVIEW_NOTE} characters at most`,
|
||||
);
|
||||
this.apply(
|
||||
new IssueReviewAnsweredEvent(
|
||||
actorId,
|
||||
this.id,
|
||||
this.projectId,
|
||||
reviewId,
|
||||
verdict,
|
||||
text,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Withdraws an open request: whoever asked, or (`canModerate`) whoever
|
||||
* manages the project.
|
||||
*/
|
||||
cancelReview(actorId: string, reviewId: string, canModerate: boolean): void {
|
||||
const review = this.openReview(reviewId);
|
||||
if (review.requesterId !== actorId && !canModerate)
|
||||
throw new ForbiddenError('Only whoever asked can withdraw the request');
|
||||
this.apply(
|
||||
new IssueReviewCancelledEvent(actorId, this.id, this.projectId, reviewId),
|
||||
);
|
||||
}
|
||||
|
||||
private openReview(reviewId: string): IssueReview {
|
||||
const review = this.reviews.find((r) => r.id === reviewId);
|
||||
if (!review) throw new NotFoundError('Review', reviewId);
|
||||
if (review.verdict)
|
||||
throw new BusinessRuleError('That review was already answered');
|
||||
return review;
|
||||
}
|
||||
|
||||
/** `userId` starts following the ticket. Idempotent. */
|
||||
watch(actorId: string, userId: string): void {
|
||||
if (this.watchers.includes(userId)) return;
|
||||
@@ -625,6 +729,39 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
||||
this.voterIds = event.voterIds;
|
||||
}
|
||||
|
||||
onIssueReviewRequestedEvent(event: IssueReviewRequestedEvent): void {
|
||||
this.reviews = [
|
||||
...(this.reviews ?? []),
|
||||
{
|
||||
id: event.reviewId,
|
||||
requesterId: event.actorId,
|
||||
reviewerId: event.reviewerId,
|
||||
note: event.note,
|
||||
requestedAt: event.occurredAt,
|
||||
verdict: null,
|
||||
answer: null,
|
||||
answeredAt: null,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
onIssueReviewAnsweredEvent(event: IssueReviewAnsweredEvent): void {
|
||||
this.reviews = this.reviews.map((r) =>
|
||||
r.id === event.reviewId
|
||||
? {
|
||||
...r,
|
||||
verdict: event.verdict,
|
||||
answer: event.answer,
|
||||
answeredAt: event.occurredAt,
|
||||
}
|
||||
: r,
|
||||
);
|
||||
}
|
||||
|
||||
onIssueReviewCancelledEvent(event: IssueReviewCancelledEvent): void {
|
||||
this.reviews = this.reviews.filter((r) => r.id !== event.reviewId);
|
||||
}
|
||||
|
||||
onIssueWatchedEvent(event: IssueWatchedEvent): void {
|
||||
this.watchers = [...this.watchers, event.userId];
|
||||
}
|
||||
|
||||
23
libs/api/tracker/domain/src/lib/issue/review.ts
Normal file
23
libs/api/tracker/domain/src/lib/issue/review.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/** What a reviewer said. */
|
||||
export enum ReviewVerdict {
|
||||
APPROVED = 'APPROVED',
|
||||
CHANGES = 'CHANGES',
|
||||
}
|
||||
|
||||
/** «Ana, can you review this?» on a ticket. */
|
||||
export interface IssueReview {
|
||||
id: string;
|
||||
requesterId: string;
|
||||
reviewerId: string;
|
||||
/** What to look at, in a line or two. */
|
||||
note: string | null;
|
||||
requestedAt: string;
|
||||
/** Null while waiting for an answer. */
|
||||
verdict: ReviewVerdict | null;
|
||||
answer: string | null;
|
||||
answeredAt: string | null;
|
||||
}
|
||||
|
||||
export const MAX_REVIEW_NOTE = 1000;
|
||||
/** Open requests a ticket takes at once. */
|
||||
export const MAX_OPEN_REVIEWS = 10;
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ReviewVerdict } from '../issue/review';
|
||||
import { DevLink } from '../issue/dev-link';
|
||||
import { IssueLinkType, IssuePriority } from '../issue/issue.enums';
|
||||
import { StatusCategory } from '../project/project-settings';
|
||||
@@ -85,6 +86,8 @@ export class IssueReadModel {
|
||||
attachments!: AttachmentReadModel[];
|
||||
/** People following the ticket. */
|
||||
watchers!: UserRefReadModel[];
|
||||
/** Asked reviews, open and answered, oldest first. */
|
||||
reviews!: IssueReviewReadModel[];
|
||||
/** How many want it done. */
|
||||
votes!: number;
|
||||
/** Who voted: to tell someone whether they did; never shown as a list. */
|
||||
@@ -103,3 +106,16 @@ export class IssueReadModel {
|
||||
/** Position of the last projected event. */
|
||||
version!: number;
|
||||
}
|
||||
|
||||
/** A review asked on a ticket, as the read side shows it. */
|
||||
export class IssueReviewReadModel {
|
||||
id!: string;
|
||||
requester!: UserRefReadModel;
|
||||
reviewer!: UserRefReadModel;
|
||||
note!: string | null;
|
||||
requestedAt!: string;
|
||||
/** Null while waiting for an answer. */
|
||||
verdict!: ReviewVerdict | null;
|
||||
answer!: string | null;
|
||||
answeredAt!: string | null;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@ export enum NotificationKind {
|
||||
ADDED_TO_PROJECT = 'ADDED_TO_PROJECT',
|
||||
/** A ticket of yours is due soon or has been still for too long. */
|
||||
REMINDER = 'REMINDER',
|
||||
/** Someone asked you to review a ticket. */
|
||||
REVIEW_REQUESTED = 'REVIEW_REQUESTED',
|
||||
/** The review you asked for was answered. */
|
||||
REVIEW_ANSWERED = 'REVIEW_ANSWERED',
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user