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:
@@ -49,6 +49,7 @@ export * from './lib/issue-reminders.service';
|
||||
export * from './lib/my-day/my-day';
|
||||
export * from './lib/ports/my-days.repository';
|
||||
export * from './lib/my-day.service';
|
||||
export * from './lib/commands/reviews';
|
||||
export * from './lib/tracker-application.module';
|
||||
export * from './lib/ports/presence.store';
|
||||
export * from './lib/presence.service';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ReactToCommentCommandHandler } from './react-to-comment.command-handler';
|
||||
import { MarkDecisionCommandHandler } from './mark-decision.command-handler';
|
||||
import { ResolveThreadCommandHandler } from './resolve-thread.command-handler';
|
||||
import { REVIEW_HANDLERS } from './reviews';
|
||||
import { VoteIssueCommandHandler } from './vote-issue.command-handler';
|
||||
import { CreateProjectCommandHandler } from './create-project.command-handler';
|
||||
import { UpdateProjectCommandHandler } from './update-project.command-handler';
|
||||
@@ -50,6 +51,7 @@ export const COMMAND_HANDLERS = [
|
||||
ReactToCommentCommandHandler,
|
||||
MarkDecisionCommandHandler,
|
||||
ResolveThreadCommandHandler,
|
||||
...REVIEW_HANDLERS,
|
||||
CreateProjectCommandHandler,
|
||||
UpdateProjectCommandHandler,
|
||||
UpdateProjectSettingsCommandHandler,
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
Project,
|
||||
IssuePriority,
|
||||
IssueType,
|
||||
ReviewVerdict,
|
||||
ProjectRole,
|
||||
TRACKER_FACTORIES,
|
||||
defaultProjectSettings,
|
||||
@@ -79,6 +80,11 @@ import { AddCommentCommand } from './add-comment.command';
|
||||
import { ReactToCommentCommand } from './react-to-comment.command';
|
||||
import { MarkDecisionCommand } from './mark-decision.command';
|
||||
import { ResolveThreadCommand } from './resolve-thread.command';
|
||||
import {
|
||||
AnswerReviewCommand,
|
||||
CancelReviewCommand,
|
||||
RequestReviewCommand,
|
||||
} from './reviews';
|
||||
import { UpdateProjectCommand } from './update-project.command';
|
||||
import { VoteIssueCommand } from './vote-issue.command';
|
||||
import { AddProjectMemberCommand } from './add-project-member.command';
|
||||
@@ -944,6 +950,74 @@ describe('tracker commands (event sourced)', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('asks a teammate to review a ticket, who answers it', async () => {
|
||||
const { run, rehydrator } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
const issue = await run(
|
||||
new CreateIssueCommand('dev', projectId, IssueType.TASK, 'Login', {}),
|
||||
);
|
||||
await run(
|
||||
new RequestReviewCommand('dev', issue.id, 'qa', ' Mira el móvil '),
|
||||
);
|
||||
// Not oneself, not twice the same open one, not outsiders.
|
||||
await expect(
|
||||
run(new RequestReviewCommand('dev', issue.id, 'dev')),
|
||||
).rejects.toThrow(ValidationError);
|
||||
await expect(
|
||||
run(new RequestReviewCommand('dev', issue.id, 'qa')),
|
||||
).rejects.toThrow(BusinessRuleError);
|
||||
await expect(
|
||||
run(new RequestReviewCommand('dev', issue.id, 'stranger')),
|
||||
).rejects.toThrow(BusinessRuleError);
|
||||
// A viewer can be asked and answer, but not ask.
|
||||
await run(new RequestReviewCommand('dev', issue.id, 'pm'));
|
||||
await expect(
|
||||
run(new RequestReviewCommand('pm', issue.id, 'qa')),
|
||||
).rejects.toThrow(ForbiddenError);
|
||||
|
||||
let reviews = (await rehydrator.rehydrate(issue.id, Issue)).reviews;
|
||||
expect(reviews.map((r) => [r.reviewerId, r.note, r.verdict])).toEqual([
|
||||
['qa', 'Mira el móvil', null],
|
||||
['pm', null, null],
|
||||
]);
|
||||
const [forQa, forPm] = reviews;
|
||||
await expect(
|
||||
run(
|
||||
new AnswerReviewCommand(
|
||||
'dev',
|
||||
issue.id,
|
||||
forQa.id,
|
||||
ReviewVerdict.APPROVED,
|
||||
),
|
||||
),
|
||||
).rejects.toThrow(ForbiddenError);
|
||||
await run(
|
||||
new AnswerReviewCommand(
|
||||
'qa',
|
||||
issue.id,
|
||||
forQa.id,
|
||||
ReviewVerdict.CHANGES,
|
||||
'Falla en iOS',
|
||||
),
|
||||
);
|
||||
await expect(
|
||||
run(
|
||||
new AnswerReviewCommand(
|
||||
'qa',
|
||||
issue.id,
|
||||
forQa.id,
|
||||
ReviewVerdict.APPROVED,
|
||||
),
|
||||
),
|
||||
).rejects.toThrow(BusinessRuleError);
|
||||
await run(new CancelReviewCommand('dev', issue.id, forPm.id));
|
||||
|
||||
reviews = (await rehydrator.rehydrate(issue.id, Issue)).reviews;
|
||||
expect(reviews.map((r) => [r.reviewerId, r.verdict, r.answer])).toEqual([
|
||||
['qa', ReviewVerdict.CHANGES, 'Falla en iOS'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('settles a conversation, keeping its conclusion as a decision', async () => {
|
||||
const { run, rehydrator, store } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
|
||||
132
libs/api/tracker/application/src/lib/commands/reviews.ts
Normal file
132
libs/api/tracker/application/src/lib/commands/reviews.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Command, CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
BusinessRuleError,
|
||||
ForbiddenError,
|
||||
} from '@task-stream/api/shared/domain';
|
||||
import { ProjectRole, ReviewVerdict } from '@task-stream/api/tracker/domain';
|
||||
import { CommandResult, ack } from './command-result';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
/*
|
||||
* «Ana, can you review this?»: a request on a ticket that the reviewer
|
||||
* answers (all good, or changes wanted). Events of the ticket, so they show
|
||||
* in its history and in the activity feed.
|
||||
*/
|
||||
|
||||
export class RequestReviewCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly issueId: string,
|
||||
public readonly reviewerId: string,
|
||||
public readonly note: string | null = null,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class AnswerReviewCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly issueId: string,
|
||||
public readonly reviewId: string,
|
||||
public readonly verdict: ReviewVerdict,
|
||||
public readonly answer: string | null = null,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class CancelReviewCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly issueId: string,
|
||||
public readonly reviewId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
/** The team asks for reviews: whoever can change the ticket. */
|
||||
@CommandHandler(RequestReviewCommand)
|
||||
export class RequestReviewCommandHandler
|
||||
implements ICommandHandler<RequestReviewCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: RequestReviewCommand): Promise<CommandResult> {
|
||||
const { issue, project } = await this.access.issueForContributor(
|
||||
command.issueId,
|
||||
command.actorId,
|
||||
);
|
||||
// Someone who reads the whole project: a guest would not see the ticket.
|
||||
const role = project.roleOf(command.reviewerId);
|
||||
if (!role || (role === ProjectRole.GUEST && !project.isPublic))
|
||||
throw new BusinessRuleError(
|
||||
`Only members of ${project.key} can be asked to review`,
|
||||
);
|
||||
issue.requestReview(
|
||||
command.actorId,
|
||||
randomUUID(),
|
||||
command.reviewerId,
|
||||
command.note,
|
||||
);
|
||||
await issue.commitAndWait();
|
||||
return ack(issue);
|
||||
}
|
||||
}
|
||||
|
||||
/** The person asked answers; reading the ticket is enough (viewers too). */
|
||||
@CommandHandler(AnswerReviewCommand)
|
||||
export class AnswerReviewCommandHandler
|
||||
implements ICommandHandler<AnswerReviewCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: AnswerReviewCommand): Promise<CommandResult> {
|
||||
const issue = await this.access.issue(command.issueId);
|
||||
const project = await this.access.project(issue.projectId);
|
||||
if (!project.isMember(command.actorId))
|
||||
throw new ForbiddenError(
|
||||
`You are not a member of project ${project.key}`,
|
||||
);
|
||||
issue.answerReview(
|
||||
command.actorId,
|
||||
command.reviewId,
|
||||
command.verdict,
|
||||
command.answer,
|
||||
);
|
||||
await issue.commitAndWait();
|
||||
return ack(issue);
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(CancelReviewCommand)
|
||||
export class CancelReviewCommandHandler
|
||||
implements ICommandHandler<CancelReviewCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: CancelReviewCommand): Promise<CommandResult> {
|
||||
const issue = await this.access.issue(command.issueId);
|
||||
const project = await this.access.project(issue.projectId);
|
||||
if (!project.isMember(command.actorId))
|
||||
throw new ForbiddenError(
|
||||
`You are not a member of project ${project.key}`,
|
||||
);
|
||||
const role = project.roleOf(command.actorId);
|
||||
issue.cancelReview(
|
||||
command.actorId,
|
||||
command.reviewId,
|
||||
role === ProjectRole.OWNER || role === ProjectRole.ADMIN,
|
||||
);
|
||||
await issue.commitAndWait();
|
||||
return ack(issue);
|
||||
}
|
||||
}
|
||||
|
||||
export const REVIEW_HANDLERS = [
|
||||
RequestReviewCommandHandler,
|
||||
AnswerReviewCommandHandler,
|
||||
CancelReviewCommandHandler,
|
||||
];
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
IssueChecklistItemRemovedEvent,
|
||||
IssueChecklistItemUpdatedEvent,
|
||||
IssueParentChangedEvent,
|
||||
IssueReviewAnsweredEvent,
|
||||
IssueReviewCancelledEvent,
|
||||
IssueReviewRequestedEvent,
|
||||
ReviewVerdict,
|
||||
IssueDevLinkRecordedEvent,
|
||||
IssueTrashedEvent,
|
||||
IssueRestoredEvent,
|
||||
@@ -191,6 +195,14 @@ export function describeEvent(
|
||||
: 'abierta';
|
||||
return `Pull request ${ref} ${verb} para ${issueKey}${who}`;
|
||||
}
|
||||
if (event instanceof IssueReviewRequestedEvent)
|
||||
return `${actor} pidió a ${ctx.user(event.reviewerId)} que revise ${issueKey}`;
|
||||
if (event instanceof IssueReviewAnsweredEvent)
|
||||
return event.verdict === ReviewVerdict.APPROVED
|
||||
? `${actor} dio por bueno ${issueKey} al revisarlo`
|
||||
: `${actor} pidió cambios en ${issueKey} al revisarlo`;
|
||||
if (event instanceof IssueReviewCancelledEvent)
|
||||
return `${actor} retiró una petición de revisión de ${issueKey}`;
|
||||
if (event instanceof IssueParentChangedEvent) {
|
||||
return event.parentKey
|
||||
? `${actor} colocó ${issueKey} bajo ${event.parentKey}`
|
||||
|
||||
@@ -11,6 +11,9 @@ import {
|
||||
IssueChecklistItemRemovedEvent,
|
||||
IssueChecklistItemUpdatedEvent,
|
||||
IssueParentChangedEvent,
|
||||
IssueReviewAnsweredEvent,
|
||||
IssueReviewCancelledEvent,
|
||||
IssueReviewRequestedEvent,
|
||||
IssueDevLinkRecordedEvent,
|
||||
IssueTrashedEvent,
|
||||
IssueRestoredEvent,
|
||||
@@ -75,6 +78,9 @@ import { versionOf } from './read-model-notifier';
|
||||
IssueAttachmentAddedEvent,
|
||||
IssueAttachmentRemovedEvent,
|
||||
IssueParentChangedEvent,
|
||||
IssueReviewRequestedEvent,
|
||||
IssueReviewAnsweredEvent,
|
||||
IssueReviewCancelledEvent,
|
||||
IssueDevLinkRecordedEvent,
|
||||
IssueTrashedEvent,
|
||||
IssueRestoredEvent,
|
||||
@@ -127,6 +133,7 @@ export class ActivityEventHandler implements IEventHandler<TrackerEvent> {
|
||||
event.actorId,
|
||||
(event as { userId?: string }).userId,
|
||||
(event as { assigneeId?: string }).assigneeId,
|
||||
(event as { reviewerId?: string }).reviewerId,
|
||||
];
|
||||
const users = await this.users.refs(mentioned);
|
||||
const actorOf = (id: string) =>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { CommentEditedEventHandler } from './comment-edited.event-handler';
|
||||
import { CommentReactedEventHandler } from './comment-reacted.event-handler';
|
||||
import { CommentDecisionMarkedEventHandler } from './comment-decision-marked.event-handler';
|
||||
import { CommentThreadResolvedEventHandler } from './comment-thread-resolved.event-handler';
|
||||
import { IssueReviewsEventHandler } from './issue-reviews.event-handler';
|
||||
import { IssueVotedEventHandler } from './issue-voted.event-handler';
|
||||
import { IssueAssignedEventHandler } from './issue-assigned.event-handler';
|
||||
import { IssueCreatedEventHandler } from './issue-created.event-handler';
|
||||
@@ -54,6 +55,7 @@ export const EVENT_HANDLERS = [
|
||||
CommentReactedEventHandler,
|
||||
CommentDecisionMarkedEventHandler,
|
||||
CommentThreadResolvedEventHandler,
|
||||
IssueReviewsEventHandler,
|
||||
IssueVotedEventHandler,
|
||||
IssueAssignedEventHandler,
|
||||
IssueCreatedEventHandler,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
IssueReviewAnsweredEvent,
|
||||
IssueReviewCancelledEvent,
|
||||
IssueReviewRequestedEvent,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
|
||||
import { UserDirectory } from '../ports/user-directory';
|
||||
import { ReadModelNotifier, versionOf } from './read-model-notifier';
|
||||
|
||||
type ReviewEvent =
|
||||
| IssueReviewRequestedEvent
|
||||
| IssueReviewAnsweredEvent
|
||||
| IssueReviewCancelledEvent;
|
||||
|
||||
/** The ticket's review requests, as its page and «my work» show them. */
|
||||
@EventsHandler(
|
||||
IssueReviewRequestedEvent,
|
||||
IssueReviewAnsweredEvent,
|
||||
IssueReviewCancelledEvent,
|
||||
)
|
||||
export class IssueReviewsEventHandler implements IEventHandler<ReviewEvent> {
|
||||
constructor(
|
||||
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
|
||||
private readonly findIssues: FindIssuesRepository,
|
||||
private readonly users: UserDirectory,
|
||||
private readonly notifier: ReadModelNotifier,
|
||||
) {}
|
||||
|
||||
async handle(event: ReviewEvent) {
|
||||
const issue = await this.findIssues.findById(event.aggregateId);
|
||||
if (!issue) return;
|
||||
// Events of a stream come in order: the list read is the one before.
|
||||
let reviews = issue.reviews ?? [];
|
||||
if (event instanceof IssueReviewRequestedEvent) {
|
||||
if (reviews.some((r) => r.id === event.reviewId)) return;
|
||||
const refs = await this.users.refs([event.actorId, event.reviewerId]);
|
||||
reviews = [
|
||||
...reviews,
|
||||
{
|
||||
id: event.reviewId,
|
||||
requester: refs.of(event.actorId),
|
||||
reviewer: refs.of(event.reviewerId),
|
||||
note: event.note,
|
||||
requestedAt: event.occurredAt,
|
||||
verdict: null,
|
||||
answer: null,
|
||||
answeredAt: null,
|
||||
},
|
||||
];
|
||||
} else if (event instanceof IssueReviewAnsweredEvent) {
|
||||
reviews = reviews.map((r) =>
|
||||
r.id === event.reviewId
|
||||
? {
|
||||
...r,
|
||||
verdict: event.verdict,
|
||||
answer: event.answer,
|
||||
answeredAt: event.occurredAt,
|
||||
}
|
||||
: r,
|
||||
);
|
||||
} else {
|
||||
reviews = reviews.filter((r) => r.id !== event.reviewId);
|
||||
}
|
||||
// A review is not a change to the ticket itself: `updatedAt` stays.
|
||||
await this.upsertMaterializedIssueRepository.upsert({
|
||||
id: event.aggregateId,
|
||||
reviews,
|
||||
version: versionOf(event),
|
||||
});
|
||||
await this.notifier.issue(event.aggregateId, event);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
IssueLinkType,
|
||||
IssuePriority,
|
||||
IssueReadModel,
|
||||
ReviewVerdict,
|
||||
SYSTEM_ACTOR_ID,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { BulkChange, BulkResult, MAX_BULK } from './bulk-change';
|
||||
@@ -21,6 +22,11 @@ import {
|
||||
} from './commands/attach-file.command';
|
||||
import { DetachFileCommand } from './commands/detach-file.command';
|
||||
import { WatchIssueCommand } from './commands/watch-issue.command';
|
||||
import {
|
||||
AnswerReviewCommand,
|
||||
CancelReviewCommand,
|
||||
RequestReviewCommand,
|
||||
} from './commands/reviews';
|
||||
import { SetIssueParentCommand } from './commands/set-issue-parent.command';
|
||||
import { RankIssueCommand } from './commands/rank-issue.command';
|
||||
import { DeleteIssueCommand } from './commands/delete-issue.command';
|
||||
@@ -212,6 +218,41 @@ export class IssuesService {
|
||||
);
|
||||
}
|
||||
|
||||
/** Asks `reviewerId` (a member) to review the ticket. */
|
||||
requestReview(
|
||||
actorId: string,
|
||||
issueId: string,
|
||||
reviewerId: string,
|
||||
note: string | null,
|
||||
): Promise<CommandResult> {
|
||||
return this.commands.execute(
|
||||
new RequestReviewCommand(actorId, issueId, reviewerId, note),
|
||||
);
|
||||
}
|
||||
|
||||
/** The person asked answers: all good, or changes wanted. */
|
||||
answerReview(
|
||||
actorId: string,
|
||||
issueId: string,
|
||||
reviewId: string,
|
||||
verdict: ReviewVerdict,
|
||||
answer: string | null,
|
||||
): Promise<CommandResult> {
|
||||
return this.commands.execute(
|
||||
new AnswerReviewCommand(actorId, issueId, reviewId, verdict, answer),
|
||||
);
|
||||
}
|
||||
|
||||
cancelReview(
|
||||
actorId: string,
|
||||
issueId: string,
|
||||
reviewId: string,
|
||||
): Promise<CommandResult> {
|
||||
return this.commands.execute(
|
||||
new CancelReviewCommand(actorId, issueId, reviewId),
|
||||
);
|
||||
}
|
||||
|
||||
/** A ticket's file, with its bytes, for whoever can read the ticket. */
|
||||
download(
|
||||
viewerId: string | null,
|
||||
|
||||
@@ -5,6 +5,9 @@ import {
|
||||
IssueCreatedEvent,
|
||||
IssueParentChangedEvent,
|
||||
IssuePriority,
|
||||
IssueReviewAnsweredEvent,
|
||||
IssueReviewRequestedEvent,
|
||||
ReviewVerdict,
|
||||
IssueSprintChangedEvent,
|
||||
IssueStatusChangedEvent,
|
||||
IssueUpdatedEvent,
|
||||
@@ -69,6 +72,8 @@ const excerpt = (text: string, max = 120) => {
|
||||
IssueUpdatedEvent,
|
||||
IssueSprintChangedEvent,
|
||||
IssueParentChangedEvent,
|
||||
IssueReviewRequestedEvent,
|
||||
IssueReviewAnsweredEvent,
|
||||
CommentAddedEvent,
|
||||
ProjectMemberAddedEvent,
|
||||
)
|
||||
@@ -161,6 +166,29 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
|
||||
});
|
||||
}
|
||||
}
|
||||
if (event instanceof IssueReviewRequestedEvent) {
|
||||
const note = event.note ? `: «${excerpt(event.note)}»` : '';
|
||||
drafts.push({
|
||||
userId: event.reviewerId,
|
||||
kind: NotificationKind.REVIEW_REQUESTED,
|
||||
summary: `${actor} te pide que revises ${ticket}${note}`,
|
||||
});
|
||||
}
|
||||
if (event instanceof IssueReviewAnsweredEvent) {
|
||||
const review = ('reviews' in issue ? (issue.reviews ?? []) : []).find(
|
||||
(r) => r.id === event.reviewId,
|
||||
);
|
||||
const answer = event.answer ? `: «${excerpt(event.answer)}»` : '';
|
||||
if (review)
|
||||
drafts.push({
|
||||
userId: review.requester.id,
|
||||
kind: NotificationKind.REVIEW_ANSWERED,
|
||||
summary:
|
||||
event.verdict === ReviewVerdict.APPROVED
|
||||
? `${actor} revisó ${ticket} y lo da por bueno${answer}`
|
||||
: `${actor} revisó ${ticket} y pide cambios${answer}`,
|
||||
});
|
||||
}
|
||||
// A ticket new under a followed one (created there or moved in): the
|
||||
// only way to hear of it.
|
||||
if (event instanceof IssueParentChangedEvent && event.parentId) {
|
||||
|
||||
@@ -47,6 +47,8 @@ export interface IssueFilter {
|
||||
watching?: boolean;
|
||||
/** Only the ones this person follows. */
|
||||
watcherId?: string;
|
||||
/** Only the ones this person was asked to review and has not answered. */
|
||||
reviewerId?: string;
|
||||
/** Only the ones this person opened, has or follows (a guest's view). */
|
||||
participantId?: string;
|
||||
/** RECENT (default): last updated first. RANK: backlog order. */
|
||||
@@ -62,6 +64,8 @@ export interface CrossProjectIssueFilter {
|
||||
assigneeId?: string;
|
||||
/** Only the ones this person follows. */
|
||||
watcherId?: string;
|
||||
/** Only the ones this person was asked to review and has not answered. */
|
||||
reviewerId?: string;
|
||||
statuses?: string[];
|
||||
categories?: StatusCategory[];
|
||||
priorities?: IssuePriority[];
|
||||
|
||||
@@ -56,6 +56,7 @@ export class SearchMyIssuesQueryHandler
|
||||
: undefined,
|
||||
assigneeId: query.filter.assignedToMe ? query.viewerId : undefined,
|
||||
watcherId: query.filter.watching ? query.viewerId : undefined,
|
||||
reviewerId: query.filter.reviewing ? query.viewerId : undefined,
|
||||
statuses: query.filter.statuses,
|
||||
categories: filter.overdue
|
||||
? (filter.categories?.length ? filter.categories : open).filter((c) =>
|
||||
|
||||
@@ -11,6 +11,8 @@ export interface MyIssuesFilter {
|
||||
assignedToMe: boolean;
|
||||
/** Only the tickets the viewer follows. */
|
||||
watching?: boolean;
|
||||
/** Only the tickets the viewer was asked to review (unanswered). */
|
||||
reviewing?: boolean;
|
||||
statuses?: string[];
|
||||
/** e.g. TODO + IN_PROGRESS + QA = still open, whatever the project calls them. */
|
||||
categories?: StatusCategory[];
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -176,6 +176,10 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
|
||||
};
|
||||
if (filter.assigneeId) query['assignee.id'] = filter.assigneeId;
|
||||
if (filter.watcherId) query['watchers.id'] = filter.watcherId;
|
||||
if (filter.reviewerId)
|
||||
query['reviews'] = {
|
||||
$elemMatch: { 'reviewer.id': filter.reviewerId, verdict: null },
|
||||
};
|
||||
if (filter.statuses?.length) query['status'] = { $in: filter.statuses };
|
||||
if (filter.categories?.length)
|
||||
query['statusCategory'] = { $in: filter.categories };
|
||||
@@ -497,6 +501,10 @@ function toMongoFilter(filter: IssueFilter): FilterQuery<IssueDoc> {
|
||||
if (filter.unassigned) query['assignee'] = null;
|
||||
else if (filter.assigneeId) query['assignee.id'] = filter.assigneeId;
|
||||
if (filter.watcherId) query['watchers.id'] = filter.watcherId;
|
||||
if (filter.reviewerId)
|
||||
query['reviews'] = {
|
||||
$elemMatch: { 'reviewer.id': filter.reviewerId, verdict: null },
|
||||
};
|
||||
if (filter.participantId)
|
||||
query.$and = [
|
||||
...(query.$and ?? []),
|
||||
|
||||
@@ -524,6 +524,7 @@ export function issueFromDoc(doc: IssueDoc): IssueReadModel {
|
||||
// Tickets projected before attachments existed have none.
|
||||
attachments: rest.attachments ?? [],
|
||||
watchers: rest.watchers ?? [],
|
||||
reviews: rest.reviews ?? [],
|
||||
votes: rest.votes ?? 0,
|
||||
voterIds: rest.voterIds ?? [],
|
||||
parent: rest.parent?.id ? rest.parent : null,
|
||||
|
||||
@@ -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